1818logger = logging .getLogger (__name__ )
1919
2020
21+ def _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+
33+ async 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+
56+ def _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+
2179@post ("/components/{name:str}" )
2280async def component_endpoint (name : str , request : Request ) -> Response :
2381 """
2482 Generic component endpoint for Litestar.
2583
2684 POST /components/{name}
27- Body: {
85+ Body (JSON or form-encoded) : {
2886 "event": "event_name",
2987 "payload": {...},
3088 "state": "serialized_state"
@@ -37,45 +95,16 @@ async def component_endpoint(name: str, request: Request) -> Response:
3795 }
3896 """
3997 try :
40- # Get component class
4198 component_cls = registry .get (name )
4299 if not component_cls :
43100 raise HTTPException (status_code = 404 , detail = f"Component '{ name } ' not found" )
44101
45- # Parse request data
46- try :
47- data = await request .json ()
48- except Exception as e :
49- raise HTTPException (status_code = 400 , detail = f"Invalid JSON: { e } " )
102+ data = await _parse_request_data (request )
103+ params , event , payload , state = _extract_params (data )
50104
51- # Extract parameters
52- params = data .get ("params" , {})
53- event = data .get ("event" )
54- payload_raw = data .get ("payload" , {})
55- state_str = data .get ("state" )
56-
57- # Guard against double-serialised payload from older client JS
58- if isinstance (payload_raw , str ):
59- try :
60- payload = json .loads (payload_raw )
61- except (json .JSONDecodeError , ValueError ):
62- payload = {}
63- else :
64- payload = payload_raw
65-
66- # Deserialize state if provided
67- state = None
68- if state_str :
69- try :
70- state = StateSerializer .deserialize (state_str )
71- except Exception as e :
72- raise HTTPException (status_code = 400 , detail = f"Invalid state: { e } " )
73-
74- # Create and dispatch component (async to support async on_* handlers)
75105 component = component_cls (** params )
76106 result = await component .async_dispatch (event = event , payload = payload , state = state )
77107
78- # Serialize state for response
79108 result ["state" ] = StateSerializer .serialize (result ["state" ])
80109
81110 return Response (content = result , media_type = "application/json" , status_code = 200 )
@@ -93,7 +122,7 @@ async def stream_component_endpoint(name: str, request: Request) -> Stream:
93122 SSE streaming endpoint for long-running component operations.
94123
95124 POST /components/{name}/stream
96- Body: same as component_endpoint
125+ Body (JSON or form-encoded) : same as component_endpoint
97126
98127 Returns: text/event-stream with one ``data:`` frame per intermediate render.
99128 """
@@ -107,30 +136,8 @@ async def stream_component_endpoint(name: str, request: Request) -> Stream:
107136 detail = f"Component '{ name } ' does not support streaming" ,
108137 )
109138
110- try :
111- data = await request .json ()
112- except Exception as e :
113- raise HTTPException (status_code = 400 , detail = f"Invalid JSON: { e } " )
114-
115- params = data .get ("params" , {})
116- event = data .get ("event" )
117- payload_raw = data .get ("payload" , {})
118- state_str = data .get ("state" )
119-
120- if isinstance (payload_raw , str ):
121- try :
122- payload = json .loads (payload_raw )
123- except (json .JSONDecodeError , ValueError ):
124- payload = {}
125- else :
126- payload = payload_raw
127-
128- state = None
129- if state_str :
130- try :
131- state = StateSerializer .deserialize (state_str )
132- except Exception as e :
133- raise HTTPException (status_code = 400 , detail = f"Invalid state: { e } " )
139+ data = await _parse_request_data (request )
140+ params , event , payload , state = _extract_params (data )
134141
135142 component = component_cls (** params )
136143
0 commit comments