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])