response-state-design.md 12 KB

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

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

// 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.

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:

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<string, string> _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:

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

public class LoginFormComponent : Component {
    
    private SessionService _session_service = inject<SessionService>();
    
    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

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

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

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<string, string> 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:

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