Skip to content

Commit 435e433

Browse files
fsecada01claude
andcommitted
fix: accept form-encoded HTMX requests in FastAPI and Litestar adapters
Closes #15 — HTMX sends application/x-www-form-urlencoded by default, but the adapters only accepted application/json, causing 400 errors. - Both adapters now detect content-type and parse JSON or form data - Extracted shared _parse_request_data() and _extract_params() helpers - JSON string values in form fields (payload, params, state) are auto-parsed, matching the existing Django adapter behavior - Added python-multipart to [fastapi] extras (required by Starlette) - 4 new tests (2 per adapter) covering form-encoded mount and events Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 664cc38 commit 435e433

5 files changed

Lines changed: 189 additions & 114 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ fastapi = [
2828
"fastapi>=0.109.0",
2929
"uvicorn[standard]>=0.27.0",
3030
"jinjax>=0.41",
31+
"python-multipart>=0.0.5",
3132
]
3233
django = [
3334
"django>=4.2",

src/component_framework/adapters/fastapi.py

Lines changed: 64 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,70 @@
1818
logger = 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
async def component_endpoint(name: str, request: Request) -> JSONResponse:
2280
"""
2381
Generic component endpoint for FastAPI.
2482
2583
POST /components/{name}
26-
Body: {
84+
Body (JSON or form-encoded): {
2785
"event": "event_name",
2886
"payload": {...},
2987
"state": "serialized_state"
@@ -36,45 +94,16 @@ async def component_endpoint(name: str, request: Request) -> JSONResponse:
3694
}
3795
"""
3896
try:
39-
# Get component class
4097
component_cls = registry.get(name)
4198
if not component_cls:
4299
raise HTTPException(status_code=404, detail=f"Component '{name}' not found")
43100

44-
# Parse request data
45-
try:
46-
data = await request.json()
47-
except Exception as e:
48-
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
101+
data = await _parse_request_data(request)
102+
params, event, payload, state = _extract_params(data)
49103

50-
# Extract parameters
51-
params = data.get("params", {})
52-
event = data.get("event")
53-
payload_raw = data.get("payload", {})
54-
state_str = data.get("state")
55-
56-
# Guard against double-serialised payload from older client JS
57-
if isinstance(payload_raw, str):
58-
try:
59-
payload = json.loads(payload_raw)
60-
except (json.JSONDecodeError, ValueError):
61-
payload = {}
62-
else:
63-
payload = payload_raw
64-
65-
# Deserialize state if provided
66-
state = None
67-
if state_str:
68-
try:
69-
state = StateSerializer.deserialize(state_str)
70-
except Exception as e:
71-
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
72-
73-
# Create and dispatch component (async to support async on_* handlers)
74104
component = component_cls(**params)
75105
result = await component.async_dispatch(event=event, payload=payload, state=state)
76106

77-
# Serialize state for response
78107
result["state"] = StateSerializer.serialize(result["state"])
79108

80109
return JSONResponse(content=result)
@@ -91,7 +120,7 @@ async def stream_component_endpoint(name: str, request: Request) -> StreamingRes
91120
SSE streaming endpoint for long-running component operations.
92121
93122
POST /components/{name}/stream
94-
Body: same as component_endpoint
123+
Body (JSON or form-encoded): same as component_endpoint
95124
96125
Returns: text/event-stream with one ``data:`` frame per intermediate render.
97126
"""
@@ -106,30 +135,8 @@ async def stream_component_endpoint(name: str, request: Request) -> StreamingRes
106135
detail=f"Component '{name}' does not support streaming",
107136
)
108137

109-
try:
110-
data = await request.json()
111-
except Exception as e:
112-
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
113-
114-
params = data.get("params", {})
115-
event = data.get("event")
116-
payload_raw = data.get("payload", {})
117-
state_str = data.get("state")
118-
119-
if isinstance(payload_raw, str):
120-
try:
121-
payload = json.loads(payload_raw)
122-
except (json.JSONDecodeError, ValueError):
123-
payload = {}
124-
else:
125-
payload = payload_raw
126-
127-
state = None
128-
if state_str:
129-
try:
130-
state = StateSerializer.deserialize(state_str)
131-
except Exception as e:
132-
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
138+
data = await _parse_request_data(request)
139+
params, event, payload, state = _extract_params(data)
133140

134141
component = component_cls(**params)
135142

src/component_framework/adapters/litestar.py

Lines changed: 64 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,71 @@
1818
logger = 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}")
2280
async 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

tests/test_fastapi_adapter.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,36 @@ def test_mount_with_no_params(self, client):
115115
deserialized = json.loads(data["state"])
116116
assert deserialized["count"] == 0
117117

118+
def test_form_encoded_mount(self, client):
119+
"""HTMX sends form-encoded by default — adapter must accept it."""
120+
response = client.post(
121+
"/components/test_counter",
122+
data={"params": '{"initial": 5}'},
123+
)
124+
assert response.status_code == 200
125+
data = response.json()
126+
deserialized = json.loads(data["state"])
127+
assert deserialized["count"] == 5
128+
129+
def test_form_encoded_event(self, client):
130+
"""HTMX hx-vals are merged into form fields."""
131+
# Mount first (JSON)
132+
r1 = client.post("/components/test_counter", json={})
133+
state = r1.json()["state"]
134+
135+
# Fire event via form-encoded (HTMX style)
136+
r2 = client.post(
137+
"/components/test_counter",
138+
data={
139+
"event": "increment",
140+
"payload": '{"amount": 7}',
141+
"state": state,
142+
},
143+
)
144+
assert r2.status_code == 200
145+
deserialized = json.loads(r2.json()["state"])
146+
assert deserialized["count"] == 7
147+
118148

119149
# ---------- create_component_routes ----------
120150

0 commit comments

Comments
 (0)