From c77e43aab3e874dfed9f0bc21309089874885f2b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 15:05:36 +0100 Subject: [PATCH 1/6] fix(hosting,inertia): Inertia body validation redirects back with field errors; faster catalog-carrying pages An Inertia form whose JSON body fails FastAPI validation rendered the 422 'invalid parameters' Error page over the form; it now redirects back with one error per field (error-bag aware), like redirect_back_with_errors. GETs and non-Inertia requests keep the error page / JSON. Perf: the resolver checks scalars before _is_dynamic, and encode_page skips jsonable_encoder for plain-JSON pages. A page carrying the 48KB i18n catalog drops from 2.27 ms to 0.32 ms in resolve+encode. Found with the inertia_lab feature lab. Claude-Session: https://claude.ai/code/session_01FXHA3ZJ5EgoJN4zXTAhBR2 --- CHANGELOG.md | 8 ++ .../simple_module_hosting/_error_handlers.py | 10 +++ .../test_hosting_inertia_body_validation.py | 89 +++++++++++++++++++ framework/inertia/README.md | 2 +- .../inertia/simple_module_inertia/page.py | 29 ++++++ .../inertia/simple_module_inertia/resolve.py | 7 +- framework/inertia/tests/test_adapter_page.py | 27 ++++++ 7 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 framework/hosting/tests/test_hosting_inertia_body_validation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 471941d40..a3deac657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,14 @@ All notable changes to this project are documented in this file. The format is b `account` or whose `azp` is another client (only `oidc` pins both). ### Fixed +- **Behaviour change:** an Inertia form submission (POST/PUT/PATCH/DELETE) + whose body fails FastAPI request validation now redirects back (303) with + one error per field, scoped by `X-Inertia-Error-Bag`, instead of rendering + the 422 "invalid parameters" Error page over the form. GETs and non-Inertia + requests are unchanged. +- Inertia responses that carry the shared i18n catalog (every first page load) + render about 1.9 ms faster: the prop resolver tests scalars before its + dynamic-prop check, and plain-JSON pages skip `jsonable_encoder`. - **Behaviour change:** `LocaleMiddleware` never picks a language the browser sends with `q=0` ("not acceptable"), including through a regional tag such as `es-MX;q=0` and when `q` follows another parameter or is spelled `Q`; diff --git a/framework/hosting/simple_module_hosting/_error_handlers.py b/framework/hosting/simple_module_hosting/_error_handlers.py index 4db296b79..b17c1bfe0 100644 --- a/framework/hosting/simple_module_hosting/_error_handlers.py +++ b/framework/hosting/simple_module_hosting/_error_handlers.py @@ -9,12 +9,14 @@ from fastapi.responses import JSONResponse from simple_module_core.exceptions import NotFoundError from simple_module_core.permission_denied import PermissionDenied +from simple_module_inertia import errors_from_validation, is_inertia_request from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import Response from simple_module_hosting._error_negotiation import _wants_json from simple_module_hosting._error_page import render_error_page +from simple_module_hosting.inertia_utils import redirect_back_with_errors from simple_module_hosting._observability import CorrelationIdMiddleware logger = logging.getLogger(__name__) @@ -51,7 +53,15 @@ async def request_validation_error_handler( Same negotiation rule as 403/404/500 (``_wants_json``), so one request never sees two different error shapes depending on the error class. + + An Inertia form submission (any method but GET) is the exception: like a + hand-written ``redirect_back_with_errors``, it goes back to the form with + one message per field, so ``useForm`` shows them in place. A GET keeps + the error page, since redirecting back there could loop. """ + if is_inertia_request(request.scope) and request.method != "GET": + errors = errors_from_validation(exc.errors(), request_source=True) + return redirect_back_with_errors(request, errors) if _wants_json(request): return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())}) return await render_error_page(request, 422, "The requested URL contains invalid parameters.") diff --git a/framework/hosting/tests/test_hosting_inertia_body_validation.py b/framework/hosting/tests/test_hosting_inertia_body_validation.py new file mode 100644 index 000000000..79c07cede --- /dev/null +++ b/framework/hosting/tests/test_hosting_inertia_body_validation.py @@ -0,0 +1,89 @@ +"""An Inertia form whose body fails FastAPI validation goes back with field errors. + +Before, hosting's request-validation handler answered every browser-shaped +request with the 422 Error page ("The requested URL contains invalid +parameters."), so an Inertia ``useForm`` POST replaced the form with an error +page instead of showing messages next to its fields. +""" + +from __future__ import annotations + +import httpx +import pytest +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_test import inertia_page +from sqlmodel import SQLModel + +_FORM = "/__test/body-validation/form" +_SUBMIT = "/__test/body-validation/submit" + + +def _inertia(client: httpx.AsyncClient) -> dict[str, str]: + return {"X-Inertia": "true", "Referer": f"{client.base_url}{_FORM}"} + + +class _Signup(SQLModel): + name: str + age: int + + +@pytest.fixture +async def form_client(app, authenticated_client: httpx.AsyncClient) -> httpx.AsyncClient: + async def form(inertia: InertiaDep): + return await inertia.render("Probe/Form") + + async def submit(data: _Signup) -> dict[str, str]: + return {"name": data.name} + + async def lookup(age: int) -> dict[str, int]: + return {"age": age} + + app.router.add_api_route(_FORM, form, methods=["GET"]) + app.router.add_api_route(_SUBMIT, submit, methods=["POST", "PUT", "PATCH"]) + app.router.add_api_route(_SUBMIT, lookup, methods=["GET"]) + return authenticated_client + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) +async def test_inertia_submission_redirects_back_with_field_errors( + form_client: httpx.AsyncClient, method: str +) -> None: + resp = await form_client.request( + method, _SUBMIT, json={"age": "old"}, headers=_inertia(form_client) + ) + assert resp.status_code == 303 + assert resp.headers["location"].endswith(_FORM) + + page = inertia_page(await form_client.get(_FORM, headers={"X-Inertia": "true"})) + assert set(page.props["errors"]) == {"name", "age"} + assert page.props["errors"]["age"].startswith("Input should be a valid integer") + + +async def test_error_bag_scopes_the_errors(form_client: httpx.AsyncClient) -> None: + headers = {**_inertia(form_client), "X-Inertia-Error-Bag": "signup"} + resp = await form_client.post(_SUBMIT, json={}, headers=headers) + assert resp.status_code == 303 + + page = inertia_page(await form_client.get(_FORM, headers={"X-Inertia": "true"})) + assert set(page.props["errors"]) == {"signup"} + assert set(page.props["errors"]["signup"]) == {"name", "age"} + + +async def test_inertia_get_with_bad_query_keeps_the_error_page( + form_client: httpx.AsyncClient, +) -> None: + resp = await form_client.get(f"{_SUBMIT}?age=old", headers={"X-Inertia": "true"}) + assert resp.status_code == 422 + assert inertia_page(resp).component == "Error" + + +async def test_plain_browser_post_keeps_the_error_page(form_client: httpx.AsyncClient) -> None: + resp = await form_client.post(_SUBMIT, json={}, headers={"Accept": "text/html"}) + assert resp.status_code == 422 + assert inertia_page(resp).component == "Error" + + +async def test_api_client_keeps_json_detail(form_client: httpx.AsyncClient) -> None: + resp = await form_client.post(_SUBMIT, json={}, headers={"Accept": "application/json"}) + assert resp.status_code == 422 + assert {err["loc"][-1] for err in resp.json()["detail"]} == {"name", "age"} diff --git a/framework/inertia/README.md b/framework/inertia/README.md index 4d1de97ec..3df9b6111 100644 --- a/framework/inertia/README.md +++ b/framework/inertia/README.md @@ -26,7 +26,7 @@ features still require the corresponding Inertia client API. | Redirects and middleware | Implemented | `inertia.redirect()` (and `back()`) answer 303 for non-GET requests and 307 for GET; middleware also converts 302/307 after PUT/PATCH/DELETE/QUERY. Redirects whose target has a `#fragment` become an empty 409 with `X-Inertia-Redirect` (`inertia.redirect_with_fragment()` emits it directly; the automatic conversion skips prefetch). Explicitly empty 200 responses redirect back with 303; HTML and Inertia responses include `Vary: X-Inertia`. | | External navigation | Implemented | `inertia.location(url)` returns 409 with `X-Inertia-Location` for a client-side full-page navigation. | | History and fragments | Implemented | `inertia.encrypt_history()`, `inertia.clear_history()`, and `inertia.preserve_fragment()` emit `encryptHistory`, `clearHistory`, and `preserveFragment`; the client performs encryption and history updates. | -| Validation | Implemented | Validation conversion keeps the first message per top-level field; `X-Inertia-Error-Bag` scopes session errors, which render only with `use_flash_errors` (hosting enables it). The standalone validation handler redirects back; hosting's form helper does likewise, while hosting's request-validation handler renders an error page. | +| Validation | Implemented | Validation conversion keeps the first message per top-level field; `X-Inertia-Error-Bag` scopes session errors, which render only with `use_flash_errors` (hosting enables it). The standalone validation handler redirects back; hosting's form helper does likewise, and hosting's request-validation handler does too for Inertia submissions (any method but GET), while an Inertia GET with invalid query parameters gets the 422 error page. | | Hosting error pages | Implemented | `simple_module_hosting` renders the `Error` component for supported browser/Inertia errors, preserving the HTTP status and exception headers; API requests retain JSON errors. | | Test helper | Implemented | `simple_module_test.InertiaPage.from_response(response)` (or `inertia_page(response)`) parses HTML/JSON pages, offers fluent page/prop assertions, and follows partial reloads and deferred groups with `reload_only`, `reload_except`, and `load_deferred`. | | SSR | Not implemented | No SSR client: `ssr_url` and the template's SSR branches are unused, and `ssr_enabled` only makes asset resolution read the Vite manifest. Tracked in [#580](https://github.com/antosubash/simple_module_python/issues/580). | diff --git a/framework/inertia/simple_module_inertia/page.py b/framework/inertia/simple_module_inertia/page.py index 3eaa2c634..85c263f6e 100644 --- a/framework/inertia/simple_module_inertia/page.py +++ b/framework/inertia/simple_module_inertia/page.py @@ -98,8 +98,37 @@ def _locate_failure(value: Any, path: str) -> str: return path +_JSON_LEAVES = frozenset({str, int, float, bool, type(None)}) + + +def _is_plain_json(value: Any) -> bool: + """True when ``value`` is built only from exact JSON types. + + Exact ``type()`` checks keep subclasses (``StrEnum``, ``IntEnum``) on the + ``jsonable_encoder`` path, which converts them; plain data, such as the + shared i18n catalog, skips a full encoder walk that costs about 5x a + ``json.dumps`` of the same dict. + """ + stack = [value] + while stack: + item = stack.pop() + kind = type(item) + if kind is dict: + for key, child in item.items(): + if type(key) is not str: + return False + stack.append(child) + elif kind is list: + stack.extend(item) + elif kind not in _JSON_LEAVES: + return False + return True + + def encode_page(page: dict[str, Any]) -> Any: """JSON-safe structure for both render branches; names the failing prop.""" + if _is_plain_json(page): + return page try: return jsonable_encoder(page) except Exception as exc: diff --git a/framework/inertia/simple_module_inertia/resolve.py b/framework/inertia/simple_module_inertia/resolve.py index 7d9141eaf..100452e2c 100644 --- a/framework/inertia/simple_module_inertia/resolve.py +++ b/framework/inertia/simple_module_inertia/resolve.py @@ -116,8 +116,11 @@ async def _resolve_tree( for key, raw in props.items(): # Fast path: plain JSON leaves and containers carry no prop flags, so # they skip the Prop wrapper; only a partial filter needs their path. - if not _is_dynamic(raw): - if type(raw) in _SCALARS and not filtering: + # The scalar test runs first: it is far cheaper than _is_dynamic, + # and shared data such as the i18n catalog is hundreds of strings. + scalar = type(raw) in _SCALARS + if scalar or not _is_dynamic(raw): + if scalar and not filtering: result[key] = raw continue path = f"{prefix}.{key}" if prefix else key diff --git a/framework/inertia/tests/test_adapter_page.py b/framework/inertia/tests/test_adapter_page.py index 43c84ff12..77a0c40e2 100644 --- a/framework/inertia/tests/test_adapter_page.py +++ b/framework/inertia/tests/test_adapter_page.py @@ -148,3 +148,30 @@ class Opaque: } with pytest.raises(PropEncodingError, match=r"props\.user\.avatar"): encode_page(page) + + +class TestPlainJsonFastPath: + def test_plain_page_is_returned_as_is(self) -> None: + page = { + "component": "C", + "props": {"i18n": {"messages": {"a.b": "x"}}, "n": [1, 2.5, True, None]}, + "url": "/", + "version": "v", + } + assert encode_page(page) is page + + def test_enum_and_tuple_still_take_the_encoder(self) -> None: + from enum import StrEnum + + class Colour(StrEnum): + RED = "red" + + page = {"component": "C", "props": {"c": Colour.RED, "t": (1, 2)}, "url": "/"} + encoded = encode_page(page) + assert encoded is not page + assert type(encoded["props"]["c"]) is str + assert encoded["props"]["t"] == [1, 2] + + def test_non_string_keys_take_the_encoder(self) -> None: + page = {"component": "C", "props": {"by_id": {1: "a"}}, "url": "/"} + assert encode_page(page) is not page From 9a48411bc7a775e1665d7f5dadddcf49247e1cc9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 15:06:00 +0100 Subject: [PATCH 2/6] style: import order --- framework/hosting/simple_module_hosting/_error_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/hosting/simple_module_hosting/_error_handlers.py b/framework/hosting/simple_module_hosting/_error_handlers.py index b17c1bfe0..6f59507e7 100644 --- a/framework/hosting/simple_module_hosting/_error_handlers.py +++ b/framework/hosting/simple_module_hosting/_error_handlers.py @@ -16,8 +16,8 @@ from simple_module_hosting._error_negotiation import _wants_json from simple_module_hosting._error_page import render_error_page -from simple_module_hosting.inertia_utils import redirect_back_with_errors from simple_module_hosting._observability import CorrelationIdMiddleware +from simple_module_hosting.inertia_utils import redirect_back_with_errors logger = logging.getLogger(__name__) From a4c4263ae06ba5363a2531479e144d08010c2932 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 15:16:16 +0100 Subject: [PATCH 3/6] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01FXHA3ZJ5EgoJN4zXTAhBR2 --- .../inertia/simple_module_inertia/page.py | 29 ++++++++++++------- .../inertia/simple_module_inertia/resolve.py | 6 ++-- framework/inertia/tests/test_adapter_page.py | 7 +++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/framework/inertia/simple_module_inertia/page.py b/framework/inertia/simple_module_inertia/page.py index 85c263f6e..247f9541e 100644 --- a/framework/inertia/simple_module_inertia/page.py +++ b/framework/inertia/simple_module_inertia/page.py @@ -16,7 +16,7 @@ from fastapi.encoders import jsonable_encoder -from simple_module_inertia.resolve import ResolvedProps +from simple_module_inertia.resolve import JSON_SCALARS, ResolvedProps class PropEncodingError(ValueError): @@ -98,29 +98,36 @@ def _locate_failure(value: Any, path: str) -> str: return path -_JSON_LEAVES = frozenset({str, int, float, bool, type(None)}) +# Plain page data is a few levels deep; anything deeper (or cyclic) goes to the +# encoder, which raises a named PropEncodingError instead of looping here. +_MAX_PLAIN_DEPTH = 64 def _is_plain_json(value: Any) -> bool: """True when ``value`` is built only from exact JSON types. Exact ``type()`` checks keep subclasses (``StrEnum``, ``IntEnum``) on the - ``jsonable_encoder`` path, which converts them; plain data, such as the - shared i18n catalog, skips a full encoder walk that costs about 5x a - ``json.dumps`` of the same dict. + ``jsonable_encoder`` path, which converts them. Plain data, such as the + shared i18n catalog, only pays this read-only walk: the encoder rebuilds + every container and measured about 5x a ``json.dumps`` of the same dict. + Keys starting with ``_sa`` also stay on the encoder path, which drops them. """ - stack = [value] + stack = [(value, 0)] while stack: - item = stack.pop() + item, depth = stack.pop() kind = type(item) if kind is dict: + if depth >= _MAX_PLAIN_DEPTH: + return False for key, child in item.items(): - if type(key) is not str: + if type(key) is not str or key.startswith("_sa"): return False - stack.append(child) + stack.append((child, depth + 1)) elif kind is list: - stack.extend(item) - elif kind not in _JSON_LEAVES: + if depth >= _MAX_PLAIN_DEPTH: + return False + stack.extend((child, depth + 1) for child in item) + elif kind not in JSON_SCALARS: return False return True diff --git a/framework/inertia/simple_module_inertia/resolve.py b/framework/inertia/simple_module_inertia/resolve.py index 100452e2c..ef31b7c75 100644 --- a/framework/inertia/simple_module_inertia/resolve.py +++ b/framework/inertia/simple_module_inertia/resolve.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -_SCALARS = frozenset({str, int, float, bool, type(None)}) +JSON_SCALARS = frozenset({str, int, float, bool, type(None)}) def _is_dynamic(value: Any) -> bool: @@ -118,7 +118,7 @@ async def _resolve_tree( # they skip the Prop wrapper; only a partial filter needs their path. # The scalar test runs first: it is far cheaper than _is_dynamic, # and shared data such as the i18n catalog is hundreds of strings. - scalar = type(raw) in _SCALARS + scalar = type(raw) in JSON_SCALARS if scalar or not _is_dynamic(raw): if scalar and not filtering: result[key] = raw @@ -180,7 +180,7 @@ async def _resolve_children( if isinstance(value, list): items: list[Any] = [] for index, child in enumerate(value): - if type(child) in _SCALARS or not ( + if type(child) in JSON_SCALARS or not ( _is_dynamic(child) or isinstance(child, (dict, list)) ): items.append(child) diff --git a/framework/inertia/tests/test_adapter_page.py b/framework/inertia/tests/test_adapter_page.py index 77a0c40e2..399ff3b7d 100644 --- a/framework/inertia/tests/test_adapter_page.py +++ b/framework/inertia/tests/test_adapter_page.py @@ -175,3 +175,10 @@ class Colour(StrEnum): def test_non_string_keys_take_the_encoder(self) -> None: page = {"component": "C", "props": {"by_id": {1: "a"}}, "url": "/"} assert encode_page(page) is not page + + def test_cyclic_data_is_not_walked_forever(self) -> None: + from simple_module_inertia.page import _is_plain_json + + props: dict[str, object] = {} + props["self"] = props + assert _is_plain_json({"props": props}) is False From 1bf52db51b23bb20d70f2235ec62bbdb665d6848 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 15:18:43 +0100 Subject: [PATCH 4/6] fix: address code review findings (round 1, pass 2) Claude-Session: https://claude.ai/code/session_01FXHA3ZJ5EgoJN4zXTAhBR2 --- CHANGELOG.md | 11 ++++++----- .../simple_module_hosting/_error_handlers.py | 16 +++++++++++----- .../test_hosting_inertia_body_validation.py | 11 +++++++++++ framework/inertia/simple_module_inertia/page.py | 4 ++-- framework/inertia/tests/test_adapter_page.py | 4 ++++ 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3deac657..8aa007672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ All notable changes to this project are documented in this file. The format is b - Inertia v3 protocol coverage and known gaps in the adapter README (#780). ### Changed +- Inertia responses that carry the shared i18n catalog (every first page load) + render about 1.9 ms faster (measured with the 48 KB English catalog): the + prop resolver tests scalars before its dynamic-prop check, and plain-JSON + pages skip `jsonable_encoder`. - **Breaking (production CSP, #785):** `script-src`/`script-src-elem` are now `'self' 'nonce-' 'strict-dynamic'` (plus `'wasm-unsafe-eval'` for WebAssembly) instead of `'unsafe-inline' 'unsafe-eval'`. The host and @@ -54,13 +58,10 @@ All notable changes to this project are documented in this file. The format is b ### Fixed - **Behaviour change:** an Inertia form submission (POST/PUT/PATCH/DELETE) - whose body fails FastAPI request validation now redirects back (303) with + whose JSON body fails FastAPI request validation now redirects back (303) with one error per field, scoped by `X-Inertia-Error-Bag`, instead of rendering the 422 "invalid parameters" Error page over the form. GETs and non-Inertia - requests are unchanged. -- Inertia responses that carry the shared i18n catalog (every first page load) - render about 1.9 ms faster: the prop resolver tests scalars before its - dynamic-prop check, and plain-JSON pages skip `jsonable_encoder`. + requests, and invalid path/query values, keep the 422 page / JSON. - **Behaviour change:** `LocaleMiddleware` never picks a language the browser sends with `q=0` ("not acceptable"), including through a regional tag such as `es-MX;q=0` and when `q` follows another parameter or is spelled `Q`; diff --git a/framework/hosting/simple_module_hosting/_error_handlers.py b/framework/hosting/simple_module_hosting/_error_handlers.py index 6f59507e7..db00d80d5 100644 --- a/framework/hosting/simple_module_hosting/_error_handlers.py +++ b/framework/hosting/simple_module_hosting/_error_handlers.py @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) _INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503}) +_FORM_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) async def http_exception_handler(request: Request, exc: HTTPException) -> Response: @@ -54,12 +55,17 @@ async def request_validation_error_handler( Same negotiation rule as 403/404/500 (``_wants_json``), so one request never sees two different error shapes depending on the error class. - An Inertia form submission (any method but GET) is the exception: like a - hand-written ``redirect_back_with_errors``, it goes back to the form with - one message per field, so ``useForm`` shows them in place. A GET keeps - the error page, since redirecting back there could loop. + An Inertia form submission whose *body* is invalid is the exception: like + a hand-written ``redirect_back_with_errors``, it goes back to the form with + one message per field, so ``useForm`` shows them in place. Invalid path, + query or header values keep the error page: no form field can show them, + and a GET redirected back could loop. """ - if is_inertia_request(request.scope) and request.method != "GET": + if ( + request.method in _FORM_METHODS + and is_inertia_request(request.scope) + and any(tuple(error["loc"][:1]) == ("body",) for error in exc.errors()) + ): errors = errors_from_validation(exc.errors(), request_source=True) return redirect_back_with_errors(request, errors) if _wants_json(request): diff --git a/framework/hosting/tests/test_hosting_inertia_body_validation.py b/framework/hosting/tests/test_hosting_inertia_body_validation.py index 79c07cede..2cbe9fb64 100644 --- a/framework/hosting/tests/test_hosting_inertia_body_validation.py +++ b/framework/hosting/tests/test_hosting_inertia_body_validation.py @@ -38,7 +38,11 @@ async def submit(data: _Signup) -> dict[str, str]: async def lookup(age: int) -> dict[str, int]: return {"age": age} + async def remove(item_id: int) -> dict[str, int]: + return {"removed": item_id} + app.router.add_api_route(_FORM, form, methods=["GET"]) + app.router.add_api_route(f"{_SUBMIT}/{{item_id}}", remove, methods=["DELETE"]) app.router.add_api_route(_SUBMIT, submit, methods=["POST", "PUT", "PATCH"]) app.router.add_api_route(_SUBMIT, lookup, methods=["GET"]) return authenticated_client @@ -87,3 +91,10 @@ async def test_api_client_keeps_json_detail(form_client: httpx.AsyncClient) -> N resp = await form_client.post(_SUBMIT, json={}, headers={"Accept": "application/json"}) assert resp.status_code == 422 assert {err["loc"][-1] for err in resp.json()["detail"]} == {"name", "age"} + + +async def test_invalid_path_param_keeps_the_error_page(form_client: httpx.AsyncClient) -> None: + """No form field can show a bad path value; a silent redirect would hide it.""" + resp = await form_client.delete(f"{_SUBMIT}/abc", headers=_inertia(form_client)) + assert resp.status_code == 422 + assert inertia_page(resp).component == "Error" diff --git a/framework/inertia/simple_module_inertia/page.py b/framework/inertia/simple_module_inertia/page.py index 247f9541e..4c0bea037 100644 --- a/framework/inertia/simple_module_inertia/page.py +++ b/framework/inertia/simple_module_inertia/page.py @@ -98,8 +98,8 @@ def _locate_failure(value: Any, path: str) -> str: return path -# Plain page data is a few levels deep; anything deeper (or cyclic) goes to the -# encoder, which raises a named PropEncodingError instead of looping here. +# Plain page data is a few levels deep. The walk gives up past this depth, so a +# cyclic structure ends here instead of looping; the encoder then handles it. _MAX_PLAIN_DEPTH = 64 diff --git a/framework/inertia/tests/test_adapter_page.py b/framework/inertia/tests/test_adapter_page.py index 399ff3b7d..34b6a6f20 100644 --- a/framework/inertia/tests/test_adapter_page.py +++ b/framework/inertia/tests/test_adapter_page.py @@ -176,6 +176,10 @@ def test_non_string_keys_take_the_encoder(self) -> None: page = {"component": "C", "props": {"by_id": {1: "a"}}, "url": "/"} assert encode_page(page) is not page + def test_sqlalchemy_state_keys_are_dropped_by_the_encoder(self) -> None: + page = {"component": "C", "props": {"_sa_instance_state": 1, "a": 1}, "url": "/"} + assert encode_page(page)["props"] == {"a": 1} + def test_cyclic_data_is_not_walked_forever(self) -> None: from simple_module_inertia.page import _is_plain_json From 86dfd20fc32bfbc4d5dc9026d8f8b5a2c45ff6ed Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 15:21:12 +0100 Subject: [PATCH 5/6] fix: address code review findings (round 1, pass 3) Claude-Session: https://claude.ai/code/session_01FXHA3ZJ5EgoJN4zXTAhBR2 --- CHANGELOG.md | 2 +- .../simple_module_hosting/_error_handlers.py | 13 ++++++------- .../tests/test_hosting_inertia_body_validation.py | 15 +++++++++++++++ framework/inertia/README.md | 2 +- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa007672..5e4e1e888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ All notable changes to this project are documented in this file. The format is b ### Fixed - **Behaviour change:** an Inertia form submission (POST/PUT/PATCH/DELETE) - whose JSON body fails FastAPI request validation now redirects back (303) with + whose request body fails FastAPI request validation now redirects back (303) with one error per field, scoped by `X-Inertia-Error-Bag`, instead of rendering the 422 "invalid parameters" Error page over the form. GETs and non-Inertia requests, and invalid path/query values, keep the 422 page / JSON. diff --git a/framework/hosting/simple_module_hosting/_error_handlers.py b/framework/hosting/simple_module_hosting/_error_handlers.py index db00d80d5..782af07dd 100644 --- a/framework/hosting/simple_module_hosting/_error_handlers.py +++ b/framework/hosting/simple_module_hosting/_error_handlers.py @@ -61,13 +61,12 @@ async def request_validation_error_handler( query or header values keep the error page: no form field can show them, and a GET redirected back could loop. """ - if ( - request.method in _FORM_METHODS - and is_inertia_request(request.scope) - and any(tuple(error["loc"][:1]) == ("body",) for error in exc.errors()) - ): - errors = errors_from_validation(exc.errors(), request_source=True) - return redirect_back_with_errors(request, errors) + if request.method in _FORM_METHODS and is_inertia_request(request.scope): + body_errors = [error for error in exc.errors() if tuple(error["loc"][:1]) == ("body",)] + if body_errors: + # Only body errors: a bad path/query value alongside them is no form field. + errors = errors_from_validation(body_errors, request_source=True) + return redirect_back_with_errors(request, errors) if _wants_json(request): return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())}) return await render_error_page(request, 422, "The requested URL contains invalid parameters.") diff --git a/framework/hosting/tests/test_hosting_inertia_body_validation.py b/framework/hosting/tests/test_hosting_inertia_body_validation.py index 2cbe9fb64..924d0b1f6 100644 --- a/framework/hosting/tests/test_hosting_inertia_body_validation.py +++ b/framework/hosting/tests/test_hosting_inertia_body_validation.py @@ -98,3 +98,18 @@ async def test_invalid_path_param_keeps_the_error_page(form_client: httpx.AsyncC resp = await form_client.delete(f"{_SUBMIT}/abc", headers=_inertia(form_client)) assert resp.status_code == 422 assert inertia_page(resp).component == "Error" + + +async def test_only_body_errors_reach_the_form(app, form_client: httpx.AsyncClient) -> None: + """A bad query value next to a bad body must not flash a stray field error.""" + + async def paged(data: _Signup, page: int) -> dict[str, int]: + return {"page": page} + + app.router.add_api_route(f"{_SUBMIT}-paged", paged, methods=["POST"]) + resp = await form_client.post( + f"{_SUBMIT}-paged?page=x", json={"age": "old"}, headers=_inertia(form_client) + ) + assert resp.status_code == 303 + page = inertia_page(await form_client.get(_FORM, headers={"X-Inertia": "true"})) + assert set(page.props["errors"]) == {"name", "age"} diff --git a/framework/inertia/README.md b/framework/inertia/README.md index 3df9b6111..ae4b81ae4 100644 --- a/framework/inertia/README.md +++ b/framework/inertia/README.md @@ -26,7 +26,7 @@ features still require the corresponding Inertia client API. | Redirects and middleware | Implemented | `inertia.redirect()` (and `back()`) answer 303 for non-GET requests and 307 for GET; middleware also converts 302/307 after PUT/PATCH/DELETE/QUERY. Redirects whose target has a `#fragment` become an empty 409 with `X-Inertia-Redirect` (`inertia.redirect_with_fragment()` emits it directly; the automatic conversion skips prefetch). Explicitly empty 200 responses redirect back with 303; HTML and Inertia responses include `Vary: X-Inertia`. | | External navigation | Implemented | `inertia.location(url)` returns 409 with `X-Inertia-Location` for a client-side full-page navigation. | | History and fragments | Implemented | `inertia.encrypt_history()`, `inertia.clear_history()`, and `inertia.preserve_fragment()` emit `encryptHistory`, `clearHistory`, and `preserveFragment`; the client performs encryption and history updates. | -| Validation | Implemented | Validation conversion keeps the first message per top-level field; `X-Inertia-Error-Bag` scopes session errors, which render only with `use_flash_errors` (hosting enables it). The standalone validation handler redirects back; hosting's form helper does likewise, and hosting's request-validation handler does too for Inertia submissions (any method but GET), while an Inertia GET with invalid query parameters gets the 422 error page. | +| Validation | Implemented | Validation conversion keeps the first message per top-level field; `X-Inertia-Error-Bag` scopes session errors, which render only with `use_flash_errors` (hosting enables it). The standalone validation handler redirects back; hosting's form helper does likewise, and hosting's request-validation handler does too for body validation errors of Inertia POST/PUT/PATCH/DELETE requests, while invalid path or query values (and any Inertia GET) get the 422 error page. | | Hosting error pages | Implemented | `simple_module_hosting` renders the `Error` component for supported browser/Inertia errors, preserving the HTTP status and exception headers; API requests retain JSON errors. | | Test helper | Implemented | `simple_module_test.InertiaPage.from_response(response)` (or `inertia_page(response)`) parses HTML/JSON pages, offers fluent page/prop assertions, and follows partial reloads and deferred groups with `reload_only`, `reload_except`, and `load_deferred`. | | SSR | Not implemented | No SSR client: `ssr_url` and the template's SSR branches are unused, and `ssr_enabled` only makes asset resolution read the Vite manifest. Tracked in [#580](https://github.com/antosubash/simple_module_python/issues/580). | From ccab8cc29f5755a2c964554e1f177e26b7d44ae1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 4 Nov 2026 16:37:50 +0100 Subject: [PATCH 6/6] test(e2e): the validated-body form test passes now; drop its xfail Claude-Session: https://claude.ai/code/session_01FXHA3ZJ5EgoJN4zXTAhBR2 --- tests/e2e/inertia/test_inertia_forms.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/inertia/test_inertia_forms.py b/tests/e2e/inertia/test_inertia_forms.py index ac8004458..09c046edc 100644 --- a/tests/e2e/inertia/test_inertia_forms.py +++ b/tests/e2e/inertia/test_inertia_forms.py @@ -53,8 +53,6 @@ def test_partial_invalid_shows_only_bad_field(page): expect(err(page, "default", "email")).to_be_empty() -@pytest.mark.allow_resource_errors -@pytest.mark.xfail(strict=False, reason="fixed by #818") def test_validated_route_returns_field_errors(page): page.goto("/__e2e/inertia/forms") seen = []