# Response State Design for Spry Components ## Problem Statement Authentication works correctly (user is found, password verified), but: 1. **Cookie is not being set** - The `Set-Cookie` header is set on a discarded `HttpResult` 2. **Redirect is not happening** - `hx-redirect` is set as an HTML attribute instead of an HTTP header ### Root Cause Analysis #### Root Cause #1: Cookie Set on Discarded HttpResult ```mermaid sequenceDiagram participant CE as ComponentEndpoint participant C as Component participant SS as SessionService CE->>C: handle_action - login C->>C: to_result - creates HttpResult A C->>SS: set_session_cookie - Result A, token SS->>SS: result.set_header - Set-Cookie on A Note over C: Result A is discarded - async void! C-->>CE: void return CE->>C: to_result - creates NEW HttpResult B C-->>CE: Result B - no cookie! ``` The problem: `handle_action()` is `async void` - it cannot return an `HttpResult`. The cookie is set on a temporary result that gets discarded. #### Root Cause #2: HTML Attribute vs HTTP Header ```vala // WRONG - This sets an HTML attribute this[login-form].set_attribute(hx-redirect, redirect_url); // CORRECT - This sets an HTTP response header result.set_header(HX-Redirect, redirect_url); ``` HTMX requires `HX-Redirect` as an **HTTP response header**, not an HTML attribute. --- ## Proposed Solution: ResponseState ### Design Overview Add a `ResponseState` class that components use to accumulate response modifications during action handling. The state is then applied when `to_result()` creates the final `HttpResult`. ```mermaid sequenceDiagram participant CE as ComponentEndpoint participant C as Component participant RS as ResponseState participant SS as SessionService CE->>C: handle_action - login C->>RS: add_header - Set-Cookie, token C->>RS: redirect - /dashboard C-->>CE: void return - state accumulated CE->>C: to_result C->>C: create HttpResult C->>RS: apply_to - result RS->>C: result.set_header - Set-Cookie RS->>C: result.set_header - HX-Redirect C-->>CE: Result with headers! ``` ### Key Components #### 1. ResponseState Class A new class to hold response modifications: ```vala namespace Spry { /** * Redirect type for component responses. */ public enum RedirectType { /** Client-side redirect using HX-Redirect header - default for HTMX */ CLIENT_SIDE, /** HTTP 302 temporary redirect using Location header */ TEMPORARY, /** HTTP 301 permanent redirect using Location header */ PERMANENT } /** * Holds response state that will be applied to the final HttpResult. * * Components can modify this during action handling to influence * the HTTP response without needing to return an HttpResult directly. */ public class ResponseState : GLib.Object { // Headers to add to the response private HashTable _headers; // HTTP status code override private StatusCode? _status_code = null; // Redirect configuration private string? _redirect_url = null; private RedirectType _redirect_type = RedirectType.CLIENT_SIDE; /** * Adds a header to the response. * If the header already exists, it will be replaced. */ public void set_header(string name, string value) { ... } /** * Adds a header to the response. * Multiple headers with the same name can be added. */ public void add_header(string name, string value) { ... } /** * Sets the HTTP status code for the response. */ public void set_status(StatusCode status) { ... } /** * Sets up a redirect. * Default type is CLIENT_SIDE which uses HX-Redirect header. */ public void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) { ... } /** * Applies all accumulated state to an HttpResult. * Called internally by Component.to_result(). */ internal void apply_to(HttpResult result) { ... } /** * Clears all accumulated state. * Called after to_result() to prepare for next request. */ internal void reset() { ... } } } ``` #### 2. Component Changes Add `ResponseState` property and convenience methods to the base `Component` class: ```vala public abstract class Component : Object, Renderable { // Response state for action handlers private ResponseState _response_state = new ResponseState(); /** * The response state for this component. * Modify during handle_action to influence the HTTP response. */ public ResponseState response { get { return _response_state; } } // Convenience methods that delegate to response_state: /** * Adds a header to the response. */ protected void set_header(string name, string value) { _response_state.set_header(name, value); } /** * Sets the HTTP status code. */ protected void set_status(StatusCode status) { _response_state.set_status(status); } /** * Sets up a redirect. * Default type is CLIENT_SIDE which uses HX-Redirect header. */ protected void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) { _response_state.redirect(url, type); } // Modified to_result(): public async HttpResult to_result() throws Error { var document = yield to_document(); var result = document.to_result(get_status()); // Apply accumulated response state _response_state.apply_to(result); // Reset state for next request _response_state.reset(); return result; } } ``` --- ## Usage Examples ### Example 1: Login with Cookie and Redirect ```vala public class LoginFormComponent : Component { private SessionService _session_service = inject(); public override async void handle_action(string action) throws Error { if (action == "login") { yield handle_login(); } } private async void handle_login() throws Error { // ... authenticate user ... // Create session var session = yield _session_service.create_session_async(user.id); var token = _session_service.generate_session_token(session); // Set cookie using convenience method var cookie_value = @"spry_session=$token; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Strict"; set_header("Set-Cookie", cookie_value); // Set up HTMX redirect redirect("/dashboard"); } } ``` ### Example 2: Unauthorized Response ```vala public override async void handle_action(string action) throws Error { if (!user_is_admin) { set_status(StatusCode.UNAUTHORIZED); error_message = "Admin access required"; return; } // ... handle action ... } ``` ### Example 3: HTTP 302 Redirect ```vala public override async void handle_action(string action) throws Error { if (action == "logout") { // Clear session yield _session_service.delete_session_async(session_id); // HTTP redirect to login page redirect("/login", RedirectType.TEMPORARY); } } ``` ### Example 4: Custom Headers ```vala public override async void handle_action(string action) throws Error { // Set custom response headers set_header("X-Custom-Header", "value"); set_header("Cache-Control", "no-cache"); // Multiple Set-Cookie headers add_header("Set-Cookie", "pref=dark; Path=/"); add_header("Set-Cookie", "lang=en; Path=/"); } ``` --- ## Implementation Details ### Header Accumulation Strategy Headers use a `HashTable` with the following behavior: - `set_header()`: Replaces existing header with same name - `add_header()`: Appends to header list - for headers that can appear multiple times like `Set-Cookie` ### Redirect Implementation The `apply_to()` method handles redirects: ```vala internal void apply_to(HttpResult result) { // Apply headers first foreach (var header in _headers) { result.set_header(header.key, header.value); } // Handle redirect if (_redirect_url != null) { switch (_redirect_type) { case RedirectType.CLIENT_SIDE: result.set_header("HX-Redirect", _redirect_url); break; case RedirectType.TEMPORARY: result.set_header("Location", _redirect_url); result.set_status(StatusCode.FOUND); // 302 break; case RedirectType.PERMANENT: result.set_header("Location", _redirect_url); result.set_status(StatusCode.MOVED_PERMANENTLY); // 301 break; } } // Apply status code if set - redirects may override this if (_status_code != null && _redirect_url == null) { result.set_status(_status_code); } } ``` ### Interaction with get_status() The existing `get_status()` virtual method continues to work for initial page renders. `ResponseState` status only applies when set during action handling. Priority order: 1. Redirect status (301/302) if redirect is set 2. ResponseState status if set 3. `get_status()` return value --- ## Files to Modify | File | Changes | |------|---------| | `src/ResponseState.vala` | **NEW** - ResponseState class | | `src/Component.vala` | Add ResponseState property, convenience methods, modify to_result | | `src/Users/Components/LoginFormComponent.vala` | Use new API for cookie and redirect | | `src/Users/SessionService.vala` | May need minor updates for cookie helper | | `src/meson.build` | Add ResponseState.vala to build | --- ## API Summary ### New Classes - `Spry.ResponseState` - Holds response modifications - `Spry.RedirectType` - Enum for redirect types ### Component Additions | Method | Description | |--------|-------------| | `response` | Property to access ResponseState | | `set_header(name, value)` | Set a response header | | `add_header(name, value)` | Add a response header | | `set_status(status)` | Set HTTP status code | | `redirect(url, type)` | Redirect with optional type - default is CLIENT_SIDE | ### ResponseState Methods | Method | Description | |--------|-------------| | `set_header(name, value)` | Set/replace a header | | `add_header(name, value)` | Add a header | | `set_status(status)` | Set status code | | `redirect(url, type)` | Redirect with optional type - default is CLIENT_SIDE | | `has_modifications()` | Check if any state is set | --- ## Questions for Consideration 1. **Should headers be accumulated or replaced by default?** - Current design: `set_header` replaces, `add_header` appends - Alternative: Always accumulate, provide `replace_header` for explicit replacement 2. **Should there be a distinction between HTMX redirects and HTTP redirects?** - Current design: Yes, via `RedirectType` enum - Default is HTMX redirect since Spry is HTMX-focused 3. **How should this interact with prepare and to_result?** - Current design: State is applied in `to_result()`, then reset - Alternative: Apply in `ComponentEndpoint` after `to_result()` 4. **Should there be convenience methods like set_cookie?** - Could add `set_cookie(name, value, options)` helper - Would encapsulate cookie formatting logic 5. **What happens if both redirect and status are set?** - Current design: Redirect takes precedence for status code - Headers are still applied --- ## Next Steps 1. Review and approve this design 2. Implement `ResponseState.vala` 3. Modify `Component.vala` to integrate ResponseState 4. Update `LoginFormComponent.vala` to use new API 5. Test login flow with cookie and redirect 6. Update documentation