component_framework.adapters.litestar

Litestar adapter for component endpoints.

  1"""Litestar adapter for component endpoints."""
  2
  3import json
  4import logging
  5
  6try:
  7    from litestar import Request, post
  8    from litestar.exceptions import HTTPException
  9    from litestar.response import Response, Stream
 10except ImportError as e:
 11    from . import _require_extra
 12
 13    raise _require_extra("litestar", "litestar") from e
 14
 15from ..core import StateSerializer, registry
 16from ..core.streaming import StreamingComponent, format_sse_frame
 17
 18logger = logging.getLogger(__name__)
 19
 20
 21def _parse_json_str(value: str | dict | None, default: dict | None = None) -> dict | None:
 22    """Parse a value that may be a JSON string, a dict, or None."""
 23    if value is None:
 24        return default
 25    if isinstance(value, dict):
 26        return value
 27    try:
 28        return json.loads(value)
 29    except (json.JSONDecodeError, ValueError):
 30        return default
 31
 32
 33async def _parse_request_data(request: Request) -> dict:
 34    """Parse request body from JSON or form-encoded data.
 35
 36    HTMX sends ``application/x-www-form-urlencoded`` by default; the JS
 37    ``component-client.js`` sends ``application/json``.  This helper
 38    normalises both into a dict with ``event``, ``payload``, ``state``,
 39    and ``params`` keys.
 40    """
 41    content_type = request.headers.get("content-type", "")
 42
 43    if "application/json" in content_type:
 44        try:
 45            data = await request.json()
 46        except Exception as e:
 47            raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
 48    else:
 49        # Form-encoded (HTMX default) — hx-vals are merged into form fields
 50        form = await request.form()
 51        data = dict(form)
 52
 53    return data
 54
 55
 56def _extract_params(data: dict) -> tuple[dict, str | None, dict, dict | None]:
 57    """Extract and normalise event, payload, state, and params from parsed data.
 58
 59    Returns:
 60        (params, event, payload, state) tuple ready for component dispatch.
 61    """
 62    params = _parse_json_str(data.get("params"), default={}) or {}
 63    event = data.get("event")
 64    payload = _parse_json_str(data.get("payload"), default={}) or {}
 65    state_raw = data.get("state")
 66
 67    state = None
 68    if state_raw:
 69        try:
 70            state = (
 71                StateSerializer.deserialize(state_raw) if isinstance(state_raw, str) else state_raw
 72            )
 73        except Exception as e:
 74            raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
 75
 76    return params, event, payload, state
 77
 78
 79@post("/components/{name:str}")
 80async def component_endpoint(name: str, request: Request) -> Response:
 81    """
 82    Generic component endpoint for Litestar.
 83
 84    POST /components/{name}
 85    Body (JSON or form-encoded): {
 86        "event": "event_name",
 87        "payload": {...},
 88        "state": "serialized_state"
 89    }
 90
 91    Returns: {
 92        "html": "rendered_html",
 93        "state": "serialized_state",
 94        "component_id": "component-id"
 95    }
 96    """
 97    try:
 98        component_cls = registry.get(name)
 99        if not component_cls:
100            raise HTTPException(status_code=404, detail=f"Component '{name}' not found")
101
102        data = await _parse_request_data(request)
103        params, event, payload, state = _extract_params(data)
104
105        component = component_cls(**params)
106        result = await component.async_dispatch(event=event, payload=payload, state=state)
107
108        result["state"] = StateSerializer.serialize(result["state"])
109
110        return Response(content=result, media_type="application/json", status_code=200)
111
112    except HTTPException:
113        raise
114    except Exception:
115        logger.exception(f"Error processing component '{name}'")
116        raise HTTPException(status_code=500, detail="Internal server error")
117
118
119@post("/components/{name:str}/stream")
120async def stream_component_endpoint(name: str, request: Request) -> Stream:
121    """
122    SSE streaming endpoint for long-running component operations.
123
124    POST /components/{name}/stream
125    Body (JSON or form-encoded): same as component_endpoint
126
127    Returns: text/event-stream with one ``data:`` frame per intermediate render.
128    """
129    component_cls = registry.get(name)
130    if not component_cls:
131        raise HTTPException(status_code=404, detail=f"Component '{name}' not found")
132
133    if not issubclass(component_cls, StreamingComponent):
134        raise HTTPException(
135            status_code=400,
136            detail=f"Component '{name}' does not support streaming",
137        )
138
139    data = await _parse_request_data(request)
140    params, event, payload, state = _extract_params(data)
141
142    component = component_cls(**params)
143
144    async def event_generator():
145        async for frame in component.async_stream_dispatch(
146            event=event, payload=payload, state=state
147        ):
148            frame["state"] = StateSerializer.serialize(frame["state"])
149            yield format_sse_frame(frame)
150
151    return Stream(event_generator(), media_type="text/event-stream", status_code=200)
152
153
154def create_component_routes(app):
155    """
156    Register component endpoint handlers with a Litestar app.
157
158    Registers both the standard POST endpoint and the SSE streaming endpoint.
159
160    Usage:
161        from litestar import Litestar
162        from component_framework.adapters.litestar import create_component_routes
163
164        app = Litestar(route_handlers=[])
165        create_component_routes(app)
166
167    Alternatively, pass the handlers directly at app creation:
168        from component_framework.adapters.litestar import (
169            component_endpoint,
170            stream_component_endpoint,
171        )
172
173        app = Litestar(route_handlers=[component_endpoint, stream_component_endpoint])
174    """
175    app.register(component_endpoint)
176    app.register(stream_component_endpoint)
logger = <Logger component_framework.adapters.litestar (WARNING)>
def create_component_routes(app):
155def create_component_routes(app):
156    """
157    Register component endpoint handlers with a Litestar app.
158
159    Registers both the standard POST endpoint and the SSE streaming endpoint.
160
161    Usage:
162        from litestar import Litestar
163        from component_framework.adapters.litestar import create_component_routes
164
165        app = Litestar(route_handlers=[])
166        create_component_routes(app)
167
168    Alternatively, pass the handlers directly at app creation:
169        from component_framework.adapters.litestar import (
170            component_endpoint,
171            stream_component_endpoint,
172        )
173
174        app = Litestar(route_handlers=[component_endpoint, stream_component_endpoint])
175    """
176    app.register(component_endpoint)
177    app.register(stream_component_endpoint)

Register component endpoint handlers with a Litestar app.

Registers both the standard POST endpoint and the SSE streaming endpoint.

Usage:

from litestar import Litestar from component_framework.adapters.litestar import create_component_routes

app = Litestar(route_handlers=[]) create_component_routes(app)

Alternatively, pass the handlers directly at app creation: from component_framework.adapters.litestar import ( component_endpoint, stream_component_endpoint, )

app = Litestar(route_handlers=[component_endpoint, stream_component_endpoint])