Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<per-request>' 'strict-dynamic'` (plus `'wasm-unsafe-eval'`
for WebAssembly) instead of `'unsafe-inline' 'unsafe-eval'`. The host and
Expand Down Expand Up @@ -53,6 +57,11 @@ 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 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.
- Bulk-DML audit prefetch preserves statement options, including loader criteria,
so it records only the rows selected by the UPDATE or DELETE (#810).
- ORM restores now emit `restored` audit entries with translated action labels
Expand Down
15 changes: 15 additions & 0 deletions framework/hosting/simple_module_hosting/_error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@
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._observability import CorrelationIdMiddleware
from simple_module_hosting.inertia_utils import redirect_back_with_errors

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:
Expand Down Expand Up @@ -51,7 +54,19 @@ 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 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 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.")
Expand Down
115 changes: 115 additions & 0 deletions framework/hosting/tests/test_hosting_inertia_body_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""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}

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


@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"}


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"


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"}
2 changes: 1 addition & 1 deletion framework/inertia/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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). |
Expand Down
38 changes: 37 additions & 1 deletion framework/inertia/simple_module_inertia/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -98,8 +98,44 @@ def _locate_failure(value: Any, path: str) -> str:
return path


# 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


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, 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, 0)]
while stack:
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 or key.startswith("_sa"):
return False
stack.append((child, depth + 1))
elif kind is list:
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


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:
Expand Down
11 changes: 7 additions & 4 deletions framework/inertia/simple_module_inertia/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 JSON_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
Expand Down Expand Up @@ -177,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)
Expand Down
38 changes: 38 additions & 0 deletions framework/inertia/tests/test_adapter_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,41 @@ 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

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

props: dict[str, object] = {}
props["self"] = props
assert _is_plain_json({"props": props}) is False
2 changes: 0 additions & 2 deletions tests/e2e/inertia/test_inertia_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading