Skip to content

Add Laravel-style fluent validation module - #326

Merged
tmgbedu merged 10 commits into
mainfrom
task/validation-module
Oct 31, 2026
Merged

tmgbedu merged 10 commits into
mainfrom
task/validation-module

Conversation

@claude-tmgbedu

Copy link
Copy Markdown

Summary

Adds fastapi_startkit.validation, a Laravel-style fluent validation module (ADR 042).

from typing import Annotated
from fastapi_startkit.validation import Email, Field, FormRequest, Validated

class RegisterUser(FormRequest):
    email: Annotated[str, Email().required().unique("users")]
    username: Annotated[str, Field().required().string().min(3).max(10)]
    password: Annotated[str, Field().required().password()]
    password_confirmation: Annotated[str, Field().required().same_as("password")]
    is_business: bool = False
    tax_id: Annotated[str | None, Field().required_if("is_business", True)] = None

@router.post("/register")
async def register(data: Validated[RegisterUser]): ...

Failing requests return 422 with {"message": "<first> (and N more errors)", "errors": {"field": ["..."]}}.

  • Rules: required, nullable, string, integer, numeric, boolean, min, max, between, password, email, url, regex, in_, not_in, same_as, confirmed, required_if, unique, exists, plus add(rule) and msg(message). Custom rules subclass Rule (sync) or AsyncRule.
  • Messages: each rule has a Laravel-style default (min/max/between change wording for strings, numbers and lists). A custom message can be a string with :attribute/:min/:max/:other/:value placeholders, or a callable (field, value, data). The builder takes it as msg=.
  • Sync phase: sync rules run in FormRequest's wrap model_validator after pydantic has validated every field, with the whole model's data. Cross-field rules therefore see fields declared after them. Empty values skip non-implicit rules, the first failure stops the field (bail), and fields with type errors run no rules. Type errors and rule errors come back together in one ValidationError.
  • Async phase: unique/exists and custom AsyncRules run in await model.validate_async(context) using the async QueryBuilder (a table name goes through DB.table, a Model class through Model.query()). Validated[Model] runs this phase after the body has parsed and passes the route's path parameters as context, so unique("users", ignore=lambda d: d["user_id"]) works on update routes. Outside HTTP, use await Model.validate_input(data).
  • 422 response: the existing, unused exceptions.ValidationException now subclasses HttpException (422) and carries a MessageBag. FastAPIProvider sends it to the same ValidationExceptionHandler as RequestValidationError, so both phases get the same JSON/Inertia/session handling. For JSON clients the only change is a new message key next to errors. FastAPI's default handler, and apps that don't use FastAPIProvider, are not changed.

Follow-ups (not in this PR): docs page, nested/list rules, combining with the form-encoded RequestModel, more rules (date, array, sometimes, ...).

Test plan

  • uv run pytest --ignore=tests/masoniteorm/postgres --ignore=tests/masoniteorm/mysql --cov: 3515 passed, total coverage 92.25%, fastapi_startkit/validation at 100%
  • tests/validation/: each rule passing and failing, default/str/callable messages, bail and empty-value skipping, cross-field rules in any declaration order, unique/exists against SQLite through the async ORM (including ignore), and the 422 shape through a real FastAPIProvider app
  • ruff check, ruff format --check
  • basedpyright: no new errors (the one remaining error is the existing unresolved optional langchain import)

🤖 Generated with Claude Code

Adds fastapi_startkit.validation: Field()/Email() builders used as
Annotated metadata on FormRequest models, sync rules run after pydantic
validation with full model data (cross-field rules work regardless of
declaration order, empty values skip non-implicit rules, first failure
per field stops), AsyncRule (unique/exists via the async QueryBuilder)
run in an awaited phase through the Validated[Model] dependency with
path params as context, and a MessageBag rendered as the Laravel 422
shape by the existing ValidationExceptionHandler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude-tmgbedu claude-tmgbedu left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: changes requested (one blocker, five should-fix items; the rest is solid)

Blocker

  1. form_request.py:125: Validated passes raw string path params into the async rules. request.path_params holds unvalidated strings, so the update example in ADR 042, unique("users", ignore=lambda data: data["user_id"]), binds '1' against an integer id. I checked this against a real Postgres (asyncpg):

    • context={"user_id": 1} passes
    • context={"user_id": "1"} (what Validated passes) fails with DBAPIError ... DataError: invalid input for query argument $2: '1' ('str' object cannot be interpreted as an integer), which is a 500

    SQLite hides this through type affinity, and the FastAPI test uses a fake AsyncRule that compares str(...), so CI cannot catch it. A related problem: FastAPI runs sub-dependencies before it validates the endpoint's own path params. So PUT /users/abc (with user_id: int) queries the database with 'abc' and returns 422 {"email": ["The email has already been taken."]} on SQLite (a 500 on Postgres) instead of the user_id int error. Possible fixes: have the dependency take the route's validated path values instead of request.path_params, or at least change the ADR/example to coerce (int(data["user_id"])). Either way, add a real-unique route test with an int ignore.

Should fix

  1. form_request.py:124: the dependency's body parameter is named payload. When the endpoint has a second body param, FastAPI embeds the body under "payload", not under the endpoint's parameter name. With async def two(data: Validated[Item], note: Annotated[str, Body()]), sending {"data": {...}, "note": ...} gives loc: ["body", "payload"] "Field required", and only {"payload": {...}} works. OpenAPI shows payload too. Document this or find a way to keep the user's name.
  2. message_bag.py:11 / exceptions.py:225: a backward-compat trap. The old ValidationException(errors: dict) was typically called as {"field": "msg"}. Now ValidationException({"email": "Taken."}).errors == {"email": ["T","a","k","e","n","."]}. Accept a str value as a single message.
  3. field.py:160: rules on a nested plain BaseModel inside a FormRequest are silently skipped. coerced_values is already set by the outer model, so Inner.code: Annotated[str, Field().required().min(5)] accepts "ab". The ADR says plain-BaseModel use raises TypeError so that rules never silently fail to run. This case gets around that guarantee. Either raise here too or document nested rules as unsupported (the ADR only lists them as a follow-up).
  4. rules.py:241: in_({"a","b"}) on a list or dict value raises TypeError: unhashable type (a 500). Catch TypeError and return False.
  5. Missing tests: a real-DB unique with a path-param ignore through Validated (see 1), an invalid path param combined with an async rule, and a multi-body-param endpoint.

Nits / notes

  • OpenAPI: required fields are advertised as anyOf [string, null] (they stay in required, which is good, but generated clients will type them as nullable).
  • required_if("is_business", True) renders "...when is business is True." (Laravel renders true). Consider lowercasing bools in :value.
  • numeric().max(10) on a str field measures length ("25" passes). Laravel treats it as a number when numeric is present. Document this or match Laravel.
  • Non-mapping input also runs rules on the defaults, which adds a spurious name "required" error next to pydantic's "valid dictionary" error.
  • Combined errors are ordered with pydantic type errors first, then rule errors, not in declaration order. Harmless.

Review focus results

  • Rules / messages / bail / empty-skip / msg() / callable messages: correct in all my tests (whitespace counts as empty for required; {braces} in messages are kept literally).
  • Cross-field rules: same_as, confirmed and required_if work regardless of declaration order.
  • Async phase: properly awaited with no nested loops. Values are bound as parameters, and table/column names come from the developer. When the sync phase fails, async rules are skipped (documented in the ADR and confirmed over HTTP).
  • FastAPI: the OpenAPI body schema and required list are kept. JSON, Inertia (303) and plain-form (303) negotiation work for both phases. A plain data: Model runs the sync phase only (a duplicate email passes, as documented).
  • Breaking change: exceptions.ValidationException has no users in src/, example/, application/ or the docs, so repurposing it is safe apart from item 3. The added message key on 422s is additive (only exact-body assertions break). Fine for a minor release with a changelog note.
  • Typing: basedpyright reports 0 errors on the module and on a user-style model and endpoint (Validated[Model] resolves to the model type).
  • Repo rules: the ADR and index entry are present, there are no new explanatory comments, and the module has 100% coverage.

Test results

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3555 passed, 10 skipped, total coverage 92%, validation/* 100%
  • ruff check, ruff format --check, basedpyright src/fastapi_startkit/validation: all clean

QA (uvicorn + SQLite, real HTTP)

valid register                 200 {"ok":true,"username":"john"}
duplicate email                422 {"message":"The email has already been taken.","errors":{"email":["The email has already been taken."]}}
empty {}                       422 {"message":"The email field is required. (and 3 more errors)","errors":{"email":["The email field is required."],"username":["The username field is required."],"password":["The password field is required."],"password_confirmation":["The password confirmation field is required."]}}
username "ab"                  422 {"message":"The username field must be at least 3 characters.", ...}
username 11 chars              422 {"message":"The username field must not be greater than 10 characters.", ...}
short pw + mismatch            422 {"message":"The password field must be at least 8 characters. (and 1 more error)","errors":{"password":[...],"password_confirmation":["The password confirmation field must match password."]}}
is_business w/o tax_id         422 {"message":"The tax id field is required when is business is True.", ...}
is_business + tax_id           200
type errors + rule errors      422 {"message":"Input should be a valid string (and 4 more errors)","errors":{"username":["Input should be a valid string"],"is_business":["Input should be a valid boolean, unable to interpret input"],"email":[...],"password":[...],"password_confirmation":[...]}}
sync fail + dup email          422 only username error (async skipped)
PUT /users/1 own email         200
PUT /users/1 bob's email       422 {"message":"The email has already been taken.", ...}
PUT /users/abc                 422 email taken  <- should be the user_id int error (blocker 1)
Inertia sync / async fail      303 -> Referer

from fastapi import Request

async def dependency(request: Request, payload: Any) -> Any:
return await payload.validate_async(request.path_params)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: request.path_params holds raw strings. On Postgres, the ADR's unique("users", ignore=lambda data: data["user_id"]) fails with asyncpg DataError: invalid input for query argument $2: '1' ('str' object cannot be interpreted as an integer), which is a 500. This dependency also runs before FastAPI validates the endpoint's user_id: int, so PUT /users/abc queries the DB with 'abc' and returns an email error instead of the path-param error.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f81e67. Validated now reads the matched APIRoute from request.scope['route'] and converts the raw path values with FastAPI's own request_params_to_args over every declared path param (get_flat_dependant(route.dependant).path_params). So data['user_id'] is the typed int. If any declared path param is invalid, the dependency returns the model without running the async phase. FastAPI then reports its normal path 422 and no query runs. Regression tests: a real unique(..., ignore=lambda d: d['account_id']) through Validated on SQLite asserts the bound query parameter is int and that /accounts/abc never reaches the DB (tests/validation/test_validated_database.py). The same route runs on Postgres/asyncpg in tests/masoniteorm/postgres/test_validated_unique.py. It reproduced your DataError on the old code and passes now. ADR 042 documents this.

def validated_dependency(model: type[FormRequest]) -> Any:
from fastapi import Request

async def dependency(request: Request, payload: Any) -> Any:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the endpoint has another body param, FastAPI embeds this body under "payload" (not the endpoint's data): {"data": {...}} gives loc ["body","payload"] Field required.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0f81e67. FastAPI builds a dependency's signature without the name of the endpoint param that declares it, so the endpoint's data name can't be reused. The body key is now the snake-cased model name (UpdateUser -> update_user) instead of a fixed payload. That keeps it predictable and lets two different Validated models on one endpoint coexist. A single body param is still not embedded. Test: test_validated_body_is_embedded_under_the_model_name_with_other_body_params (request body plus OpenAPI keys). ADR 042 documents the key and the escape hatch: data: Model + await data.validate_async(context).

def __init__(self, messages: Mapping[str, Iterable[str]] | None = None) -> None:
self.messages: dict[str, list[str]] = {}
for field, field_messages in (messages or {}).items():
for message in field_messages:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidationException({"email": "Taken."}) (the old call shape) gives {"email": ["T","a","k","e","n","."]}. Treat a str as one message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f81e67. MessageBag treats a str value as one message, so ValidationException({'email': 'Taken.'}).errors == {'email': ['Taken.']}. Covered by test_string_messages_are_single_messages.

return schema

@staticmethod
def require_form_request(value: Any) -> Any:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nested plain BaseModel with Field rules inside a FormRequest passes this check, because the outer model set the ContextVar, so its rules silently never run (min(5) accepts "ab"). That contradicts the ADR's no-silent-skip guarantee.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f81e67. FormRequest.__pydantic_init_subclass__ raises TypeError at class definition when a field's annotation (direct, list[...], X | None, other generic args) contains a model with Field rules. Rules can no longer silently skip. Nested dotted-key rules stay a follow-up (ADR 042 updated). Tests: test_rules_on_nested_models_raise_at_class_definition, test_nested_models_without_rules_are_allowed.

self.values = values

def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool:
return value in self.values

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An unhashable value (list/dict) with set values raises TypeError, which is a 500. Catch it and return False.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f81e67. In.contains catches TypeError: in_ fails validation and not_in passes for unhashable values. Covered in the rule matrix and in test_in_rule_rejects_unhashable_values (a 422 message, not a 500).

self.expected = expected

def replacements(self) -> Mapping[str, Any]:
return {"other": humanize(self.other), "value": self.expected}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: renders is True / is False. Laravel renders true/false.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f81e67. Booleans in :value render lowercase: '... when is business is true.' Covered by test_required_if_renders_booleans_in_lowercase.

… rules

- Validated converts declared path params with FastAPI's own param
  validation before the async phase; invalid path params skip it so
  FastAPI returns its path 422 and no query runs.
- Dependency body key is the snake-cased model name.
- MessageBag treats a string value as one message.
- Field rules on nested models raise TypeError at class definition.
- in_/not_in treat unhashable values as not in the collection.
- Required fields keep a plain JSON schema type, booleans render in
  lowercase, size rules compare numeric values with numeric/integer,
  and non-mapping input reports only the type error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@claude-tmgbedu claude-tmgbedu left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: changes requested. One new blocker. Everything else from the previous review is fixed and verified, over real HTTP on both SQLite and Postgres.

Blocker

  1. form_request.py:148: get_flat_dependant no longer exists in current FastAPI, so every Validated request returns 500. The package declares fastapi[standard]>=0.124.4 with no upper bound. A fresh install therefore resolves to FastAPI 0.162.0, where the import raises:

    ImportError: cannot import name 'get_flat_dependant' from 'fastapi.dependencies.utils'
    

    Because the import is lazy inside the dependency, this happens at request time and gives a 500 on every Validated endpoint. I bisected it: the function is present in 0.124.4 through 0.140.0 and gone in 0.140.13, 0.141.x, 0.145, 0.150, 0.155, 0.160 and 0.162. uv run --with fastapi==0.162.0 pytest tests/validation gives 8 failed (every Validated HTTP test). CI only exercises the locked 0.124.4, so it is green.

    Suggested fix (I verified it on both 0.124.4 and 0.162.0): drop get_flat_dependant and collect the path params from the public Dependant dataclass fields, which exist in every supported version:

    def declared_path_params(dependant: Dependant) -> list[ModelField]:
        return [*dependant.path_params, *(p for sub in dependant.dependencies for p in declared_path_params(sub))]

    request_params_to_args(fields, received) is also internal, but it still has the same signature and behaviour in 0.162.0. With this walker, my probe app gives identical results on both versions: typed ints for router-prefix params, for params declared only in dependencies=[Depends(...)] and for Path(gt=0) constraints, plus the path 422 with no async phase when any of them is invalid.
    Please also add a guard so this cannot regress silently, for example a CI job (or a nox/uv step) that runs tests/validation against the latest FastAPI. Alternatively, cap the upper bound until the code is verified on a newer version.

Previous items: verified

  • Typed path context (old blocker): fixed on 0.124.4. PUT /users/abc returns FastAPI's path 422 with zero queries (checked with a query logger), and ignore binds an int. The PR's tests/masoniteorm/postgres/test_validated_unique.py passes against a throwaway postgres:16. Also checked:
    • APIRouter(prefix="/teams/{team_id}") gives both params as ints, and /teams/x/... returns the path 422 without the async phase.
    • The fastapi_startkit.fastapi.Router wrapper works.
    • A route-level dependencies=[Depends(dep(tenant_id: int))] gives tenant_id as an int, and an invalid value returns 422.
    • Path(gt=0) with 0 returns 422 without the async phase.
    • A mounted sub-app (app.mount("/orgs/{org_id}", inner)) gives the inner route's params typed, and the outer mount param stays a raw str. This is expected, since the inner app doesn't declare it.
    • An undeclared path param stays a raw str, and no matched APIRoute falls back to raw params.
  • Embed key: acceptable. A single-body endpoint still takes the plain body ({"email": ...} gives 200, and the OpenAPI $ref points straight at UpdateUser). With a second body param, the body is embedded under update_user, and {"data": ...} gives a clear loc ["body","update_user"] Field required. FastAPI gives a dependency no access to the declaring param's name, so a stable, model-derived key is the most predictable option, and the ADR documents the escape hatch. Two edge cases are worth guarding or documenting (non-blocking):
    • A model class named Request (key request) raises ValueError: duplicate parameter name: 'request' at route definition, because it collides with the dependency's own request param. Rename that param to something unlikely, or raise a clear error.
    • An endpoint body param literally named update_user silently shares the key with Validated[UpdateUser], and the request fails with a confusing combined 422.
  • MessageBag str: ValidationException({"email": "Taken."}).errors == {"email": ["Taken."]}. Verified.
  • Nested rules: a TypeError is raised at class definition, including through dict[str, list[Inner]]. Verified.
  • in_ unhashable: gives a 422 rule message, not a 500. Verified.
  • Nits: all verified. The plain {"type": "string"} schema is shown, booleans render lowercase (...is true.), numeric().max(10) rejects "25", and non-mapping input gives only the model_type error.

Notes (non-blocking)

  • typed_path_params recomputes the flattened dependant on every request. It is cheap, but the field list could be cached per route (for example in a WeakKeyDictionary keyed by route).

Test results (0f81e67, FastAPI 0.124.4 from the lockfile)

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3570 passed, 10 skipped, coverage 92.41%
  • ruff check, ruff format --check and basedpyright src/fastapi_startkit/validation: clean
  • tests/masoniteorm/postgres/test_validated_unique.py on postgres:16: 2 passed
  • tests/validation on FastAPI 0.162.0: 8 failed (blocker 1)

QA: uvicorn over real HTTP, SQLite and Postgres 16 (asyncpg) gave identical results

valid register            200 {"ok":true,"username":"john"}
duplicate email           422 {"message":"The email has already been taken.","errors":{"email":["The email has already been taken."]}}
empty {}                  422 {"message":"The username field is required. (and 3 more errors)", ...}
is_business w/o tax_id    422 {"message":"The tax id field is required when is business is true.", ...}
sync fail + dup email     422 only username min error; queries=[]
PUT /users/1 own email    200 {"id":1,"email":"jane@example.com"}   queries=[[... "id" != ?, ["jane@example.com", 1]]]
PUT /users/1 bob's email  422 {"message":"The email has already been taken.","errors":{"email":["The email has already been taken."]}}
PUT /users/abc            422 {"message":"Input should be a valid integer, unable to parse string as an integer","errors":{"user_id":[...]}}   queries=[]
PUT /users/1 "nope"       422 {"message":"The email field must be a valid email address.", ...}   queries=[]
PUT /users/1 email=["a"]  422 {"message":"Input should be a valid string", ...}   queries=[]

get_flat_dependant was removed in FastAPI 0.140, so every Validated
request raised ImportError on a fresh install. Walk the public Dependant
tree instead, cache the result per route, rename the dependency's request
parameter so a model named Request does not collide, and run the
validation and FastAPI tests against the latest FastAPI in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@claude-tmgbedu

Copy link
Copy Markdown
Author

Blocker fixed in 55625cb.

  • get_flat_dependant is gone. Path params now come from walking the public Dependant tree (dependant.path_params plus each dependant.dependencies entry, recursively), so router-prefix params, params from route-level dependencies=[Depends(...)] and Path(...) constraints are still typed. request_params_to_args is kept (same signature in 0.162.0).
  • The collected fields are cached per route. APIRoute defines __eq__, so it can't be a WeakKeyDictionary key; the cache is keyed by id(route) and a weakref.finalize removes the entry when the route is collected.
  • The dependency's request param is renamed to __validated_request, so a FormRequest named Request no longer raises the duplicate-parameter error (there's a test for it). The body param named like the embed key can't be detected from inside the dependency, so it's documented in ADR 042.
  • CI: after the locked run, the Test workflow upgrades FastAPI in the venv, prints the version, and runs tests/validation tests/fastapi. Plain uv run --with "fastapi>=0.124.4" doesn't work for this, because the locked 0.124.4 already satisfies the constraint and gets reused.

Results:

  • uv run pytest tests/validation (0.124.4): 106 passed
  • uv run --with fastapi==0.162.0 pytest tests/validation tests/fastapi: 294 passed (before the fix: 8 failed)
  • Full suite --ignore=tests/masoniteorm/postgres --cov: 3572 passed, 10 skipped, 92% coverage, validation/* at 100%
  • ruff check/format and basedpyright on validation: clean

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@claude-tmgbedu claude-tmgbedu left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE (posted as COMMENT because this is an own-PR). Re-review of ffe64582 after the FastAPI-compat fix.

What I checked

  1. get_flat_dependant is gone. declared_path_params walks the public Dependant.path_params and .dependencies recursively. On the locked FastAPI 0.124.4, tests/validation + tests/fastapi give 294 passed. In a separate venv with uv pip install --upgrade fastapi (0.162.0), the same suites also give 294 passed.
  2. Per-route cache (id(route) + weakref.finalize). APIRoute.__hash__ is None (it inherits Starlette's __eq__), so WeakKeyDictionary cannot be used and an id key is needed. The finalizer runs when the route is deallocated, before its memory (and so its id) can be reused, so a stale entry cannot be served to a new route. I probed this outside the PR's tests:
    • Recreated apps: 50 apps built and GC'd in a loop. Each request saw its own typed uid, and the cache never held more than 1 entry, so there is no leak.
    • Mounted sub-app with Path(gt=0): 5 gives typed context. 0 gives a 422 and the async phase never runs.
    • dependency_overrides: an overridden sub-dependency that declares the path param works (200, typed context).
  3. __validated_request. Name mangling only applies inside class bodies, and this is a nested function at module level that builds an inspect.Signature and looks the value up by string key, so it is not mangled. FastAPI injects the request by annotation (Request), not by name. The new test shows a FormRequest named Request works.
  4. CI. In run 40851309238 (head ffe64582), every Python job (3.12, 3.13, 3.14, 3.15) logs - fastapi==0.124.4 / + fastapi==0.162.0, prints FastAPI 0.162.0, and then shows 294 passed. Because --no-sync is used, the upgrade is not reverted.
  5. env="testing" removal. All three make_application calls run inside the test (in the lazy _application setup), where PYTEST_CURRENT_TEST is set, so Environment.resolve_environment returns testing. I confirmed Application(...).env == "testing" and is_testing() without the argument.
  6. ADR 042 and docs/adr/AGENTS.md. Both are updated with the rationale for dropping get_flat_dependant, the cache design, the __validated_request naming, and the CI step. No explanatory comments or docstrings in validation/. The full suite (excluding postgres) gives 3572 passed with 92% total coverage, and validation/* is at 100%. Ruff, Basedpyright and codecov/patch are green.

Non-blocking notes

  • declared_path_params can return the same param twice when a router prefix param is also declared on the endpoint. This is harmless because request_params_to_args writes the same value again.
  • With dependency_overrides, path-param types come from the original route's Dependant, not from the override. In practice they match, and this is fine as is.

Not merging.

tmgbedu and others added 5 commits October 30, 2026 14:59
Per-module CI steps do not scale; drop the step and the ADR 042 claim
that CI runs the validation tests against the latest FastAPI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	docs/adr/AGENTS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validate_input runs authorize, prepare_for_validation, a type-only pydantic parse, every rule in written order (awaiting async rules and closures in place) and after hooks, then raises one ValidationException. Validated takes the raw body so type and rule errors share one 422, and validate_async is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k/validation-async

# Conflicts:
#	docs/adr/AGENTS.md
@tmgbedu

tmgbedu commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

QA (task #3714): bug with multiple Validated[...] params on one endpoint (embedded bodies)

When an endpoint takes two Validated models, FastAPI embeds the body as {"billing": {...}, "shipping": {...}} (the OpenAPI Body_* schema is correct). But:

  1. Error keys drop the embed prefix. If both models have a field with the same name, the client can't tell which one failed.
  2. Errors are not combined. The first dependency raises ValidationException, so the second model never runs. The client gets only part of the errors, which goes against the "single combined 422" goal in ADR 048.

Repro:

class Billing(FormRequest):
    name: Annotated[str, Field().required().min(3)]

class Shipping(FormRequest):
    name: Annotated[str, Field().required().min(5)]

@app.fastapi.post("/checkout")
async def checkout(billing: Validated[Billing], shipping: Validated[Shipping]): ...
  • {"billing": {"name": "abcd"}, "shipping": {"name": "ab"}} → 422 {"errors": {"name": ["...at least 5 characters."]}}. Expected key: shipping.name.
  • {"billing": {"name": "a"}, "shipping": {"name": "ab"}} → 422 {"errors": {"name": ["...at least 3 characters."]}}. The shipping error is missing. Expected: billing.name and shipping.name together.

The cause is in validated_dependency: each dependency calls validate_input, which raises right away, and the bag keys come from the model's own fields without the body key underscore(model.__name__). Possible fix: prefix the keys with the embed key when the route body is embedded (more than one body param). Or document that only one Validated per route is supported.

Everything else passed in a scratch FastAPI app on sqlite: combined type + rule + unique/exists + sync/async closure 422, async authorize → 403 (before type parsing), async prepare/after, rule order, stop_on_first_failure, 40 concurrent requests with isolated error bags, no event-loop blocking (10×200ms async rules took 0.20s), and the OpenAPI $ref for both single and embedded bodies. Full suite: 3624 passed, 92.56% coverage, validation module at 100%.

Note: the 422 body is now {"message", "errors"}, a superset of main's {"errors"}. This also applies to plain RequestValidationError responses. It's additive, but clients that compare the exact body will see the change.

Each Validated dependency records its errors in the request scope, prefixed with its embed key when the body is embedded, and the route's last Validated dependency raises them together. ValidationExceptionHandler merges recorded errors into RequestValidationError responses. ADR 048 now documents the {message, errors} 422 body as a superset of main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tmgbedu
tmgbedu merged commit 366fa26 into main Oct 31, 2026
10 checks passed
@tmgbedu
tmgbedu deleted the task/validation-module branch October 31, 2026 00:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants