From d8e192d4f7a7c6a017da968c24feab775a7c8372 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 29 Oct 2026 01:53:56 -0700 Subject: [PATCH 1/8] Add fluent validation module with async database rules 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 --- docs/adr/042-fluent-validation-rules.md | 88 +++++ docs/adr/AGENTS.md | 1 + .../fastapi_startkit/exceptions/exceptions.py | 24 +- .../fastapi_startkit/fastapi/exceptions.py | 13 +- .../fastapi/providers/fastapi_provider.py | 3 +- .../fastapi_startkit/validation/__init__.py | 18 + .../src/fastapi_startkit/validation/field.py | 169 ++++++++++ .../validation/form_request.py | 148 +++++++++ .../validation/message_bag.py | 61 ++++ .../src/fastapi_startkit/validation/rules.py | 313 ++++++++++++++++++ .../tests/fastapi/test_exceptions.py | 23 +- .../tests/inertia/test_validation_redirect.py | 2 +- fastapi_startkit/tests/validation/__init__.py | 0 .../tests/validation/test_database_rules.py | 96 ++++++ .../validation/test_fastapi_validation.py | 125 +++++++ .../tests/validation/test_form_request.py | 211 ++++++++++++ .../tests/validation/test_message_bag.py | 49 +++ .../tests/validation/test_rules.py | 145 ++++++++ 18 files changed, 1474 insertions(+), 15 deletions(-) create mode 100644 docs/adr/042-fluent-validation-rules.md create mode 100644 fastapi_startkit/src/fastapi_startkit/validation/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/validation/field.py create mode 100644 fastapi_startkit/src/fastapi_startkit/validation/form_request.py create mode 100644 fastapi_startkit/src/fastapi_startkit/validation/message_bag.py create mode 100644 fastapi_startkit/src/fastapi_startkit/validation/rules.py create mode 100644 fastapi_startkit/tests/validation/__init__.py create mode 100644 fastapi_startkit/tests/validation/test_database_rules.py create mode 100644 fastapi_startkit/tests/validation/test_fastapi_validation.py create mode 100644 fastapi_startkit/tests/validation/test_form_request.py create mode 100644 fastapi_startkit/tests/validation/test_message_bag.py create mode 100644 fastapi_startkit/tests/validation/test_rules.py diff --git a/docs/adr/042-fluent-validation-rules.md b/docs/adr/042-fluent-validation-rules.md new file mode 100644 index 00000000..99281ac9 --- /dev/null +++ b/docs/adr/042-fluent-validation-rules.md @@ -0,0 +1,88 @@ +# 042. Fluent validation rules with an async phase and a Laravel 422 shape + +Date: 2026-10-29 + +## Problem + +Request models are plain pydantic models. Pydantic covers types, but Laravel-style rules (`required`, `min:3`, `same:password`, `required_if:is_business,true`, `unique:users,email,{id}`) need hand-written validators, and their error messages do not follow one shape. Three things do not fit pydantic directly: + +- `unique` and `exists` query the database. Pydantic validators are synchronous and the ORM is async. Calling `asyncio.run` or a nested loop inside a validator would block the event loop or fail inside a running one. +- Cross-field rules (`same_as`, `required_if`) read other fields through `info.data`, which only holds the fields declared before the current one. +- A missing optional field must not run `min`/`max`, and a failing `required` must not also report `min`/`max`. + +## Alternatives + +1. **Pydantic `Field` constraints and `field_validator`/`model_validator` only.** No new API, but every message, cross-field rule and database check is hand-written per model, and database checks still have no async place to run. +2. **A string-rule DSL (`"required|email|max:255"`).** Familiar from Laravel, but rules are untyped strings parsed at runtime, custom messages and custom rule classes need a second mechanism, and typos are only found when a request arrives. +3. **A separate `Validator(data, rules)` class.** Async-friendly, but the rules live away from the model, the model's types and the rules can drift, and the request body would be parsed twice. +4. **A fluent builder used as `Annotated` metadata on a pydantic model**, with the synchronous rules running during pydantic validation and the async rules in a second awaited phase. + +## Decision + +Option 4, in a new `fastapi_startkit.validation` package that depends only on pydantic. The ORM and FastAPI are imported lazily, only by the rules and the dependency that use them. + +```python +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]): ... +``` + +### Rules and the builder + +- `Rule` (sync, `passes(attribute, value, data) -> bool`) and `AsyncRule` (`async passes(...)`) share `BaseRule`, which holds the message. Custom rules subclass one of them and set `message`. A rule with `implicit = True` also runs on empty values (`required`, `required_if`). +- `Field()` is the builder. Every method appends a rule and returns the builder: `required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password` (`min(8)`), `unique`, `exists`, `add(rule)` and `msg(message)` (replaces the message of the last rule). `Email()` is a `Field()` that starts with the `email` rule. +- `min`/`max`/`between` measure string and list length, and numeric value, with Laravel's per-kind messages. +- Messages are a string or a callable `(field, value, data) -> str`. In strings, `:attribute` becomes the humanized field name (`password_confirmation` → `password confirmation`), and each rule adds its own placeholders (`:min`, `:max`, `:other`, `:value`). + +### Pydantic schema and short-circuit + +`Field.__get_pydantic_core_schema__` keeps the annotated type's schema. When the chain contains `required`, `required_if` or `nullable`, `None` is accepted at the type level. For `required`/`required_if` fields, `FormRequest`'s wrap `model_validator` fills a missing key with `None` before pydantic runs, so a missing key reaches the rule and gets the rule's message instead of pydantic's `Field required`, and the field stays required in the OpenAPI schema. Without those rules the annotation is enforced as plain pydantic, so `Annotated[str, Field().max(3)]` still rejects `None`. + +Rules do not run per field. A wildcard `field_validator` on `FormRequest` records every field's coerced value in a `ContextVar` opened by the wrap validator. After pydantic has validated every field, the wrap validator runs the synchronous rules of every field in declaration order against the whole model (`dict(instance)`). If pydantic reported type errors, the rules still run for the other fields, against the field defaults, then the raw input, then the recorded coerced values. This removes the declaration-order problem: `same_as` and `required_if` see fields declared later. Within a field: + +- An empty value (`None`, `""`, `[]`, `{}`) skips every non-implicit rule, so a missing optional field never runs `min`/`max`. +- The first failing rule stops the field (Laravel `bail`), so a failing `required` does not also report `min`. +- A field with a pydantic type error runs no rules. + +Rule failures and pydantic's own errors are raised together as one `ValidationError`, so FastAPI turns them into a single `RequestValidationError`. `Field` metadata on a plain `BaseModel` raises `TypeError` when a value is validated, because the rules would silently never run. + +### Async phase for database rules + +`AsyncRule`s (`unique`, `exists`, custom ones) never run inside pydantic. `await model.validate_async(context)` runs them after the model is built, in declaration order, with the same empty-value and bail behaviour, and raises `ValidationException` with a `MessageBag`. It only runs when the synchronous phase passed, so invalid input does not reach the database. + +- `unique(table, column=None, ignore=None, ignore_column="id")` and `exists(table, column=None)` take a table name (queried through `DB.table`) or a `Model` class (`Model.query()`), and use the async `QueryBuilder` (`where(...).exists()`). `column` defaults to the field name. +- `ignore` is a value or a callable receiving the validation data. The data is the model's fields over the `context` mapping. `Validated[...]` passes the route's path parameters as context, so an update route `PUT /users/{user_id}` uses `unique("users", ignore=lambda data: data["user_id"])`, which is Laravel's `unique:users,email,{id}`. +- `await RegisterUser.validate_input(data, context=None)` runs both phases outside FastAPI (commands, jobs, tests) and raises `ValidationException` for either phase. + +### FastAPI integration + +`Validated[Model]` is `Annotated[Model, Depends(dependency)]`, where the dependency takes `request: Request` and the body as `Model`. FastAPI parses the body (the OpenAPI schema is kept), the sync phase runs, then the dependency awaits `validate_async(request.path_params)`. A body parameter typed as the plain model still works but runs only the sync phase. + +The response comes from the existing `ValidationExceptionHandler` that `FastAPIProvider` registers for `RequestValidationError` (ADR 017, ADR 037). `fastapi_startkit.exceptions` already had an unused `ValidationException(errors: dict)`. It is reused instead of adding a second class with the same name: it now subclasses the dependency-free `HttpException` (status 422, `get_response()` is the summary), accepts a `MessageBag` or a `{field: [messages]}` mapping, and keeps `errors` as that mapping. `fastapi_startkit.validation` re-exports it. `FastAPIProvider` registers the same handler for it, so the async phase gets the same JSON, Inertia and session-redirect negotiation. The JSON body becomes `MessageBag.to_dict()`: + +```json +{"message": "The username field is required. (and 2 more errors)", "errors": {"username": ["..."], "email": ["..."]}} +``` + +This is scoped to apps that register `FastAPIProvider`. They already get `{"errors": {...}}` instead of FastAPI's default `{"detail": [...]}` body, and this change only adds the `message` key. FastAPI's default handler, and apps without `FastAPIProvider`, are not touched. + +### Out of scope (follow-ups) + +Docs page; `sometimes`, `array`, `date`/`before`/`after`, `alpha`/`alpha_num`, `digits`, `size`, `different`, `required_with`/`required_without`, `prohibited`, `file`/`image`/`mimes`; rules on nested models and list items with dotted keys; `Field` combined with the form-encoded `RequestModel`; alias-aware error keys; per-rule database connection; translated messages. + +## Validation + +- `tests/validation/` covers each rule's pass and fail cases, default, string and callable messages, `msg()`, `:attribute` humanization, empty-value skipping and bail, `same_as`/`required_if`/`confirmed` regardless of declaration order, type errors combined with rule errors, `TypeError` on a plain `BaseModel`, `MessageBag`, custom `Rule`/`AsyncRule` subclasses, and `unique`/`exists` against SQLite through the async ORM (table name and `Model`, with and without `ignore`). +- A FastAPI app with `FastAPIProvider` checks the 422 body for sync and async failures, the path-parameter `ignore` on an update route, and a successful request. +- Existing `ValidationExceptionHandler` tests are updated for the added `message` key. +- 100% coverage of `fastapi_startkit/validation`, total coverage at least 80%, `ruff check`, `ruff format --check` and `basedpyright` clean for the new files. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 1a2970f5..dc34cfed 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -38,3 +38,4 @@ Read this index first, then open the records relevant to the change. Before impl | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | +| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with path params as context (`ignore=lambda data: data["user_id"]`). The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | diff --git a/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py b/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py index a688a19b..aed89f71 100644 --- a/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py +++ b/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py @@ -1,3 +1,10 @@ +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi_startkit.validation.message_bag import MessageBag + + class DriverNotFound(Exception): pass @@ -215,7 +222,16 @@ class LoaderNotFound(Exception): pass -class ValidationException(Exception): - def __init__(self, errors: dict): - super().__init__("The given data was invalid.") - self.errors = errors +class ValidationException(HttpException): + def __init__(self, errors: "MessageBag | Mapping[str, Iterable[str]]"): + from fastapi_startkit.validation.message_bag import MessageBag + + self.bag = errors if isinstance(errors, MessageBag) else MessageBag(errors) + self.errors = self.bag.messages + super().__init__(self.bag.summary()) + + def get_status(self) -> int: + return 422 + + def get_response(self) -> str: + return self.bag.summary() diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py index 3f5ff1cb..7423baa3 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py @@ -66,7 +66,7 @@ async def render(self, request, exc): class ValidationExceptionHandler: """ - Handles RequestValidationError with content negotiation. + Handles RequestValidationError and ValidationException with content negotiation. JSON requests (API clients) receive a 422 Unprocessable Entity response. Non-JSON requests (browser/Inertia) have errors flashed to the session @@ -84,10 +84,11 @@ async def render(self, request, exc): wants_json = "application/json" in accept or content_type.startswith("application/json") - errors = {} - for err in exc.errors(): - field = ".".join(str(x) for x in err["loc"][1:]) - errors.setdefault(field, []).append(err["msg"]) + from fastapi_startkit.exceptions import ValidationException + from fastapi_startkit.validation.message_bag import MessageBag + + bag = exc.bag if isinstance(exc, ValidationException) else MessageBag.from_errors(exc.errors(), skip=1) + errors = bag.messages if request.headers.get(Header.INERTIA): return self.redirect_back_with_errors(request, errors) @@ -95,7 +96,7 @@ async def render(self, request, exc): if wants_json: from fastapi.responses import JSONResponse - return JSONResponse(status_code=422, content={"errors": errors}) + return JSONResponse(status_code=422, content=bag.to_dict()) if "session" in request.scope: request.session["errors"] = errors diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py index de35c996..08fb9474 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py @@ -37,12 +37,13 @@ def _register_exception_handlers(self): from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException - from fastapi_startkit.exceptions import HttpException + from fastapi_startkit.exceptions import HttpException, ValidationException exception_manager = self.app.exception_manager exception_manager.register_handler(Exception, HTTPExceptionHandler()) exception_manager.register_handler(HTTPException, HTTPExceptionHandler()) exception_manager.register_handler(RequestValidationError, ValidationExceptionHandler()) + exception_manager.register_handler(ValidationException, ValidationExceptionHandler()) async def handler(request, exc): response = await exception_manager.handle(exc, {"request": request}) diff --git a/fastapi_startkit/src/fastapi_startkit/validation/__init__.py b/fastapi_startkit/src/fastapi_startkit/validation/__init__.py new file mode 100644 index 00000000..30d933f2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/__init__.py @@ -0,0 +1,18 @@ +from fastapi_startkit.exceptions import ValidationException +from fastapi_startkit.validation.field import Email, Field +from fastapi_startkit.validation.form_request import FormRequest, Validated +from fastapi_startkit.validation.message_bag import MessageBag +from fastapi_startkit.validation.rules import AsyncRule, BaseRule, Message, Rule + +__all__ = [ + "AsyncRule", + "BaseRule", + "Email", + "Field", + "FormRequest", + "Message", + "MessageBag", + "Rule", + "Validated", + "ValidationException", +] diff --git a/fastapi_startkit/src/fastapi_startkit/validation/field.py b/fastapi_startkit/src/fastapi_startkit/validation/field.py new file mode 100644 index 00000000..e2f89a39 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/field.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import re +from collections.abc import Collection, Mapping +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, Self + +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema, core_schema + +from fastapi_startkit.validation.rules import ( + AsyncRule, + BaseRule, + Between, + Boolean, + Confirmed, + EmailRule, + Exists, + Ignore, + In, + Integer, + Max, + Message, + Min, + NotIn, + Nullable, + Numeric, + Regex, + Required, + RequiredIf, + Rule, + SameAs, + String, + Unique, + Url, + is_empty, +) + +if TYPE_CHECKING: + from fastapi_startkit.masoniteorm.models.model import Model + +coerced_values: ContextVar[dict[str, Any] | None] = ContextVar("coerced_values", default=None) + + +class Field: + def __init__(self) -> None: + self.rules: list[BaseRule] = [] + + def add(self, rule: BaseRule) -> Self: + self.rules.append(rule) + return self + + def msg(self, message: Message) -> Self: + if not self.rules: + raise ValueError("msg() needs a rule before it.") + self.rules[-1].custom_message = message + return self + + def required(self, msg: Message | None = None) -> Self: + return self.add(Required(msg)) + + def nullable(self) -> Self: + return self.add(Nullable()) + + def required_if(self, other: str, value: Any, msg: Message | None = None) -> Self: + return self.add(RequiredIf(other, value, msg)) + + def string(self, msg: Message | None = None) -> Self: + return self.add(String(msg)) + + def integer(self, msg: Message | None = None) -> Self: + return self.add(Integer(msg)) + + def numeric(self, msg: Message | None = None) -> Self: + return self.add(Numeric(msg)) + + def boolean(self, msg: Message | None = None) -> Self: + return self.add(Boolean(msg)) + + def min(self, minimum: int | float, msg: Message | None = None) -> Self: + return self.add(Min(minimum, msg)) + + def max(self, maximum: int | float, msg: Message | None = None) -> Self: + return self.add(Max(maximum, msg)) + + def between(self, minimum: int | float, maximum: int | float, msg: Message | None = None) -> Self: + return self.add(Between(minimum, maximum, msg)) + + def password(self, msg: Message | None = None) -> Self: + return self.min(8, msg) + + def email(self, msg: Message | None = None) -> Self: + return self.add(EmailRule(msg)) + + def url(self, msg: Message | None = None) -> Self: + return self.add(Url(msg)) + + def regex(self, pattern: str | re.Pattern[str], msg: Message | None = None) -> Self: + return self.add(Regex(pattern, msg)) + + def in_(self, values: Collection[Any], msg: Message | None = None) -> Self: + return self.add(In(values, msg)) + + def not_in(self, values: Collection[Any], msg: Message | None = None) -> Self: + return self.add(NotIn(values, msg)) + + def same_as(self, other: str, msg: Message | None = None) -> Self: + return self.add(SameAs(other, msg)) + + def confirmed(self, msg: Message | None = None) -> Self: + return self.add(Confirmed(msg)) + + def unique( + self, + table: str | type[Model], + column: str | None = None, + ignore: Ignore = None, + ignore_column: str = "id", + msg: Message | None = None, + ) -> Self: + return self.add(Unique(table, column, ignore, ignore_column, msg)) + + def exists(self, table: str | type[Model], column: str | None = None, msg: Message | None = None) -> Self: + return self.add(Exists(table, column, msg)) + + def accepts_none(self) -> bool: + return any(isinstance(rule, (Required, RequiredIf, Nullable)) for rule in self.rules) + + def defaults_to_none(self) -> bool: + return any(isinstance(rule, (Required, RequiredIf)) for rule in self.rules) + + def check(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str | None: + for rule in self.rules: + if not isinstance(rule, Rule) or self.skips(rule, value): + continue + if not rule.passes(attribute, value, data): + return rule.format_message(attribute, value, data) + return None + + async def check_async(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str | None: + for rule in self.rules: + if not isinstance(rule, AsyncRule) or self.skips(rule, value): + continue + if not await rule.passes(attribute, value, data): + return rule.format_message(attribute, value, data) + return None + + @staticmethod + def skips(rule: BaseRule, value: Any) -> bool: + return not rule.implicit and is_empty(value) + + def __get_pydantic_core_schema__(self, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: + schema = handler(source_type) + if self.accepts_none(): + schema = core_schema.nullable_schema(schema) + schema = core_schema.no_info_after_validator_function(self.require_form_request, schema) + return schema + + @staticmethod + def require_form_request(value: Any) -> Any: + if coerced_values.get() is None: + raise TypeError("Validation rules need a FormRequest model.") + return value + + +class Email(Field): + def __init__(self) -> None: + super().__init__() + self.email() diff --git a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py new file mode 100644 index 00000000..ba102683 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import inspect +from collections.abc import Iterator, Mapping +from typing import TYPE_CHECKING, Annotated, Any, LiteralString, Self, cast + +from pydantic import ( + BaseModel, + ModelWrapValidatorHandler, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) +from pydantic_core import InitErrorDetails, PydanticCustomError + +from fastapi_startkit.exceptions import ValidationException +from fastapi_startkit.validation.field import Field, coerced_values +from fastapi_startkit.validation.message_bag import MessageBag + + +class FormRequest(BaseModel): + @classmethod + def rule_fields(cls) -> Iterator[tuple[str, Field]]: + for name, info in cls.model_fields.items(): + for metadata in info.metadata: + if isinstance(metadata, Field): + yield name, metadata + + @field_validator("*", mode="after") + @classmethod + def record_value(cls, value: Any, info: ValidationInfo) -> Any: + values = coerced_values.get() + if values is not None: + values[str(info.field_name)] = value + return value + + @model_validator(mode="wrap") + @classmethod + def run_rules(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> Self: + values: dict[str, Any] = {} + token = coerced_values.set(values) + try: + instance = handler(cls.fill_missing(data)) + except ValidationError as error: + raise cls.combine(cls.partial_data(data, values), error.errors(include_url=False)) from None + finally: + coerced_values.reset(token) + failures = cls.rule_failures(dict(instance), set()) + if failures: + raise ValidationError.from_exception_data(cls.__name__, failures) + return instance + + @classmethod + def fill_missing(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return data + missing: dict[str, None] = {} + for name, field in cls.rule_fields(): + info = cls.model_fields[name] + key = info.alias or name + if field.defaults_to_none() and info.is_required() and key not in data: + missing[key] = None + return {**missing, **data} if missing else data + + @classmethod + def partial_data(cls, data: Any, values: dict[str, Any]) -> dict[str, Any]: + defaults = { + name: info.get_default(call_default_factory=True) + for name, info in cls.model_fields.items() + if not info.is_required() + } + raw = data if isinstance(data, Mapping) else {} + return {**defaults, **raw, **values} + + @classmethod + def combine(cls, data: dict[str, Any], errors: list[Any]) -> ValidationError: + failed = {error["loc"][0] for error in errors if error["loc"]} + line_errors: list[InitErrorDetails] = [ + { + "type": PydanticCustomError(error["type"], error["msg"]), + "loc": tuple(error["loc"]), + "input": error["input"], + } + for error in errors + ] + return ValidationError.from_exception_data(cls.__name__, line_errors + cls.rule_failures(data, failed)) + + @classmethod + def rule_failures(cls, data: Mapping[str, Any], failed: set[Any]) -> list[InitErrorDetails]: + failures: list[InitErrorDetails] = [] + for name, field in cls.rule_fields(): + if name in failed: + continue + message = field.check(name, data.get(name), data) + if message is not None: + error = PydanticCustomError("rule", cast("LiteralString", message)) + failures.append({"type": error, "loc": (name,), "input": data.get(name)}) + return failures + + async def validate_async(self, context: Mapping[str, Any] | None = None) -> Self: + data = {**(context or {}), **dict(self)} + bag = MessageBag() + for name, field in self.rule_fields(): + message = await field.check_async(name, data[name], data) + if message is not None: + bag.add(name, message) + if bag.any(): + raise ValidationException(bag) + return self + + @classmethod + async def validate_input(cls, data: Any, context: Mapping[str, Any] | None = None) -> Self: + try: + instance = cls.model_validate(data) + except ValidationError as error: + raise ValidationException(MessageBag.from_errors(error.errors(include_url=False))) from None + return await instance.validate_async(context) + + +def validated_dependency(model: type[FormRequest]) -> Any: + from fastapi import Request + + async def dependency(request: Request, payload: Any) -> Any: + return await payload.validate_async(request.path_params) + + setattr( + dependency, + "__signature__", + inspect.Signature( + [ + inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), + inspect.Parameter("payload", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=model), + ] + ), + ) + return dependency + + +if TYPE_CHECKING: + type Validated[T: FormRequest] = Annotated[T, ...] +else: + + class Validated: + def __class_getitem__(cls, model: type[FormRequest]) -> Any: + from fastapi import Depends + + return Annotated[model, Depends(validated_dependency(model))] diff --git a/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py b/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py new file mode 100644 index 00000000..2b2047c8 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from typing import Any, Self + + +class MessageBag: + 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: + self.add(field, message) + + @classmethod + def from_errors(cls, errors: Iterable[Mapping[str, Any]], skip: int = 0) -> Self: + bag = cls() + for error in errors: + bag.add(".".join(str(part) for part in tuple(error["loc"])[skip:]), error["msg"]) + return bag + + def add(self, field: str, message: str) -> Self: + self.messages.setdefault(field, []).append(message) + return self + + def merge(self, other: MessageBag) -> Self: + for field, messages in other.messages.items(): + for message in messages: + self.add(field, message) + return self + + def has(self, field: str) -> bool: + return field in self.messages + + def get(self, field: str) -> Sequence[str]: + return self.messages.get(field, []) + + def first(self, field: str | None = None) -> str | None: + messages = self.get(field) if field is not None else self.all() + return messages[0] if messages else None + + def all(self) -> list[str]: + return [message for messages in self.messages.values() for message in messages] + + def count(self) -> int: + return len(self.all()) + + def any(self) -> bool: + return bool(self.messages) + + def summary(self) -> str: + first = self.first() or "The given data was invalid." + remaining = self.count() - 1 + if remaining <= 0: + return first + return f"{first} (and {remaining} more error{'s' if remaining > 1 else ''})" + + def to_dict(self) -> dict[str, Any]: + return { + "message": self.summary(), + "errors": {field: list(messages) for field, messages in self.messages.items()}, + } diff --git a/fastapi_startkit/src/fastapi_startkit/validation/rules.py b/fastapi_startkit/src/fastapi_startkit/validation/rules.py new file mode 100644 index 00000000..3c77d4bf --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/rules.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import re +from collections.abc import Callable, Collection, Mapping +from decimal import Decimal, InvalidOperation +from typing import TYPE_CHECKING, Any, ClassVar +from urllib.parse import urlparse + +if TYPE_CHECKING: + from fastapi_startkit.masoniteorm.models.builder import QueryBuilder + from fastapi_startkit.masoniteorm.models.model import Model + +type Message = str | Callable[[str, Any, Mapping[str, Any]], str] +type Ignore = Any | Callable[[Mapping[str, Any]], Any] + +PLACEHOLDER = re.compile(r":(\w+)") + + +def is_empty(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return value.strip() == "" + if isinstance(value, (list, tuple, set, dict)): + return len(value) == 0 + return False + + +def humanize(attribute: str) -> str: + return attribute.replace("_", " ").replace(".", " ").strip() + + +class BaseRule: + message: ClassVar[str] = "The :attribute field is invalid." + implicit: ClassVar[bool] = False + + def __init__(self, message: Message | None = None) -> None: + self.custom_message: Message | None = message + + def default_message(self, value: Any) -> str: + return self.message + + def replacements(self) -> Mapping[str, Any]: + return {} + + def format_message(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str: + if callable(self.custom_message): + return self.custom_message(attribute, value, data) + template = self.custom_message or self.default_message(value) + placeholders = {"attribute": humanize(attribute), **self.replacements()} + return PLACEHOLDER.sub(lambda match: str(placeholders.get(match[1], match[0])), template) + + +class Rule(BaseRule): + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return True + + +class AsyncRule(BaseRule): + async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return True + + +class Required(Rule): + message = "The :attribute field is required." + implicit = True + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return not is_empty(value) + + +class Nullable(Rule): + pass + + +class RequiredIf(Rule): + message = "The :attribute field is required when :other is :value." + implicit = True + + def __init__(self, other: str, expected: Any, message: Message | None = None) -> None: + super().__init__(message) + self.other = other + self.expected = expected + + def replacements(self) -> Mapping[str, Any]: + return {"other": humanize(self.other), "value": self.expected} + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return data.get(self.other) != self.expected or not is_empty(value) + + +class String(Rule): + message = "The :attribute field must be a string." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return isinstance(value, str) + + +class Integer(Rule): + message = "The :attribute field must be an integer." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + if isinstance(value, bool): + return False + if isinstance(value, int): + return True + return isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()) is not None + + +class Numeric(Rule): + message = "The :attribute field must be a number." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return to_number(value) is not None + + +class Boolean(Rule): + message = "The :attribute field must be true or false." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return isinstance(value, bool) or value in (0, 1, "0", "1") + + +def to_number(value: Any) -> int | float | Decimal | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float, Decimal)): + return value + if isinstance(value, str): + try: + return Decimal(value.strip()) + except InvalidOperation: + return None + return None + + +class Size(Rule): + messages: ClassVar[dict[str, str]] = {} + + def __init__( + self, minimum: int | float | None = None, maximum: int | float | None = None, message: Message | None = None + ) -> None: + super().__init__(message) + self.minimum = minimum + self.maximum = maximum + + def replacements(self) -> Mapping[str, Any]: + return {"min": self.minimum, "max": self.maximum} + + def default_message(self, value: Any) -> str: + if isinstance(value, str): + return self.messages["string"] + if isinstance(value, Collection): + return self.messages["array"] + return self.messages["numeric"] + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + size = size_of(value) + if size is None: + return False + return (self.minimum is None or size >= self.minimum) and (self.maximum is None or size <= self.maximum) + + +def size_of(value: Any) -> int | float | Decimal | None: + if isinstance(value, str): + return len(value) + if isinstance(value, (int, float, Decimal)) and not isinstance(value, bool): + return value + if isinstance(value, Collection): + return len(value) + return None + + +class Min(Size): + messages = { + "string": "The :attribute field must be at least :min characters.", + "numeric": "The :attribute field must be at least :min.", + "array": "The :attribute field must have at least :min items.", + } + + def __init__(self, minimum: int | float, message: Message | None = None) -> None: + super().__init__(minimum, None, message) + + +class Max(Size): + messages = { + "string": "The :attribute field must not be greater than :max characters.", + "numeric": "The :attribute field must not be greater than :max.", + "array": "The :attribute field must not have more than :max items.", + } + + def __init__(self, maximum: int | float, message: Message | None = None) -> None: + super().__init__(None, maximum, message) + + +class Between(Size): + messages = { + "string": "The :attribute field must be between :min and :max characters.", + "numeric": "The :attribute field must be between :min and :max.", + "array": "The :attribute field must have between :min and :max items.", + } + + +class EmailRule(Rule): + message = "The :attribute field must be a valid email address." + pattern: ClassVar[re.Pattern[str]] = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+") + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return isinstance(value, str) and self.pattern.fullmatch(value) is not None + + +class Url(Rule): + message = "The :attribute field must be a valid URL." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + if not isinstance(value, str): + return False + parsed = urlparse(value) + return bool(parsed.scheme and parsed.netloc) + + +class Regex(Rule): + message = "The :attribute field format is invalid." + + def __init__(self, pattern: str | re.Pattern[str], message: Message | None = None) -> None: + super().__init__(message) + self.pattern = re.compile(pattern) + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return isinstance(value, str) and self.pattern.search(value) is not None + + +class In(Rule): + message = "The selected :attribute is invalid." + + def __init__(self, values: Collection[Any], message: Message | None = None) -> None: + super().__init__(message) + self.values = values + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return value in self.values + + +class NotIn(In): + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return value not in self.values + + +class SameAs(Rule): + message = "The :attribute field must match :other." + + def __init__(self, other: str, message: Message | None = None) -> None: + super().__init__(message) + self.other = other + + def replacements(self) -> Mapping[str, Any]: + return {"other": humanize(self.other)} + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return value == data.get(self.other) + + +class Confirmed(Rule): + message = "The :attribute field confirmation does not match." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return value == data.get(f"{attribute}_confirmation") + + +class DatabaseRule(AsyncRule): + def __init__(self, table: str | type[Model], column: str | None = None, message: Message | None = None) -> None: + super().__init__(message) + self.table = table + self.column = column + + def query(self, attribute: str, value: Any) -> QueryBuilder[Any]: + if isinstance(self.table, str): + from fastapi_startkit.masoniteorm.facades.DB import DB + + builder = DB.table(self.table) + else: + builder = self.table.query() + return builder.where(self.column or attribute, value) + + +class Unique(DatabaseRule): + message = "The :attribute has already been taken." + + def __init__( + self, + table: str | type[Model], + column: str | None = None, + ignore: Ignore = None, + ignore_column: str = "id", + message: Message | None = None, + ) -> None: + super().__init__(table, column, message) + self.ignore = ignore + self.ignore_column = ignore_column + + async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + query = self.query(attribute, value) + ignore = self.ignore(data) if callable(self.ignore) else self.ignore + if ignore is not None: + query = query.where(self.ignore_column, "!=", ignore) + return not await query.exists() + + +class Exists(DatabaseRule): + message = "The selected :attribute is invalid." + + async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return await self.query(attribute, value).exists() diff --git a/fastapi_startkit/tests/fastapi/test_exceptions.py b/fastapi_startkit/tests/fastapi/test_exceptions.py index 907873ad..aa1459d0 100644 --- a/fastapi_startkit/tests/fastapi/test_exceptions.py +++ b/fastapi_startkit/tests/fastapi/test_exceptions.py @@ -15,7 +15,12 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi_startkit.container import Container -from fastapi_startkit.exceptions import AuthorizationException, MethodNotAllowedException, ThrottleRequestsException +from fastapi_startkit.exceptions import ( + AuthorizationException, + MethodNotAllowedException, + ThrottleRequestsException, + ValidationException, +) from fastapi_startkit.masoniteorm.exceptions import ModelNotFoundException from fastapi_startkit.fastapi.exceptions import ( HTTPExceptionHandler, @@ -141,7 +146,10 @@ async def test_json_request_returns_422(self): response = await ValidationExceptionHandler().render(request, exc) assert response.status_code == 422 - assert json.loads(response.body) == {"errors": {"email": ["field required", "invalid email"]}} + assert json.loads(response.body) == { + "message": "field required (and 1 more error)", + "errors": {"email": ["field required", "invalid email"]}, + } async def test_content_type_json_returns_422(self): exc = FakeValidationError([{"loc": ("body", "name"), "msg": "required"}]) @@ -150,7 +158,7 @@ async def test_content_type_json_returns_422(self): response = await ValidationExceptionHandler().render(request, exc) assert response.status_code == 422 - assert json.loads(response.body) == {"errors": {"name": ["required"]}} + assert json.loads(response.body) == {"message": "required", "errors": {"name": ["required"]}} async def test_non_json_flashes_errors_and_redirects(self): exc = FakeValidationError([{"loc": ("body", "name"), "msg": "required"}]) @@ -182,3 +190,12 @@ async def test_non_json_ignores_cross_origin_referer(self): response = await ValidationExceptionHandler().render(request, exc) assert response.headers["location"] == "/" + + async def test_validation_exception_flashes_its_bag_and_redirects(self): + exc = ValidationException({"email": ["The email has already been taken."]}) + request = FakeRequest(headers={"accept": "text/html", "referer": "/register"}, scope={"session": {}}) + + response = await ValidationExceptionHandler().render(request, exc) + + assert response.status_code == 303 + assert request.session["errors"] == {"email": ["The email has already been taken."]} diff --git a/fastapi_startkit/tests/inertia/test_validation_redirect.py b/fastapi_startkit/tests/inertia/test_validation_redirect.py index bfd0d9bf..6aad8c96 100644 --- a/fastapi_startkit/tests/inertia/test_validation_redirect.py +++ b/fastapi_startkit/tests/inertia/test_validation_redirect.py @@ -127,4 +127,4 @@ def test_json_validation_failure_without_inertia_still_returns_422(): response = make_client({}).post("/users", json={"name": "Taylor"}) assert response.status_code == 422 - assert response.json() == {"errors": {"email": ["Field required"]}} + assert response.json() == {"message": "Field required", "errors": {"email": ["Field required"]}} diff --git a/fastapi_startkit/tests/validation/__init__.py b/fastapi_startkit/tests/validation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/validation/test_database_rules.py b/fastapi_startkit/tests/validation/test_database_rules.py new file mode 100644 index 00000000..9e54f750 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_database_rules.py @@ -0,0 +1,96 @@ +import tempfile +from pathlib import Path +from typing import Annotated +from unittest import IsolatedAsyncioTestCase +from unittest.mock import patch + +from fastapi_startkit.masoniteorm import DatabaseConfig, SQLiteConfig +from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory +from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager +from fastapi_startkit.masoniteorm.facades.DB import DB +from fastapi_startkit.masoniteorm.models.model import Model +from fastapi_startkit.validation import Email, Field, FormRequest, ValidationException + + +class Account(Model): + __table__ = "accounts" + + +class Signup(FormRequest): + email: Annotated[str, Email().required().unique("accounts")] + handle: Annotated[str, Field().required().unique(Account, "username")] + + +class UpdateAccount(FormRequest): + email: Annotated[str, Field().required().unique("accounts", ignore=lambda data: data["account_id"])] + + +class UpdateByHandle(FormRequest): + email: Annotated[str, Field().unique(Account, ignore="jane", ignore_column="username")] + + +class Transfer(FormRequest): + account: Annotated[str, Field().required().exists("accounts", "username")] + owner: Annotated[str | None, Field().exists(Account, "email").msg("Unknown owner :attribute.")] = None + + +class DatabaseRulesTest(IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.tmp = tempfile.TemporaryDirectory() + url = f"sqlite+aiosqlite:///{Path(self.tmp.name) / 'validation.sqlite3'}" + self.manager = DatabaseManager( + ConnectionFactory(), + DatabaseConfig(default="sqlite", connections={"sqlite": SQLiteConfig(driver="sqlite", url=url)}), + ) + original_manager = Model.db_manager + Model.db_manager = self.manager + self.addCleanup(setattr, Model, "db_manager", original_manager) + db_patch = patch.object(DB, "_instance", self.manager) + db_patch.start() + self.addCleanup(db_patch.stop) + + connection = self.manager.connection() + await connection.statement( + "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email VARCHAR(255), username VARCHAR(255))" + ) + await connection.insert("INSERT INTO accounts (id, email, username) VALUES (1, 'jane@example.com', 'jane')") + + async def asyncTearDown(self): + await self.manager.clear() + self.tmp.cleanup() + + async def assert_errors(self, request: FormRequest, errors: dict[str, list[str]], context=None): + with self.assertRaises(ValidationException) as caught: + await request.validate_async(context) + self.assertEqual(caught.exception.errors, errors) + + async def test_unique_passes_for_new_values(self): + request = Signup(email="john@example.com", handle="john") + + self.assertIs(await request.validate_async(), request) + + async def test_unique_fails_for_taken_values_by_table_and_model(self): + await self.assert_errors( + Signup(email="jane@example.com", handle="jane"), + {"email": ["The email has already been taken."], "handle": ["The handle has already been taken."]}, + ) + + async def test_unique_ignores_the_row_from_context(self): + request = UpdateAccount(email="jane@example.com") + + self.assertIs(await request.validate_async({"account_id": 1}), request) + await self.assert_errors(request, {"email": ["The email has already been taken."]}, {"account_id": 2}) + + async def test_unique_ignore_value_and_column(self): + self.assertIsNotNone(await UpdateByHandle(email="jane@example.com").validate_async()) + + async def test_unique_skips_empty_values(self): + self.assertIsNotNone(await UpdateByHandle(email="").validate_async()) + + async def test_exists(self): + self.assertIsNotNone(await Transfer(account="jane", owner="jane@example.com").validate_async()) + self.assertIsNotNone(await Transfer(account="jane").validate_async()) + await self.assert_errors( + Transfer(account="john", owner="john@example.com"), + {"account": ["The selected account is invalid."], "owner": ["Unknown owner owner."]}, + ) diff --git a/fastapi_startkit/tests/validation/test_fastapi_validation.py b/fastapi_startkit/tests/validation/test_fastapi_validation.py new file mode 100644 index 00000000..21461055 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -0,0 +1,125 @@ +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Annotated, Any + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.fastapi import FastAPIProvider +from fastapi_startkit.fastapi.testing import HttpTestCase +from fastapi_startkit.validation import AsyncRule, Email, Field, FormRequest, Validated + +TAKEN = {"jane@example.com": 1} + + +class UniqueEmail(AsyncRule): + message = "The :attribute has already been taken." + + async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + owner = TAKEN.get(value) + return owner is None or str(owner) == str(data.get("user_id")) + + +class RegisterUser(FormRequest): + email: Annotated[str, Email().required().add(UniqueEmail())] + 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")] + + +class UpdateUser(FormRequest): + email: Annotated[str, Email().required().add(UniqueEmail())] + + +def make_application(base_path: Path) -> Application: + app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + + @app.fastapi.post("/register") + async def register(data: Validated[RegisterUser]): + return {"username": data.username} + + @app.fastapi.put("/users/{user_id}") + async def update(user_id: int, data: Validated[UpdateUser]): + return {"id": user_id, "email": data.email} + + @app.fastapi.post("/plain") + async def plain(data: RegisterUser): + return {"username": data.username} + + return app + + +VALID = { + "email": "john@example.com", + "username": "john", + "password": "secret123", + "password_confirmation": "secret123", +} + + +class FastAPIValidationTest(HttpTestCase): + def get_application(self) -> Application: + if not hasattr(self, "_application"): + self._tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp_dir.cleanup) + original_instance = Container._instance + self.addCleanup(lambda: setattr(Container, "_instance", original_instance)) + self._application = make_application(Path(self._tmp_dir.name)) + return self._application + + async def test_valid_request_reaches_the_endpoint(self): + response = await self.post("/register", json=VALID) + + response.assert_status(200) + self.assertEqual(response.json(), {"username": "john"}) + + async def test_sync_rule_failures_return_laravel_shape(self): + response = await self.post("/register", json={"email": "nope", "username": "jo", "password": "secret123"}) + + response.assert_status(422) + self.assertEqual( + response.json(), + { + "message": "The email field must be a valid email address. (and 2 more errors)", + "errors": { + "email": ["The email field must be a valid email address."], + "username": ["The username field must be at least 3 characters."], + "password_confirmation": ["The password confirmation field is required."], + }, + }, + ) + + async def test_async_rule_failures_return_laravel_shape(self): + response = await self.post("/register", json={**VALID, "email": "jane@example.com"}) + + response.assert_status(422) + self.assertEqual( + response.json(), + { + "message": "The email has already been taken.", + "errors": {"email": ["The email has already been taken."]}, + }, + ) + + async def test_path_parameters_are_validation_context(self): + own = await self.put("/users/1", json={"email": "jane@example.com"}) + other = await self.put("/users/2", json={"email": "jane@example.com"}) + + own.assert_status(200) + self.assertEqual(own.json(), {"id": 1, "email": "jane@example.com"}) + other.assert_status(422) + self.assertEqual(other.json()["errors"], {"email": ["The email has already been taken."]}) + + async def test_plain_body_parameter_runs_only_sync_rules(self): + accepted = await self.post("/plain", json={**VALID, "email": "jane@example.com"}) + rejected = await self.post("/plain", json={**VALID, "username": "jo"}) + + accepted.assert_status(200) + rejected.assert_status(422) + self.assertEqual(rejected.json()["errors"], {"username": ["The username field must be at least 3 characters."]}) + + async def test_openapi_keeps_the_request_body_schema(self): + schema = (await self.get("/openapi.json")).json() + body = schema["paths"]["/register"]["post"]["requestBody"]["content"]["application/json"]["schema"] + + self.assertEqual(body, {"$ref": "#/components/schemas/RegisterUser"}) diff --git a/fastapi_startkit/tests/validation/test_form_request.py b/fastapi_startkit/tests/validation/test_form_request.py new file mode 100644 index 00000000..6708497e --- /dev/null +++ b/fastapi_startkit/tests/validation/test_form_request.py @@ -0,0 +1,211 @@ +from collections.abc import Mapping +from typing import Annotated, Any + +import pytest +from pydantic import BaseModel, ValidationError + +from fastapi_startkit.exceptions import HttpException +from fastapi_startkit.validation import AsyncRule, Email, Field, FormRequest, MessageBag, Rule, ValidationException + + +class Captcha(Rule): + message = "The :attribute is wrong." + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return value == "42" + + +class NotReserved(AsyncRule): + message = "The :attribute :value is reserved." + + def __init__(self, reserved: str) -> None: + super().__init__() + self.reserved = reserved + + def replacements(self) -> Mapping[str, Any]: + return {"value": self.reserved} + + async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return value != self.reserved + + +class RegisterUser(FormRequest): + password_confirmation: Annotated[str, Field().required().same_as("password")] + email: Annotated[str, Email().required()] + username: Annotated[ + str, + Field() + .required(msg=lambda field, value, data: f"{field} is required but you provided {value!r}") + .string() + .min(3) + .max(10) + .add(NotReserved("admin")), + ] + password: Annotated[str, Field().required().password()] + age: Annotated[int | None, Field().min(18).msg("Adults only.")] = None + nickname: Annotated[str | None, Field().nullable().min(3)] = None + captcha: Annotated[str | None, Field().add(Captcha())] = None + tax_id: Annotated[str | None, Field().required_if("is_business", True)] = None + is_business: bool = False + + +VALID = { + "email": "jane@example.com", + "username": "jane", + "password": "secret123", + "password_confirmation": "secret123", +} + + +def errors_of(data: dict[str, Any]) -> dict[str, list[str]]: + with pytest.raises(ValidationError) as caught: + RegisterUser.model_validate(data) + return MessageBag.from_errors(caught.value.errors()).messages + + +def test_valid_data_builds_the_model(): + user = RegisterUser.model_validate({**VALID, "age": "20", "nickname": None}) + + assert user.age == 20 + assert user.nickname is None + assert user.tax_id is None + + +def test_missing_required_fields_use_rule_messages(): + assert errors_of({}) == { + "password_confirmation": ["The password confirmation field is required."], + "email": ["The email field is required."], + "username": ["username is required but you provided None"], + "password": ["The password field is required."], + } + + +def test_required_failure_stops_the_field(): + assert errors_of({**VALID, "username": ""})["username"] == ["username is required but you provided ''"] + + +def test_first_failing_rule_stops_the_field(): + assert errors_of({**VALID, "username": "ab"})["username"] == ["The username field must be at least 3 characters."] + + +def test_missing_optional_field_skips_its_rules(): + assert RegisterUser.model_validate({**VALID, "nickname": ""}).nickname == "" + + +def test_msg_replaces_last_rule_message(): + assert errors_of({**VALID, "age": 17}) == {"age": ["Adults only."]} + + +def test_custom_rule(): + assert errors_of({**VALID, "captcha": "41"}) == {"captcha": ["The captcha is wrong."]} + assert RegisterUser.model_validate({**VALID, "captcha": "42"}).captcha == "42" + + +def test_cross_field_rules_see_fields_declared_later(): + errors = errors_of({**VALID, "password_confirmation": "different", "is_business": True}) + + assert errors == { + "password_confirmation": ["The password confirmation field must match password."], + "tax_id": ["The tax id field is required when is business is True."], + } + + +def test_type_errors_are_reported_with_rule_errors(): + errors = errors_of({**VALID, "age": "old", "email": "nope", "is_business": "true"}) + + assert errors == { + "age": ["Input should be a valid integer, unable to parse string as an integer"], + "email": ["The email field must be a valid email address."], + "tax_id": ["The tax id field is required when is business is True."], + } + + +def test_plain_type_errors_are_reraised_unchanged_when_no_rule_fails(): + assert errors_of({**VALID, "is_business": "maybe"}) == { + "is_business": ["Input should be a valid boolean, unable to interpret input"] + } + + +def test_non_mapping_input_keeps_pydantic_error(): + with pytest.raises(ValidationError) as caught: + RegisterUser.model_validate("not a dict") + + assert caught.value.errors()[0]["type"] == "model_type" + + +def test_field_without_nullable_rejects_none(): + class Profile(FormRequest): + bio: Annotated[str, Field().max(5)] = "hi" + + with pytest.raises(ValidationError) as caught: + Profile.model_validate({"bio": None}) + + assert caught.value.errors()[0]["type"] == "string_type" + + +def test_field_rules_need_a_form_request(): + class Plain(BaseModel): + name: Annotated[str, Field().required()] + + with pytest.raises(TypeError, match="FormRequest"): + Plain.model_validate({"name": "jane"}) + + +def test_msg_without_rule_raises(): + with pytest.raises(ValueError, match="needs a rule"): + Field().msg("nothing to replace") + + +async def test_validate_async_runs_async_rules(): + user = RegisterUser.model_validate({**VALID, "username": "admin"}) + + with pytest.raises(ValidationException) as caught: + await user.validate_async() + + assert caught.value.errors == {"username": ["The username admin is reserved."]} + + +async def test_validate_async_returns_model_when_rules_pass(): + user = RegisterUser.model_validate(VALID) + + assert await user.validate_async({"user_id": 1}) is user + + +async def test_validate_input_runs_both_phases(): + assert (await RegisterUser.validate_input(VALID)).username == "jane" + + with pytest.raises(ValidationException) as sync_failure: + await RegisterUser.validate_input({**VALID, "email": "nope"}) + assert sync_failure.value.errors == {"email": ["The email field must be a valid email address."]} + + with pytest.raises(ValidationException) as async_failure: + await RegisterUser.validate_input({**VALID, "username": "admin"}) + assert async_failure.value.errors == {"username": ["The username admin is reserved."]} + + +def test_validation_exception_is_a_422_http_exception(): + exception = ValidationException({"name": ["The name field is required.", "Second."]}) + + assert isinstance(exception, HttpException) + assert exception.get_status() == 422 + assert exception.get_response() == "The name field is required. (and 1 more error)" + assert str(exception) == "The name field is required. (and 1 more error)" + assert exception.bag.messages == exception.errors + + +def test_builder_methods_append_their_rules(): + from fastapi_startkit.validation import rules + + field = Field().integer().numeric().boolean().between(1, 5).url().regex(r"\d").in_([1, 2]).not_in([3]).confirmed() + + assert [type(rule) for rule in field.rules] == [ + rules.Integer, + rules.Numeric, + rules.Boolean, + rules.Between, + rules.Url, + rules.Regex, + rules.In, + rules.NotIn, + rules.Confirmed, + ] diff --git a/fastapi_startkit/tests/validation/test_message_bag.py b/fastapi_startkit/tests/validation/test_message_bag.py new file mode 100644 index 00000000..7f12d77e --- /dev/null +++ b/fastapi_startkit/tests/validation/test_message_bag.py @@ -0,0 +1,49 @@ +from fastapi_startkit.validation import MessageBag + + +def test_empty_bag(): + bag = MessageBag() + + assert not bag.any() + assert bag.count() == 0 + assert bag.first() is None + assert bag.to_dict() == {"message": "The given data was invalid.", "errors": {}} + + +def test_add_get_first_and_has(): + bag = MessageBag().add("email", "Taken.").add("email", "Invalid.").add("name", "Required.") + + assert bag.has("email") + assert not bag.has("password") + assert bag.get("email") == ["Taken.", "Invalid."] + assert bag.get("password") == [] + assert bag.first("name") == "Required." + assert bag.first() == "Taken." + assert bag.all() == ["Taken.", "Invalid.", "Required."] + + +def test_summary_counts_remaining_errors(): + assert MessageBag({"a": ["One."]}).summary() == "One." + assert MessageBag({"a": ["One.", "Two."]}).summary() == "One. (and 1 more error)" + assert MessageBag({"a": ["One."], "b": ["Two.", "Three."]}).to_dict() == { + "message": "One. (and 2 more errors)", + "errors": {"a": ["One."], "b": ["Two.", "Three."]}, + } + + +def test_merge(): + bag = MessageBag({"a": ["One."]}).merge(MessageBag({"a": ["Two."], "b": ["Three."]})) + + assert bag.messages == {"a": ["One.", "Two."], "b": ["Three."]} + + +def test_from_errors_joins_locations_and_skips_prefix(): + errors = [ + {"loc": ("body", "address", "street"), "msg": "Required."}, + {"loc": ("body", "items", 0), "msg": "Invalid."}, + ] + + assert MessageBag.from_errors(errors, skip=1).messages == { + "address.street": ["Required."], + "items.0": ["Invalid."], + } diff --git a/fastapi_startkit/tests/validation/test_rules.py b/fastapi_startkit/tests/validation/test_rules.py new file mode 100644 index 00000000..22a18b55 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_rules.py @@ -0,0 +1,145 @@ +import re +from decimal import Decimal +from typing import Any + +import pytest + +from fastapi_startkit.validation import AsyncRule, Rule +from fastapi_startkit.validation.rules import ( + Between, + Boolean, + Confirmed, + EmailRule, + In, + Integer, + Max, + Min, + NotIn, + Nullable, + Numeric, + Regex, + Required, + RequiredIf, + SameAs, + String, + Url, + humanize, + is_empty, +) + + +@pytest.mark.parametrize("value", [None, "", " ", [], (), set(), {}]) +def test_empty_values(value): + assert is_empty(value) + + +@pytest.mark.parametrize("value", [0, False, "a", [0], {"a": 1}]) +def test_non_empty_values(value): + assert not is_empty(value) + + +def test_humanize(): + assert humanize("password_confirmation") == "password confirmation" + assert humanize("address.street_name") == "address street name" + + +@pytest.mark.parametrize( + ("rule", "passing", "failing"), + [ + (Required(), ["a", 0, [1]], [None, "", " ", []]), + (Nullable(), [None, "a"], []), + (String(), ["a", ""], [1, None, b"a"]), + (Integer(), [1, -2, "3", " -4 "], [True, 1.5, "1.5", "a", None]), + (Numeric(), [1, 1.5, Decimal("2"), "3.25", "-1"], [True, "abc", None, [1]]), + (Boolean(), [True, False, 0, 1, "0", "1"], ["true", 2, None]), + (Min(3), ["abc", 3, 4.5, [1, 2, 3], Decimal("3")], ["ab", 2, [1], True, object()]), + (Max(3), ["abc", 3, [1, 2]], ["abcd", 4, [1, 2, 3, 4]]), + (Between(2, 4), ["ab", 3, [1, 2, 3, 4]], ["a", 5, [1]]), + (EmailRule(), ["a@b.co", "first.last@sub.example.org"], ["a@b", "a b@c.de", "plain", 5]), + (Url(), ["https://example.com/path", "ftp://files.example.com"], ["example.com", "http://", 5]), + (Regex(r"^\d{3}$"), ["123"], ["12", "abcd", 123]), + (Regex(re.compile("ab")), ["xaby"], ["ba"]), + (In(["a", "b"]), ["a", "b"], ["c", None]), + (NotIn(["a", "b"]), ["c"], ["a"]), + ], +) +def test_rule_passes_and_fails(rule: Rule, passing: list[Any], failing: list[Any]): + for value in passing: + assert rule.passes("field", value, {}), value + for value in failing: + assert not rule.passes("field", value, {}), value + + +def test_required_if_only_applies_when_other_matches(): + rule = RequiredIf("is_business", True) + + assert rule.passes("tax_id", None, {"is_business": False}) + assert rule.passes("tax_id", "123", {"is_business": True}) + assert not rule.passes("tax_id", None, {"is_business": True}) + assert rule.format_message("tax_id", None, {}) == "The tax id field is required when is business is True." + + +def test_same_as_compares_with_other_field(): + rule = SameAs("password") + + assert rule.passes("password_confirmation", "secret", {"password": "secret"}) + assert not rule.passes("password_confirmation", "other", {"password": "secret"}) + assert rule.format_message("password_confirmation", "x", {}) == ( + "The password confirmation field must match password." + ) + + +def test_confirmed_compares_with_confirmation_field(): + rule = Confirmed() + + assert rule.passes("password", "secret", {"password_confirmation": "secret"}) + assert not rule.passes("password", "secret", {}) + assert rule.format_message("password", "secret", {}) == "The password field confirmation does not match." + + +@pytest.mark.parametrize( + ("rule", "value", "message"), + [ + (Min(3), "ab", "The name field must be at least 3 characters."), + (Min(3), 2, "The name field must be at least 3."), + (Min(3), [1], "The name field must have at least 3 items."), + (Max(3), "abcd", "The name field must not be greater than 3 characters."), + (Max(3), 4, "The name field must not be greater than 3."), + (Max(3), [1, 2, 3, 4], "The name field must not have more than 3 items."), + (Between(2, 4), "a", "The name field must be between 2 and 4 characters."), + (Between(2, 4), 5, "The name field must be between 2 and 4."), + (Between(2, 4), [1], "The name field must have between 2 and 4 items."), + (Required(), None, "The name field is required."), + (String(), 1, "The name field must be a string."), + (Integer(), "a", "The name field must be an integer."), + (Numeric(), "a", "The name field must be a number."), + (Boolean(), "a", "The name field must be true or false."), + (EmailRule(), "a", "The name field must be a valid email address."), + (Url(), "a", "The name field must be a valid URL."), + (Regex("x"), "a", "The name field format is invalid."), + (In(["x"]), "a", "The selected name is invalid."), + ], +) +def test_default_messages(rule: Rule, value: Any, message: str): + assert rule.format_message("name", value, {}) == message + + +def test_custom_string_message_replaces_placeholders(): + rule = Between(2, 4, ":attribute needs :min-:max (:minimum stays)") + + assert rule.format_message("user_name", "a", {}) == "user name needs 2-4 (:minimum stays)" + + +def test_custom_callable_message_receives_field_value_and_data(): + rule = Required(lambda field, value, data: f"{field}={value!r} with {sorted(data)}") + + assert rule.format_message("username", "", {"a": 1}) == "username='' with ['a']" + + +def test_base_rules_pass_by_default(): + assert Rule().passes("field", "value", {}) + assert Rule().format_message("field", "value", {}) == "The field field is invalid." + + +async def test_base_async_rule_passes_by_default(): + assert await AsyncRule().passes("field", "value", {}) From 0f81e678e5a6e1df985222d1ba7ca74e79333d76 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 29 Oct 2026 02:14:51 -0700 Subject: [PATCH 2/8] Fix validation review findings: typed path context, embed key, nested 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 --- docs/adr/042-fluent-validation-rules.md | 25 ++++-- docs/adr/AGENTS.md | 2 +- .../src/fastapi_startkit/validation/field.py | 13 ++- .../validation/form_request.py | 65 +++++++++++--- .../validation/message_bag.py | 4 +- .../src/fastapi_startkit/validation/rules.py | 21 ++++- .../postgres/test_validated_unique.py | 74 ++++++++++++++++ .../validation/test_fastapi_validation.py | 39 ++++++++- .../tests/validation/test_form_request.py | 65 +++++++++++++- .../tests/validation/test_message_bag.py | 9 +- .../tests/validation/test_rules.py | 23 ++++- .../validation/test_validated_database.py | 85 +++++++++++++++++++ 12 files changed, 391 insertions(+), 34 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py create mode 100644 fastapi_startkit/tests/validation/test_validated_database.py diff --git a/docs/adr/042-fluent-validation-rules.md b/docs/adr/042-fluent-validation-rules.md index 99281ac9..776c2bd0 100644 --- a/docs/adr/042-fluent-validation-rules.md +++ b/docs/adr/042-fluent-validation-rules.md @@ -41,34 +41,39 @@ async def register(data: Validated[RegisterUser]): ... - `Rule` (sync, `passes(attribute, value, data) -> bool`) and `AsyncRule` (`async passes(...)`) share `BaseRule`, which holds the message. Custom rules subclass one of them and set `message`. A rule with `implicit = True` also runs on empty values (`required`, `required_if`). - `Field()` is the builder. Every method appends a rule and returns the builder: `required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password` (`min(8)`), `unique`, `exists`, `add(rule)` and `msg(message)` (replaces the message of the last rule). `Email()` is a `Field()` that starts with the `email` rule. -- `min`/`max`/`between` measure string and list length, and numeric value, with Laravel's per-kind messages. -- Messages are a string or a callable `(field, value, data) -> str`. In strings, `:attribute` becomes the humanized field name (`password_confirmation` → `password confirmation`), and each rule adds its own placeholders (`:min`, `:max`, `:other`, `:value`). +- `min`/`max`/`between` measure string and list length, and numeric value, with Laravel's per-kind messages. When the chain also has `numeric` or `integer`, they compare the numeric value, so `Field().numeric().max(10)` rejects `"25"` on a `str` field (Laravel's `getSize`). +- `in_`/`not_in` treat an unhashable value (a list or dict checked against a set) as not in the collection, so it is a validation error, not a `TypeError`. +- Messages are a string or a callable `(field, value, data) -> str`. In strings, `:attribute` becomes the humanized field name (`password_confirmation` → `password confirmation`), and each rule adds its own placeholders (`:min`, `:max`, `:other`, `:value`). Booleans render in lowercase, so `required_if("is_business", True)` reads "... when is business is true." ### Pydantic schema and short-circuit -`Field.__get_pydantic_core_schema__` keeps the annotated type's schema. When the chain contains `required`, `required_if` or `nullable`, `None` is accepted at the type level. For `required`/`required_if` fields, `FormRequest`'s wrap `model_validator` fills a missing key with `None` before pydantic runs, so a missing key reaches the rule and gets the rule's message instead of pydantic's `Field required`, and the field stays required in the OpenAPI schema. Without those rules the annotation is enforced as plain pydantic, so `Annotated[str, Field().max(3)]` still rejects `None`. +`Field.__get_pydantic_core_schema__` keeps the annotated type's schema. When the chain contains `required`, `required_if` or `nullable`, `None` is accepted at the type level. For `required`/`required_if` fields, `FormRequest`'s wrap `model_validator` fills a missing key with `None` before pydantic runs, so a missing key reaches the rule and gets the rule's message instead of pydantic's `Field required`, and the field stays required in the OpenAPI schema. For `required` fields without `nullable`, the JSON schema shows the plain annotated type rather than `anyOf [type, null]`, because a `null` is always rejected by the rule. Without those rules the annotation is enforced as plain pydantic, so `Annotated[str, Field().max(3)]` still rejects `None`. -Rules do not run per field. A wildcard `field_validator` on `FormRequest` records every field's coerced value in a `ContextVar` opened by the wrap validator. After pydantic has validated every field, the wrap validator runs the synchronous rules of every field in declaration order against the whole model (`dict(instance)`). If pydantic reported type errors, the rules still run for the other fields, against the field defaults, then the raw input, then the recorded coerced values. This removes the declaration-order problem: `same_as` and `required_if` see fields declared later. Within a field: +Rules do not run per field. A wildcard `field_validator` on `FormRequest` records every field's coerced value in a `ContextVar` opened by the wrap validator. After pydantic has validated every field, the wrap validator runs the synchronous rules of every field in declaration order against the whole model (`dict(instance)`). If pydantic reported type errors, the rules still run for the other fields, against the field defaults, then the raw input, then the recorded coerced values. Input that is not a mapping only reports pydantic's type error, because no field values exist to check. This removes the declaration-order problem: `same_as` and `required_if` see fields declared later. Within a field: - An empty value (`None`, `""`, `[]`, `{}`) skips every non-implicit rule, so a missing optional field never runs `min`/`max`. - The first failing rule stops the field (Laravel `bail`), so a failing `required` does not also report `min`. - A field with a pydantic type error runs no rules. -Rule failures and pydantic's own errors are raised together as one `ValidationError`, so FastAPI turns them into a single `RequestValidationError`. `Field` metadata on a plain `BaseModel` raises `TypeError` when a value is validated, because the rules would silently never run. +Rule failures and pydantic's own errors are raised together as one `ValidationError`, so FastAPI turns them into a single `RequestValidationError`. `Field` metadata on a plain `BaseModel` raises `TypeError` when a value is validated, because the rules would silently never run. Inside a `FormRequest` the outer model's `ContextVar` hides that check for nested models, so `FormRequest.__pydantic_init_subclass__` raises `TypeError` when a field's annotation (including `list[...]`, `X | None` and other generic arguments) contains a model with `Field` rules. Nested rules with dotted keys stay a follow-up. Raising keeps the guarantee that rules never silently fail to run. ### Async phase for database rules `AsyncRule`s (`unique`, `exists`, custom ones) never run inside pydantic. `await model.validate_async(context)` runs them after the model is built, in declaration order, with the same empty-value and bail behaviour, and raises `ValidationException` with a `MessageBag`. It only runs when the synchronous phase passed, so invalid input does not reach the database. - `unique(table, column=None, ignore=None, ignore_column="id")` and `exists(table, column=None)` take a table name (queried through `DB.table`) or a `Model` class (`Model.query()`), and use the async `QueryBuilder` (`where(...).exists()`). `column` defaults to the field name. -- `ignore` is a value or a callable receiving the validation data. The data is the model's fields over the `context` mapping. `Validated[...]` passes the route's path parameters as context, so an update route `PUT /users/{user_id}` uses `unique("users", ignore=lambda data: data["user_id"])`, which is Laravel's `unique:users,email,{id}`. +- `ignore` is a value or a callable receiving the validation data. The data is the model's fields over the `context` mapping. `Validated[...]` passes the route's typed path parameters as context, so an update route `PUT /users/{user_id}` uses `unique("users", ignore=lambda data: data["user_id"])`, which is Laravel's `unique:users,email,{id}`. - `await RegisterUser.validate_input(data, context=None)` runs both phases outside FastAPI (commands, jobs, tests) and raises `ValidationException` for either phase. ### FastAPI integration -`Validated[Model]` is `Annotated[Model, Depends(dependency)]`, where the dependency takes `request: Request` and the body as `Model`. FastAPI parses the body (the OpenAPI schema is kept), the sync phase runs, then the dependency awaits `validate_async(request.path_params)`. A body parameter typed as the plain model still works but runs only the sync phase. +`Validated[Model]` is `Annotated[Model, Depends(dependency)]`, where the dependency takes `request: Request` and the body as `Model`. FastAPI parses the body (the OpenAPI schema is kept), the sync phase runs, then the dependency awaits `validate_async(context)`. A body parameter typed as the plain model still works but runs only the sync phase. -The response comes from the existing `ValidationExceptionHandler` that `FastAPIProvider` registers for `RequestValidationError` (ADR 017, ADR 037). `fastapi_startkit.exceptions` already had an unused `ValidationException(errors: dict)`. It is reused instead of adding a second class with the same name: it now subclasses the dependency-free `HttpException` (status 422, `get_response()` is the summary), accepts a `MessageBag` or a `{field: [messages]}` mapping, and keeps `errors` as that mapping. `fastapi_startkit.validation` re-exports it. `FastAPIProvider` registers the same handler for it, so the async phase gets the same JSON, Inertia and session-redirect negotiation. The JSON body becomes `MessageBag.to_dict()`: +FastAPI solves sub-dependencies before it validates the endpoint's own path parameters, and `request.path_params` only holds strings. Passing those strings made `unique(..., ignore=lambda data: data["user_id"])` bind `'1'` against an integer column, which asyncpg rejects with a `DataError` (a 500; SQLite hides it through type affinity), and `PUT /users/abc` queried the database before the `user_id: int` error was reported. The dependency now reads the matched `APIRoute` from `request.scope["route"]` and converts the raw values with FastAPI's own `request_params_to_args` over every path parameter declared on the route (`get_flat_dependant(route.dependant).path_params`). The context is the converted values over the raw ones, so undeclared path parameters stay strings. If any declared path parameter is invalid, the dependency returns the model without running the async phase, and FastAPI then reports its normal path-parameter 422 when it validates the endpoint. Alternatives considered: coercing in user code (`int(data["user_id"])`) moves the bug to every caller and still queries with invalid input; a custom `APIRoute` class only covers routers that use it, so async rules would silently not run elsewhere. + +The dependency's body parameter is named after the model in snake case (`UpdateUser` → `update_user`). With a single body parameter the name is invisible because FastAPI does not embed the body. When the endpoint has other body parameters, FastAPI embeds each body under its parameter name, and for `Validated` that is the dependency's parameter, not the endpoint's: FastAPI builds a dependency's signature without the name of the parameter that declares it, so the endpoint's name cannot be used. `async def note(data: Validated[UpdateUser], note: Annotated[str, Body()])` therefore expects `{"update_user": {...}, "note": "..."}`, and OpenAPI shows the same keys. Naming the key after the model (instead of a fixed `payload`) keeps it predictable and lets two different `Validated` models on one endpoint coexist. An endpoint that needs its own key can declare `data: Model` and call `await data.validate_async(context)`. + +The response comes from the existing `ValidationExceptionHandler` that `FastAPIProvider` registers for `RequestValidationError` (ADR 017, ADR 037). `fastapi_startkit.exceptions` already had an unused `ValidationException(errors: dict)`. It is reused instead of adding a second class with the same name: it now subclasses the dependency-free `HttpException` (status 422, `get_response()` is the summary), accepts a `MessageBag` or a `{field: [messages]}` mapping, and keeps `errors` as that mapping. A string value counts as one message, so the old call shape `ValidationException({"email": "Taken."})` still gives `{"email": ["Taken."]}`. `fastapi_startkit.validation` re-exports it. `FastAPIProvider` registers the same handler for it, so the async phase gets the same JSON, Inertia and session-redirect negotiation. The JSON body becomes `MessageBag.to_dict()`: ```json {"message": "The username field is required. (and 2 more errors)", "errors": {"username": ["..."], "email": ["..."]}} @@ -83,6 +88,8 @@ Docs page; `sometimes`, `array`, `date`/`before`/`after`, `alpha`/`alpha_num`, ` ## Validation - `tests/validation/` covers each rule's pass and fail cases, default, string and callable messages, `msg()`, `:attribute` humanization, empty-value skipping and bail, `same_as`/`required_if`/`confirmed` regardless of declaration order, type errors combined with rule errors, `TypeError` on a plain `BaseModel`, `MessageBag`, custom `Rule`/`AsyncRule` subclasses, and `unique`/`exists` against SQLite through the async ORM (table name and `Model`, with and without `ignore`). -- A FastAPI app with `FastAPIProvider` checks the 422 body for sync and async failures, the path-parameter `ignore` on an update route, and a successful request. +- A FastAPI app with `FastAPIProvider` checks the 422 body for sync and async failures, the path-parameter `ignore` on an update route (the context holds the typed `int`), an invalid path parameter that returns the path error without running async rules, a multi-body endpoint with the model-name embed key, and a successful request. +- A real `unique(..., ignore=lambda data: data[...])` through `Validated` runs against SQLite with the bound query parameter asserted to be an `int`, and against Postgres (asyncpg) in `tests/masoniteorm/postgres/test_validated_unique.py`, where the old string context failed with `DataError`. An invalid path parameter never reaches the database. +- Rules on a nested model (direct, `list[...]`, optional) raise `TypeError` at class definition; `in_` with unhashable values, string messages in `MessageBag`, lowercase boolean placeholders, numeric `max`, plain JSON schema for required fields, and non-mapping input are covered. - Existing `ValidationExceptionHandler` tests are updated for the added `message` key. - 100% coverage of `fastapi_startkit/validation`, total coverage at least 80%, `ruff check`, `ruff format --check` and `basedpyright` clean for the new files. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index dc34cfed..741b20ba 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -38,4 +38,4 @@ Read this index first, then open the records relevant to the change. Before impl | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | -| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with path params as context (`ignore=lambda data: data["user_id"]`). The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | +| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | diff --git a/fastapi_startkit/src/fastapi_startkit/validation/field.py b/fastapi_startkit/src/fastapi_startkit/validation/field.py index e2f89a39..2739707e 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/field.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/field.py @@ -30,6 +30,7 @@ RequiredIf, Rule, SameAs, + Size, String, Unique, Url, @@ -48,6 +49,10 @@ def __init__(self) -> None: def add(self, rule: BaseRule) -> Self: self.rules.append(rule) + numeric = any(isinstance(rule, (Numeric, Integer)) for rule in self.rules) + for size in self.rules: + if isinstance(size, Size): + size.numeric = numeric return self def msg(self, message: Message) -> Self: @@ -152,10 +157,16 @@ def skips(rule: BaseRule, value: Any) -> bool: def __get_pydantic_core_schema__(self, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: schema = handler(source_type) if self.accepts_none(): - schema = core_schema.nullable_schema(schema) + schema = core_schema.nullable_schema(schema, metadata=self.json_schema_metadata(schema)) schema = core_schema.no_info_after_validator_function(self.require_form_request, schema) return schema + def json_schema_metadata(self, schema: CoreSchema) -> dict[str, Any] | None: + required = any(isinstance(rule, Required) for rule in self.rules) + if not required or any(isinstance(rule, Nullable) for rule in self.rules): + return None + return {"pydantic_js_functions": [lambda _, handler: handler(schema)]} + @staticmethod def require_form_request(value: Any) -> Any: if coerced_values.get() is None: diff --git a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py index ba102683..d794d7e0 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py @@ -2,8 +2,9 @@ import inspect from collections.abc import Iterator, Mapping -from typing import TYPE_CHECKING, Annotated, Any, LiteralString, Self, cast +from typing import TYPE_CHECKING, Annotated, Any, LiteralString, Self, cast, get_args +from inflection import underscore from pydantic import ( BaseModel, ModelWrapValidatorHandler, @@ -18,8 +19,32 @@ from fastapi_startkit.validation.field import Field, coerced_values from fastapi_startkit.validation.message_bag import MessageBag +if TYPE_CHECKING: + from starlette.requests import Request + + +def has_rules(model: type[BaseModel]) -> bool: + return any(isinstance(metadata, Field) for info in model.model_fields.values() for metadata in info.metadata) + + +def nested_models(annotation: Any) -> Iterator[type[BaseModel]]: + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + yield annotation + for argument in get_args(annotation): + yield from nested_models(argument) + class FormRequest(BaseModel): + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + super().__pydantic_init_subclass__(**kwargs) + for name, info in cls.model_fields.items(): + for model in nested_models(info.annotation): + if has_rules(model): + raise TypeError( + f"{cls.__name__}.{name}: validation rules on nested model {model.__name__} are not supported." + ) + @classmethod def rule_fields(cls) -> Iterator[tuple[str, Field]]: for name, info in cls.model_fields.items(): @@ -43,7 +68,7 @@ def run_rules(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> Self: try: instance = handler(cls.fill_missing(data)) except ValidationError as error: - raise cls.combine(cls.partial_data(data, values), error.errors(include_url=False)) from None + raise cls.combine(data, values, error.errors(include_url=False)) from None finally: coerced_values.reset(token) failures = cls.rule_failures(dict(instance), set()) @@ -64,18 +89,16 @@ def fill_missing(cls, data: Any) -> Any: return {**missing, **data} if missing else data @classmethod - def partial_data(cls, data: Any, values: dict[str, Any]) -> dict[str, Any]: + def partial_data(cls, data: Mapping[str, Any], values: dict[str, Any]) -> dict[str, Any]: defaults = { name: info.get_default(call_default_factory=True) for name, info in cls.model_fields.items() if not info.is_required() } - raw = data if isinstance(data, Mapping) else {} - return {**defaults, **raw, **values} + return {**defaults, **data, **values} @classmethod - def combine(cls, data: dict[str, Any], errors: list[Any]) -> ValidationError: - failed = {error["loc"][0] for error in errors if error["loc"]} + def combine(cls, data: Any, values: dict[str, Any], errors: list[Any]) -> ValidationError: line_errors: list[InitErrorDetails] = [ { "type": PydanticCustomError(error["type"], error["msg"]), @@ -84,7 +107,10 @@ def combine(cls, data: dict[str, Any], errors: list[Any]) -> ValidationError: } for error in errors ] - return ValidationError.from_exception_data(cls.__name__, line_errors + cls.rule_failures(data, failed)) + if isinstance(data, Mapping): + failed = {error["loc"][0] for error in errors if error["loc"]} + line_errors += cls.rule_failures(cls.partial_data(data, values), failed) + return ValidationError.from_exception_data(cls.__name__, line_errors) @classmethod def rule_failures(cls, data: Mapping[str, Any], failed: set[Any]) -> list[InitErrorDetails]: @@ -118,11 +144,28 @@ async def validate_input(cls, data: Any, context: Mapping[str, Any] | None = Non return await instance.validate_async(context) +def typed_path_params(request: Request) -> dict[str, Any] | None: + from fastapi.dependencies.utils import get_flat_dependant, request_params_to_args + from fastapi.routing import APIRoute + + route = request.scope.get("route") + if not isinstance(route, APIRoute): + return dict(request.path_params) + values, errors = request_params_to_args(get_flat_dependant(route.dependant).path_params, request.path_params) + return None if errors else {**request.path_params, **values} + + def validated_dependency(model: type[FormRequest]) -> Any: from fastapi import Request - async def dependency(request: Request, payload: Any) -> Any: - return await payload.validate_async(request.path_params) + key = underscore(model.__name__) + + async def dependency(request: Request, **body: Any) -> Any: + payload = body[key] + context = typed_path_params(request) + if context is None: + return payload + return await payload.validate_async(context) setattr( dependency, @@ -130,7 +173,7 @@ async def dependency(request: Request, payload: Any) -> Any: inspect.Signature( [ inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), - inspect.Parameter("payload", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=model), + inspect.Parameter(key, inspect.Parameter.KEYWORD_ONLY, annotation=model), ] ), ) diff --git a/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py b/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py index 2b2047c8..4a0fbd43 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py @@ -5,10 +5,10 @@ class MessageBag: - def __init__(self, messages: Mapping[str, Iterable[str]] | None = None) -> None: + def __init__(self, messages: Mapping[str, 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: + for message in [field_messages] if isinstance(field_messages, str) else field_messages: self.add(field, message) @classmethod diff --git a/fastapi_startkit/src/fastapi_startkit/validation/rules.py b/fastapi_startkit/src/fastapi_startkit/validation/rules.py index 3c77d4bf..edb6c7a7 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/rules.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/rules.py @@ -26,6 +26,10 @@ def is_empty(value: Any) -> bool: return False +def display(value: Any) -> Any: + return str(value).lower() if isinstance(value, bool) else value + + def humanize(attribute: str) -> str: return attribute.replace("_", " ").replace(".", " ").strip() @@ -83,7 +87,7 @@ def __init__(self, other: str, expected: Any, message: Message | None = None) -> self.expected = expected def replacements(self) -> Mapping[str, Any]: - return {"other": humanize(self.other), "value": self.expected} + return {"other": humanize(self.other), "value": display(self.expected)} def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: return data.get(self.other) != self.expected or not is_empty(value) @@ -143,11 +147,14 @@ def __init__( super().__init__(message) self.minimum = minimum self.maximum = maximum + self.numeric = False def replacements(self) -> Mapping[str, Any]: return {"min": self.minimum, "max": self.maximum} def default_message(self, value: Any) -> str: + if self.numeric: + return self.messages["numeric"] if isinstance(value, str): return self.messages["string"] if isinstance(value, Collection): @@ -155,7 +162,7 @@ def default_message(self, value: Any) -> str: return self.messages["numeric"] def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: - size = size_of(value) + size = to_number(value) if self.numeric else size_of(value) if size is None: return False return (self.minimum is None or size >= self.minimum) and (self.maximum is None or size <= self.maximum) @@ -237,13 +244,19 @@ def __init__(self, values: Collection[Any], message: Message | None = None) -> N super().__init__(message) self.values = values + def contains(self, value: Any) -> bool: + try: + return value in self.values + except TypeError: + return False + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: - return value in self.values + return self.contains(value) class NotIn(In): def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: - return value not in self.values + return not self.contains(value) class SameAs(Rule): diff --git a/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py b/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py new file mode 100644 index 00000000..090d4101 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py @@ -0,0 +1,74 @@ +import tempfile +from pathlib import Path +from typing import Annotated +from unittest.mock import patch + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.fastapi import FastAPIProvider +from fastapi_startkit.fastapi.testing import HttpTestCase +from fastapi_startkit.masoniteorm.facades.DB import DB as DBFacade +from fastapi_startkit.masoniteorm.models.model import Model +from fastapi_startkit.validation import Email, FormRequest, Validated + +from .fixtures.db import DB + + +class UpdateMember(FormRequest): + email: Annotated[str, Email().required().unique("validation_members", ignore=lambda data: data["member_id"])] + + +def make_application(base_path: Path) -> Application: + app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + + @app.fastapi.put("/members/{member_id}") + async def update(member_id: int, data: Validated[UpdateMember]): + return {"id": member_id, "email": data.email} + + return app + + +class ValidatedUniquePostgresTest(HttpTestCase): + def get_application(self) -> Application: + if not hasattr(self, "_application"): + self._tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp_dir.cleanup) + original_instance = Container._instance + self.addCleanup(lambda: setattr(Container, "_instance", original_instance)) + self._application = make_application(Path(self._tmp_dir.name)) + return self._application + + async def asyncSetUp(self): + await super().asyncSetUp() + original_manager = Model.db_manager + Model.db_manager = DB + self.addCleanup(setattr, Model, "db_manager", original_manager) + db_patch = patch.object(DBFacade, "_instance", DB) + db_patch.start() + self.addCleanup(db_patch.stop) + + await DB.clear() + connection = DB.connection() + await connection.statement("DROP TABLE IF EXISTS validation_members") + await connection.statement("CREATE TABLE validation_members (id INTEGER PRIMARY KEY, email VARCHAR(255))") + await connection.insert("INSERT INTO validation_members (id, email) VALUES (1, 'jane@example.com')") + + async def asyncTearDown(self): + await DB.connection().statement("DROP TABLE IF EXISTS validation_members") + await DB.clear() + await super().asyncTearDown() + + async def test_unique_ignore_uses_the_typed_path_parameter(self): + own = await self.put("/members/1", json={"email": "jane@example.com"}) + other = await self.put("/members/2", json={"email": "jane@example.com"}) + + own.assert_status(200) + self.assertEqual(own.json(), {"id": 1, "email": "jane@example.com"}) + other.assert_status(422) + self.assertEqual(other.json()["errors"], {"email": ["The email has already been taken."]}) + + async def test_invalid_path_parameter_returns_the_path_error(self): + response = await self.put("/members/abc", json={"email": "jane@example.com"}) + + response.assert_status(422) + self.assertEqual(list(response.json()["errors"]), ["member_id"]) diff --git a/fastapi_startkit/tests/validation/test_fastapi_validation.py b/fastapi_startkit/tests/validation/test_fastapi_validation.py index 21461055..582a0489 100644 --- a/fastapi_startkit/tests/validation/test_fastapi_validation.py +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -3,21 +3,27 @@ from pathlib import Path from typing import Annotated, Any +from fastapi import Body +from starlette.requests import Request + from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container from fastapi_startkit.fastapi import FastAPIProvider from fastapi_startkit.fastapi.testing import HttpTestCase from fastapi_startkit.validation import AsyncRule, Email, Field, FormRequest, Validated +from fastapi_startkit.validation.form_request import typed_path_params TAKEN = {"jane@example.com": 1} +LOOKUPS: list[Any] = [] class UniqueEmail(AsyncRule): message = "The :attribute has already been taken." async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + LOOKUPS.append(data.get("user_id")) owner = TAKEN.get(value) - return owner is None or str(owner) == str(data.get("user_id")) + return owner is None or owner == data.get("user_id") class RegisterUser(FormRequest): @@ -42,6 +48,10 @@ async def register(data: Validated[RegisterUser]): async def update(user_id: int, data: Validated[UpdateUser]): return {"id": user_id, "email": data.email} + @app.fastapi.post("/users/{user_id}/notes") + async def note(user_id: int, data: Validated[UpdateUser], note: Annotated[str, Body()]): + return {"id": user_id, "email": data.email, "note": note} + @app.fastapi.post("/plain") async def plain(data: RegisterUser): return {"username": data.username} @@ -110,6 +120,27 @@ async def test_path_parameters_are_validation_context(self): other.assert_status(422) self.assertEqual(other.json()["errors"], {"email": ["The email has already been taken."]}) + async def test_invalid_path_parameter_is_reported_before_async_rules(self): + LOOKUPS.clear() + + response = await self.put("/users/abc", json={"email": "jane@example.com"}) + + response.assert_status(422) + self.assertEqual(list(response.json()["errors"]), ["user_id"]) + self.assertEqual(LOOKUPS, []) + + async def test_validated_body_is_embedded_under_the_model_name_with_other_body_params(self): + valid = await self.post("/users/1/notes", json={"update_user": {"email": "jane@example.com"}, "note": "hi"}) + taken = await self.post("/users/2/notes", json={"update_user": {"email": "jane@example.com"}, "note": "hi"}) + schema = (await self.get("/openapi.json")).json() + body = schema["components"]["schemas"]["Body_note_users__user_id__notes_post"] + + valid.assert_status(200) + self.assertEqual(valid.json(), {"id": 1, "email": "jane@example.com", "note": "hi"}) + taken.assert_status(422) + self.assertEqual(taken.json()["errors"], {"email": ["The email has already been taken."]}) + self.assertEqual(sorted(body["properties"]), ["note", "update_user"]) + async def test_plain_body_parameter_runs_only_sync_rules(self): accepted = await self.post("/plain", json={**VALID, "email": "jane@example.com"}) rejected = await self.post("/plain", json={**VALID, "username": "jo"}) @@ -123,3 +154,9 @@ async def test_openapi_keeps_the_request_body_schema(self): body = schema["paths"]["/register"]["post"]["requestBody"]["content"]["application/json"]["schema"] self.assertEqual(body, {"$ref": "#/components/schemas/RegisterUser"}) + + +def test_path_params_without_a_matched_route_stay_raw(): + request = Request({"type": "http", "path_params": {"user_id": "1"}}) + + assert typed_path_params(request) == {"user_id": "1"} diff --git a/fastapi_startkit/tests/validation/test_form_request.py b/fastapi_startkit/tests/validation/test_form_request.py index 6708497e..248ff25e 100644 --- a/fastapi_startkit/tests/validation/test_form_request.py +++ b/fastapi_startkit/tests/validation/test_form_request.py @@ -106,7 +106,7 @@ def test_cross_field_rules_see_fields_declared_later(): assert errors == { "password_confirmation": ["The password confirmation field must match password."], - "tax_id": ["The tax id field is required when is business is True."], + "tax_id": ["The tax id field is required when is business is true."], } @@ -116,7 +116,7 @@ def test_type_errors_are_reported_with_rule_errors(): assert errors == { "age": ["Input should be a valid integer, unable to parse string as an integer"], "email": ["The email field must be a valid email address."], - "tax_id": ["The tax id field is required when is business is True."], + "tax_id": ["The tax id field is required when is business is true."], } @@ -130,7 +130,7 @@ def test_non_mapping_input_keeps_pydantic_error(): with pytest.raises(ValidationError) as caught: RegisterUser.model_validate("not a dict") - assert caught.value.errors()[0]["type"] == "model_type" + assert [error["type"] for error in caught.value.errors()] == ["model_type"] def test_field_without_nullable_rejects_none(): @@ -151,6 +151,65 @@ class Plain(BaseModel): Plain.model_validate({"name": "jane"}) +def test_rules_on_nested_models_raise_at_class_definition(): + class Address(BaseModel): + code: Annotated[str, Field().required().min(5)] + + with pytest.raises(TypeError, match=r"Order\.address: validation rules on nested model Address"): + + class Order(FormRequest): + address: Address + + with pytest.raises(TypeError, match=r"Shipment\.stops: validation rules on nested model Address"): + + class Shipment(FormRequest): + stops: list[Address] | None = None + + +def test_nested_models_without_rules_are_allowed(): + class Address(BaseModel): + code: str + + class Order(FormRequest): + address: Address + name: Annotated[str, Field().required()] + + assert Order.model_validate({"address": {"code": "ab"}, "name": "x"}).address.code == "ab" + + +def test_numeric_fields_compare_size_by_value(): + class Payment(FormRequest): + amount: Annotated[str, Field().required().max(10).numeric()] + count: Annotated[str | None, Field().integer().between(1, 3)] = None + + assert Payment.model_validate({"amount": "9.5", "count": "2"}).amount == "9.5" + with pytest.raises(ValidationError) as caught: + Payment.model_validate({"amount": "25", "count": "12"}) + + assert MessageBag.from_errors(caught.value.errors()).messages == { + "amount": ["The amount field must not be greater than 10."], + "count": ["The count field must be between 1 and 3."], + } + + +def test_in_rule_rejects_unhashable_values(): + class Pick(FormRequest): + choice: Annotated[Any, Field().required().in_({"a", "b"})] + + with pytest.raises(ValidationError) as caught: + Pick.model_validate({"choice": ["a"]}) + + assert MessageBag.from_errors(caught.value.errors()).messages == {"choice": ["The selected choice is invalid."]} + + +def test_json_schema_keeps_required_fields_non_nullable(): + properties = RegisterUser.model_json_schema()["properties"] + + assert properties["email"] == {"title": "Email", "type": "string"} + assert properties["nickname"]["anyOf"] == [{"type": "string"}, {"type": "null"}] + assert properties["tax_id"]["anyOf"] == [{"type": "string"}, {"type": "null"}] + + def test_msg_without_rule_raises(): with pytest.raises(ValueError, match="needs a rule"): Field().msg("nothing to replace") diff --git a/fastapi_startkit/tests/validation/test_message_bag.py b/fastapi_startkit/tests/validation/test_message_bag.py index 7f12d77e..b13eab61 100644 --- a/fastapi_startkit/tests/validation/test_message_bag.py +++ b/fastapi_startkit/tests/validation/test_message_bag.py @@ -1,4 +1,4 @@ -from fastapi_startkit.validation import MessageBag +from fastapi_startkit.validation import MessageBag, ValidationException def test_empty_bag(): @@ -47,3 +47,10 @@ def test_from_errors_joins_locations_and_skips_prefix(): "address.street": ["Required."], "items.0": ["Invalid."], } + + +def test_string_messages_are_single_messages(): + bag = MessageBag({"email": "Taken.", "name": ["Short.", "Odd."]}) + + assert bag.messages == {"email": ["Taken."], "name": ["Short.", "Odd."]} + assert ValidationException({"email": "Taken."}).errors == {"email": ["Taken."]} diff --git a/fastapi_startkit/tests/validation/test_rules.py b/fastapi_startkit/tests/validation/test_rules.py index 22a18b55..a0051b35 100644 --- a/fastapi_startkit/tests/validation/test_rules.py +++ b/fastapi_startkit/tests/validation/test_rules.py @@ -60,6 +60,8 @@ def test_humanize(): (Regex(r"^\d{3}$"), ["123"], ["12", "abcd", 123]), (Regex(re.compile("ab")), ["xaby"], ["ba"]), (In(["a", "b"]), ["a", "b"], ["c", None]), + (In({"a", "b"}), ["a"], [["a"], {"a": 1}]), + (NotIn({"a", "b"}), ["c", ["a"], {"a": 1}], ["a"]), (NotIn(["a", "b"]), ["c"], ["a"]), ], ) @@ -76,7 +78,7 @@ def test_required_if_only_applies_when_other_matches(): assert rule.passes("tax_id", None, {"is_business": False}) assert rule.passes("tax_id", "123", {"is_business": True}) assert not rule.passes("tax_id", None, {"is_business": True}) - assert rule.format_message("tax_id", None, {}) == "The tax id field is required when is business is True." + assert rule.format_message("tax_id", None, {}) == "The tax id field is required when is business is true." def test_same_as_compares_with_other_field(): @@ -143,3 +145,22 @@ def test_base_rules_pass_by_default(): async def test_base_async_rule_passes_by_default(): assert await AsyncRule().passes("field", "value", {}) + + +def test_required_if_renders_booleans_in_lowercase(): + assert RequiredIf("active", False).format_message("reason", None, {}) == ( + "The reason field is required when active is false." + ) + assert ( + RequiredIf("kind", "pro").format_message("tax_id", None, {}) == "The tax id field is required when kind is pro." + ) + + +def test_size_rules_compare_numeric_value_when_numeric(): + rule = Max(10) + rule.numeric = True + + assert rule.passes("amount", "9.5", {}) + assert not rule.passes("amount", "25", {}) + assert not rule.passes("amount", "abc", {}) + assert rule.format_message("amount", "25", {}) == "The amount field must not be greater than 10." diff --git a/fastapi_startkit/tests/validation/test_validated_database.py b/fastapi_startkit/tests/validation/test_validated_database.py new file mode 100644 index 00000000..d1e92c9f --- /dev/null +++ b/fastapi_startkit/tests/validation/test_validated_database.py @@ -0,0 +1,85 @@ +import tempfile +from pathlib import Path +from typing import Annotated +from unittest.mock import patch + +from fastapi_startkit.application import Application +from fastapi_startkit.container.container import Container +from fastapi_startkit.fastapi import FastAPIProvider +from fastapi_startkit.fastapi.testing import HttpTestCase +from fastapi_startkit.masoniteorm import DatabaseConfig, SQLiteConfig +from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory +from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager +from fastapi_startkit.masoniteorm.facades.DB import DB +from fastapi_startkit.masoniteorm.models.model import Model +from fastapi_startkit.validation import Email, FormRequest, Validated + + +class UpdateAccount(FormRequest): + email: Annotated[str, Email().required().unique("accounts", ignore=lambda data: data["account_id"])] + + +def make_application(base_path: Path) -> Application: + app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + + @app.fastapi.put("/accounts/{account_id}") + async def update(account_id: int, data: Validated[UpdateAccount]): + return {"id": account_id, "email": data.email} + + return app + + +class ValidatedUniqueTest(HttpTestCase): + def get_application(self) -> Application: + if not hasattr(self, "_application"): + self._tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp_dir.cleanup) + original_instance = Container._instance + self.addCleanup(lambda: setattr(Container, "_instance", original_instance)) + self._application = make_application(Path(self._tmp_dir.name)) + return self._application + + async def asyncSetUp(self): + await super().asyncSetUp() + url = f"sqlite+aiosqlite:///{Path(self._tmp_dir.name) / 'validated.sqlite3'}" + self.manager = DatabaseManager( + ConnectionFactory(), + DatabaseConfig(default="sqlite", connections={"sqlite": SQLiteConfig(driver="sqlite", url=url)}), + ) + original_manager = Model.db_manager + Model.db_manager = self.manager + self.addCleanup(setattr, Model, "db_manager", original_manager) + db_patch = patch.object(DB, "_instance", self.manager) + db_patch.start() + self.addCleanup(db_patch.stop) + + self.connection = self.manager.connection() + await self.connection.statement("CREATE TABLE accounts (id INTEGER PRIMARY KEY, email VARCHAR(255))") + await self.connection.insert("INSERT INTO accounts (id, email) VALUES (1, 'jane@example.com')") + + async def asyncTearDown(self): + await self.manager.clear() + await super().asyncTearDown() + + async def test_unique_ignore_receives_the_typed_path_parameter(self): + select_one = type(self.connection).select_one + with patch.object(type(self.connection), "select_one", autospec=True, side_effect=select_one) as spy: + own = await self.put("/accounts/1", json={"email": "jane@example.com"}) + other = await self.put("/accounts/2", json={"email": "jane@example.com"}) + + own.assert_status(200) + other.assert_status(422) + self.assertEqual(other.json()["errors"], {"email": ["The email has already been taken."]}) + self.assertEqual( + [call.args[2] for call in spy.call_args_list], [["jane@example.com", 1], ["jane@example.com", 2]] + ) + self.assertEqual([type(call.args[2][1]) for call in spy.call_args_list], [int, int]) + + async def test_invalid_path_parameter_never_reaches_the_database(self): + select_one = type(self.connection).select_one + with patch.object(type(self.connection), "select_one", autospec=True, side_effect=select_one) as spy: + response = await self.put("/accounts/abc", json={"email": "jane@example.com"}) + + response.assert_status(422) + self.assertEqual(list(response.json()["errors"]), ["account_id"]) + spy.assert_not_called() From 55625cbcef3073681bc6782e8dd29e15d69177d9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 29 Oct 2026 02:28:48 -0700 Subject: [PATCH 3/8] Collect validation path params without get_flat_dependant 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 --- .github/workflows/test.yml | 7 +++ docs/adr/042-fluent-validation-rules.md | 6 +-- docs/adr/AGENTS.md | 2 +- .../validation/form_request.py | 32 +++++++++--- .../validation/test_fastapi_validation.py | 52 +++++++++++++++++-- 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6ab00eb..d91b6963 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,13 @@ jobs: flags: fastapi_startkit fail_ci_if_error: false + - name: Run validation and FastAPI tests against the latest FastAPI + working-directory: fastapi_startkit + run: | + uv pip install --upgrade fastapi + uv run --no-sync python -c "import fastapi; print('FastAPI', fastapi.__version__)" + uv run --no-sync pytest tests/validation tests/fastapi -v + - name: Stop MySQL if: always() run: docker compose -f docker-compose.yml down diff --git a/docs/adr/042-fluent-validation-rules.md b/docs/adr/042-fluent-validation-rules.md index 776c2bd0..33dc9ff5 100644 --- a/docs/adr/042-fluent-validation-rules.md +++ b/docs/adr/042-fluent-validation-rules.md @@ -69,9 +69,9 @@ Rule failures and pydantic's own errors are raised together as one `ValidationEr `Validated[Model]` is `Annotated[Model, Depends(dependency)]`, where the dependency takes `request: Request` and the body as `Model`. FastAPI parses the body (the OpenAPI schema is kept), the sync phase runs, then the dependency awaits `validate_async(context)`. A body parameter typed as the plain model still works but runs only the sync phase. -FastAPI solves sub-dependencies before it validates the endpoint's own path parameters, and `request.path_params` only holds strings. Passing those strings made `unique(..., ignore=lambda data: data["user_id"])` bind `'1'` against an integer column, which asyncpg rejects with a `DataError` (a 500; SQLite hides it through type affinity), and `PUT /users/abc` queried the database before the `user_id: int` error was reported. The dependency now reads the matched `APIRoute` from `request.scope["route"]` and converts the raw values with FastAPI's own `request_params_to_args` over every path parameter declared on the route (`get_flat_dependant(route.dependant).path_params`). The context is the converted values over the raw ones, so undeclared path parameters stay strings. If any declared path parameter is invalid, the dependency returns the model without running the async phase, and FastAPI then reports its normal path-parameter 422 when it validates the endpoint. Alternatives considered: coercing in user code (`int(data["user_id"])`) moves the bug to every caller and still queries with invalid input; a custom `APIRoute` class only covers routers that use it, so async rules would silently not run elsewhere. +FastAPI solves sub-dependencies before it validates the endpoint's own path parameters, and `request.path_params` only holds strings. Passing those strings made `unique(..., ignore=lambda data: data["user_id"])` bind `'1'` against an integer column, which asyncpg rejects with a `DataError` (a 500; SQLite hides it through type affinity), and `PUT /users/abc` queried the database before the `user_id: int` error was reported. The dependency now reads the matched `APIRoute` from `request.scope["route"]` and converts the raw values with FastAPI's own `request_params_to_args` over every path parameter declared on the route. Those are collected by walking the public `Dependant` dataclass (`dependant.path_params` plus each `dependant.dependencies` entry, recursively), so parameters from a router prefix, a route-level `dependencies=[Depends(...)]` or a `Path(gt=0)` constraint are all typed. `get_flat_dependant` was used first, but it was removed in FastAPI 0.140.x while the package allows any `fastapi>=0.124.4`, which turned every `Validated` request into an `ImportError` 500 on a fresh install. `request_params_to_args` is also internal, but its signature is unchanged from 0.124.4 to 0.162.0; CI runs `tests/validation` and `tests/fastapi` against the latest FastAPI after the locked run, so a future break fails CI instead of production. The collected fields are cached per route, keyed by `id(route)` with a `weakref.finalize` eviction because `APIRoute` defines `__eq__` and is unhashable. The context is the converted values over the raw ones, so undeclared path parameters stay strings. If any declared path parameter is invalid, the dependency returns the model without running the async phase, and FastAPI then reports its normal path-parameter 422 when it validates the endpoint. Alternatives considered: coercing in user code (`int(data["user_id"])`) moves the bug to every caller and still queries with invalid input; a custom `APIRoute` class only covers routers that use it, so async rules would silently not run elsewhere. -The dependency's body parameter is named after the model in snake case (`UpdateUser` → `update_user`). With a single body parameter the name is invisible because FastAPI does not embed the body. When the endpoint has other body parameters, FastAPI embeds each body under its parameter name, and for `Validated` that is the dependency's parameter, not the endpoint's: FastAPI builds a dependency's signature without the name of the parameter that declares it, so the endpoint's name cannot be used. `async def note(data: Validated[UpdateUser], note: Annotated[str, Body()])` therefore expects `{"update_user": {...}, "note": "..."}`, and OpenAPI shows the same keys. Naming the key after the model (instead of a fixed `payload`) keeps it predictable and lets two different `Validated` models on one endpoint coexist. An endpoint that needs its own key can declare `data: Model` and call `await data.validate_async(context)`. +The dependency's body parameter is named after the model in snake case (`UpdateUser` → `update_user`). With a single body parameter the name is invisible because FastAPI does not embed the body. When the endpoint has other body parameters, FastAPI embeds each body under its parameter name, and for `Validated` that is the dependency's parameter, not the endpoint's: FastAPI builds a dependency's signature without the name of the parameter that declares it, so the endpoint's name cannot be used. `async def note(data: Validated[UpdateUser], note: Annotated[str, Body()])` therefore expects `{"update_user": {...}, "note": "..."}`, and OpenAPI shows the same keys. Naming the key after the model (instead of a fixed `payload`) keeps it predictable and lets two different `Validated` models on one endpoint coexist. An endpoint that needs its own key can declare `data: Model` and call `await data.validate_async(context)`. The dependency's own `Request` parameter is named `__validated_request`, so a model named `Request` (key `request`) does not collide with it. An endpoint body parameter that has the same name as the embed key (`update_user: Annotated[dict, Body()]` next to `Validated[UpdateUser]`) cannot be detected from the dependency, because FastAPI merges the two into one body field; the request fails with a combined 422. This is documented rather than guarded. The response comes from the existing `ValidationExceptionHandler` that `FastAPIProvider` registers for `RequestValidationError` (ADR 017, ADR 037). `fastapi_startkit.exceptions` already had an unused `ValidationException(errors: dict)`. It is reused instead of adding a second class with the same name: it now subclasses the dependency-free `HttpException` (status 422, `get_response()` is the summary), accepts a `MessageBag` or a `{field: [messages]}` mapping, and keeps `errors` as that mapping. A string value counts as one message, so the old call shape `ValidationException({"email": "Taken."})` still gives `{"email": ["Taken."]}`. `fastapi_startkit.validation` re-exports it. `FastAPIProvider` registers the same handler for it, so the async phase gets the same JSON, Inertia and session-redirect negotiation. The JSON body becomes `MessageBag.to_dict()`: @@ -88,7 +88,7 @@ Docs page; `sometimes`, `array`, `date`/`before`/`after`, `alpha`/`alpha_num`, ` ## Validation - `tests/validation/` covers each rule's pass and fail cases, default, string and callable messages, `msg()`, `:attribute` humanization, empty-value skipping and bail, `same_as`/`required_if`/`confirmed` regardless of declaration order, type errors combined with rule errors, `TypeError` on a plain `BaseModel`, `MessageBag`, custom `Rule`/`AsyncRule` subclasses, and `unique`/`exists` against SQLite through the async ORM (table name and `Model`, with and without `ignore`). -- A FastAPI app with `FastAPIProvider` checks the 422 body for sync and async failures, the path-parameter `ignore` on an update route (the context holds the typed `int`), an invalid path parameter that returns the path error without running async rules, a multi-body endpoint with the model-name embed key, and a successful request. +- A FastAPI app with `FastAPIProvider` checks the 422 body for sync and async failures, the path-parameter `ignore` on an update route (the context holds the typed `int`), an invalid path parameter that returns the path error without running async rules, a multi-body endpoint with the model-name embed key, path parameters declared on a router prefix and in route-level dependencies, a model named `Request`, and a successful request. The same suite passes on the locked FastAPI 0.124.4 and on 0.162.0. - A real `unique(..., ignore=lambda data: data[...])` through `Validated` runs against SQLite with the bound query parameter asserted to be an `int`, and against Postgres (asyncpg) in `tests/masoniteorm/postgres/test_validated_unique.py`, where the old string context failed with `DataError`. An invalid path parameter never reaches the database. - Rules on a nested model (direct, `list[...]`, optional) raise `TypeError` at class definition; `in_` with unhashable values, string messages in `MessageBag`, lowercase boolean placeholders, numeric `max`, plain JSON schema for required fields, and non-mapping input are covered. - Existing `ValidationExceptionHandler` tests are updated for the added `message` key. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 741b20ba..b5d35664 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -38,4 +38,4 @@ Read this index first, then open the records relevant to the change. Before impl | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | -| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | +| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query; path params are collected by walking the public `Dependant` tree (no `get_flat_dependant`, removed in FastAPI 0.140), cached per route, and CI also runs the validation tests on the latest FastAPI. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | diff --git a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py index d794d7e0..bffd05fa 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py @@ -2,6 +2,7 @@ import inspect from collections.abc import Iterator, Mapping +from weakref import finalize from typing import TYPE_CHECKING, Annotated, Any, LiteralString, Self, cast, get_args from inflection import underscore @@ -20,8 +21,12 @@ from fastapi_startkit.validation.message_bag import MessageBag if TYPE_CHECKING: + from fastapi.dependencies.models import Dependant + from fastapi.routing import APIRoute from starlette.requests import Request +REQUEST_PARAMETER = "__validated_request" + def has_rules(model: type[BaseModel]) -> bool: return any(isinstance(metadata, Field) for info in model.model_fields.values() for metadata in info.metadata) @@ -144,14 +149,29 @@ async def validate_input(cls, data: Any, context: Mapping[str, Any] | None = Non return await instance.validate_async(context) +def declared_path_params(dependant: Dependant) -> list[Any]: + return [*dependant.path_params, *(param for sub in dependant.dependencies for param in declared_path_params(sub))] + + +route_path_params: dict[int, list[Any]] = {} + + +def path_params_of(route: APIRoute) -> list[Any]: + key = id(route) + if key not in route_path_params: + route_path_params[key] = declared_path_params(route.dependant) + finalize(route, route_path_params.pop, key, None) + return route_path_params[key] + + def typed_path_params(request: Request) -> dict[str, Any] | None: - from fastapi.dependencies.utils import get_flat_dependant, request_params_to_args + from fastapi.dependencies.utils import request_params_to_args from fastapi.routing import APIRoute route = request.scope.get("route") if not isinstance(route, APIRoute): return dict(request.path_params) - values, errors = request_params_to_args(get_flat_dependant(route.dependant).path_params, request.path_params) + values, errors = request_params_to_args(path_params_of(route), request.path_params) return None if errors else {**request.path_params, **values} @@ -160,9 +180,9 @@ def validated_dependency(model: type[FormRequest]) -> Any: key = underscore(model.__name__) - async def dependency(request: Request, **body: Any) -> Any: - payload = body[key] - context = typed_path_params(request) + async def dependency(**parameters: Any) -> Any: + payload = parameters[key] + context = typed_path_params(parameters[REQUEST_PARAMETER]) if context is None: return payload return await payload.validate_async(context) @@ -172,7 +192,7 @@ async def dependency(request: Request, **body: Any) -> Any: "__signature__", inspect.Signature( [ - inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), + inspect.Parameter(REQUEST_PARAMETER, inspect.Parameter.KEYWORD_ONLY, annotation=Request), inspect.Parameter(key, inspect.Parameter.KEYWORD_ONLY, annotation=model), ] ), diff --git a/fastapi_startkit/tests/validation/test_fastapi_validation.py b/fastapi_startkit/tests/validation/test_fastapi_validation.py index 582a0489..ca98c5ac 100644 --- a/fastapi_startkit/tests/validation/test_fastapi_validation.py +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -3,8 +3,8 @@ from pathlib import Path from typing import Annotated, Any -from fastapi import Body -from starlette.requests import Request +from fastapi import APIRouter, Body, Depends +from starlette.requests import Request as StarletteRequest from fastapi_startkit.application import Application from fastapi_startkit.container.container import Container @@ -37,8 +37,38 @@ class UpdateUser(FormRequest): email: Annotated[str, Email().required().add(UniqueEmail())] +class Request(FormRequest): + email: Annotated[str, Email().required()] + + +TEAM_CONTEXT: list[dict[str, Any]] = [] + + +class TeamMember(FormRequest): + email: Annotated[str, Email().required()] + + async def validate_async(self, context: Mapping[str, Any] | None = None): + TEAM_CONTEXT.append(dict(context or {})) + return await super().validate_async(context) + + +async def tenant(tenant_id: int) -> int: + return tenant_id + + def make_application(base_path: Path) -> Application: app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + teams = APIRouter(prefix="/tenants/{tenant_id}/teams/{team_id}", dependencies=[Depends(tenant)]) + + @teams.post("/members") + async def add_member(team_id: int, data: Validated[TeamMember]): + return {"email": data.email} + + app.fastapi.include_router(teams) + + @app.fastapi.post("/requests") + async def request_model(data: Validated[Request]): + return {"email": data.email} @app.fastapi.post("/register") async def register(data: Validated[RegisterUser]): @@ -155,8 +185,24 @@ async def test_openapi_keeps_the_request_body_schema(self): self.assertEqual(body, {"$ref": "#/components/schemas/RegisterUser"}) + async def test_router_prefix_and_dependency_path_params_are_typed_context(self): + TEAM_CONTEXT.clear() + + valid = await self.post("/tenants/7/teams/3/members", json={"email": "a@example.com"}) + invalid = await self.post("/tenants/x/teams/3/members", json={"email": "a@example.com"}) + + valid.assert_status(200) + invalid.assert_status(422) + self.assertEqual(TEAM_CONTEXT, [{"tenant_id": 7, "team_id": 3}]) + + async def test_form_request_named_request_does_not_collide_with_the_http_request(self): + response = await self.post("/requests", json={"email": "a@example.com"}) + + response.assert_status(200) + self.assertEqual(response.json(), {"email": "a@example.com"}) + def test_path_params_without_a_matched_route_stay_raw(): - request = Request({"type": "http", "path_params": {"user_id": "1"}}) + request = StarletteRequest({"type": "http", "path_params": {"user_id": "1"}}) assert typed_path_params(request) == {"user_id": "1"} From ffe6458295c832749ae2fc6d5f238f55cde06bf7 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 29 Oct 2026 02:37:20 -0700 Subject: [PATCH 4/8] Drop deprecated Application env argument from validation tests Co-Authored-By: Claude Opus 5.5 --- .../tests/masoniteorm/postgres/test_validated_unique.py | 2 +- fastapi_startkit/tests/validation/test_fastapi_validation.py | 2 +- fastapi_startkit/tests/validation/test_validated_database.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py b/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py index 090d4101..c4cce3f2 100644 --- a/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py +++ b/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py @@ -19,7 +19,7 @@ class UpdateMember(FormRequest): def make_application(base_path: Path) -> Application: - app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + app = Application(base_path=base_path, providers=[FastAPIProvider]) @app.fastapi.put("/members/{member_id}") async def update(member_id: int, data: Validated[UpdateMember]): diff --git a/fastapi_startkit/tests/validation/test_fastapi_validation.py b/fastapi_startkit/tests/validation/test_fastapi_validation.py index ca98c5ac..1e137298 100644 --- a/fastapi_startkit/tests/validation/test_fastapi_validation.py +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -57,7 +57,7 @@ async def tenant(tenant_id: int) -> int: def make_application(base_path: Path) -> Application: - app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + app = Application(base_path=base_path, providers=[FastAPIProvider]) teams = APIRouter(prefix="/tenants/{tenant_id}/teams/{team_id}", dependencies=[Depends(tenant)]) @teams.post("/members") diff --git a/fastapi_startkit/tests/validation/test_validated_database.py b/fastapi_startkit/tests/validation/test_validated_database.py index d1e92c9f..5b4befe7 100644 --- a/fastapi_startkit/tests/validation/test_validated_database.py +++ b/fastapi_startkit/tests/validation/test_validated_database.py @@ -20,7 +20,7 @@ class UpdateAccount(FormRequest): def make_application(base_path: Path) -> Application: - app = Application(base_path=base_path, env="testing", providers=[FastAPIProvider]) + app = Application(base_path=base_path, providers=[FastAPIProvider]) @app.fastapi.put("/accounts/{account_id}") async def update(account_id: int, data: Validated[UpdateAccount]): From b6ff4145b02c063a78d609aba351b8b68d7b9121 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 30 Oct 2026 14:59:48 -0700 Subject: [PATCH 5/8] Remove the latest-FastAPI CI step 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 --- .github/workflows/test.yml | 7 ------- docs/adr/042-fluent-validation-rules.md | 2 +- docs/adr/AGENTS.md | 2 +- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d91b6963..b6ab00eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,13 +55,6 @@ jobs: flags: fastapi_startkit fail_ci_if_error: false - - name: Run validation and FastAPI tests against the latest FastAPI - working-directory: fastapi_startkit - run: | - uv pip install --upgrade fastapi - uv run --no-sync python -c "import fastapi; print('FastAPI', fastapi.__version__)" - uv run --no-sync pytest tests/validation tests/fastapi -v - - name: Stop MySQL if: always() run: docker compose -f docker-compose.yml down diff --git a/docs/adr/042-fluent-validation-rules.md b/docs/adr/042-fluent-validation-rules.md index 33dc9ff5..a654b258 100644 --- a/docs/adr/042-fluent-validation-rules.md +++ b/docs/adr/042-fluent-validation-rules.md @@ -69,7 +69,7 @@ Rule failures and pydantic's own errors are raised together as one `ValidationEr `Validated[Model]` is `Annotated[Model, Depends(dependency)]`, where the dependency takes `request: Request` and the body as `Model`. FastAPI parses the body (the OpenAPI schema is kept), the sync phase runs, then the dependency awaits `validate_async(context)`. A body parameter typed as the plain model still works but runs only the sync phase. -FastAPI solves sub-dependencies before it validates the endpoint's own path parameters, and `request.path_params` only holds strings. Passing those strings made `unique(..., ignore=lambda data: data["user_id"])` bind `'1'` against an integer column, which asyncpg rejects with a `DataError` (a 500; SQLite hides it through type affinity), and `PUT /users/abc` queried the database before the `user_id: int` error was reported. The dependency now reads the matched `APIRoute` from `request.scope["route"]` and converts the raw values with FastAPI's own `request_params_to_args` over every path parameter declared on the route. Those are collected by walking the public `Dependant` dataclass (`dependant.path_params` plus each `dependant.dependencies` entry, recursively), so parameters from a router prefix, a route-level `dependencies=[Depends(...)]` or a `Path(gt=0)` constraint are all typed. `get_flat_dependant` was used first, but it was removed in FastAPI 0.140.x while the package allows any `fastapi>=0.124.4`, which turned every `Validated` request into an `ImportError` 500 on a fresh install. `request_params_to_args` is also internal, but its signature is unchanged from 0.124.4 to 0.162.0; CI runs `tests/validation` and `tests/fastapi` against the latest FastAPI after the locked run, so a future break fails CI instead of production. The collected fields are cached per route, keyed by `id(route)` with a `weakref.finalize` eviction because `APIRoute` defines `__eq__` and is unhashable. The context is the converted values over the raw ones, so undeclared path parameters stay strings. If any declared path parameter is invalid, the dependency returns the model without running the async phase, and FastAPI then reports its normal path-parameter 422 when it validates the endpoint. Alternatives considered: coercing in user code (`int(data["user_id"])`) moves the bug to every caller and still queries with invalid input; a custom `APIRoute` class only covers routers that use it, so async rules would silently not run elsewhere. +FastAPI solves sub-dependencies before it validates the endpoint's own path parameters, and `request.path_params` only holds strings. Passing those strings made `unique(..., ignore=lambda data: data["user_id"])` bind `'1'` against an integer column, which asyncpg rejects with a `DataError` (a 500; SQLite hides it through type affinity), and `PUT /users/abc` queried the database before the `user_id: int` error was reported. The dependency now reads the matched `APIRoute` from `request.scope["route"]` and converts the raw values with FastAPI's own `request_params_to_args` over every path parameter declared on the route. Those are collected by walking the public `Dependant` dataclass (`dependant.path_params` plus each `dependant.dependencies` entry, recursively), so parameters from a router prefix, a route-level `dependencies=[Depends(...)]` or a `Path(gt=0)` constraint are all typed. `get_flat_dependant` was used first, but it was removed in FastAPI 0.140.x while the package allows any `fastapi>=0.124.4`, which turned every `Validated` request into an `ImportError` 500 on a fresh install. `request_params_to_args` is also internal, but its signature is unchanged from 0.124.4 to 0.162.0, so a future break shows up in `tests/validation` when the locked FastAPI version is upgraded. The collected fields are cached per route, keyed by `id(route)` with a `weakref.finalize` eviction because `APIRoute` defines `__eq__` and is unhashable. The context is the converted values over the raw ones, so undeclared path parameters stay strings. If any declared path parameter is invalid, the dependency returns the model without running the async phase, and FastAPI then reports its normal path-parameter 422 when it validates the endpoint. Alternatives considered: coercing in user code (`int(data["user_id"])`) moves the bug to every caller and still queries with invalid input; a custom `APIRoute` class only covers routers that use it, so async rules would silently not run elsewhere. The dependency's body parameter is named after the model in snake case (`UpdateUser` → `update_user`). With a single body parameter the name is invisible because FastAPI does not embed the body. When the endpoint has other body parameters, FastAPI embeds each body under its parameter name, and for `Validated` that is the dependency's parameter, not the endpoint's: FastAPI builds a dependency's signature without the name of the parameter that declares it, so the endpoint's name cannot be used. `async def note(data: Validated[UpdateUser], note: Annotated[str, Body()])` therefore expects `{"update_user": {...}, "note": "..."}`, and OpenAPI shows the same keys. Naming the key after the model (instead of a fixed `payload`) keeps it predictable and lets two different `Validated` models on one endpoint coexist. An endpoint that needs its own key can declare `data: Model` and call `await data.validate_async(context)`. The dependency's own `Request` parameter is named `__validated_request`, so a model named `Request` (key `request`) does not collide with it. An endpoint body parameter that has the same name as the embed key (`update_user: Annotated[dict, Body()]` next to `Validated[UpdateUser]`) cannot be detected from the dependency, because FastAPI merges the two into one body field; the request fails with a combined 422. This is documented rather than guarded. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index b5d35664..6a2d2556 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -38,4 +38,4 @@ Read this index first, then open the records relevant to the change. Before impl | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | -| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query; path params are collected by walking the public `Dependant` tree (no `get_flat_dependant`, removed in FastAPI 0.140), cached per route, and CI also runs the validation tests on the latest FastAPI. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | +| [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query; path params are collected by walking the public `Dependant` tree (no `get_flat_dependant`, removed in FastAPI 0.140) and cached per route. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | From c20911fef128c6d815c2c2c1e363688fd8b7f854 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 30 Oct 2026 15:04:23 -0700 Subject: [PATCH 6/8] Add ADR 048 for async-first validation Co-Authored-By: Claude Opus 5.5 --- docs/adr/048-async-first-validation.md | 62 ++++++++++++++++++++++++++ docs/adr/AGENTS.md | 1 + 2 files changed, 63 insertions(+) create mode 100644 docs/adr/048-async-first-validation.md diff --git a/docs/adr/048-async-first-validation.md b/docs/adr/048-async-first-validation.md new file mode 100644 index 00000000..e31bbcd0 --- /dev/null +++ b/docs/adr/048-async-first-validation.md @@ -0,0 +1,62 @@ +# 048. Async-first validation: one awaited rule pass, async hooks and closures + +Date: 2026-10-30 + +Supersedes the "Pydantic schema and short-circuit", "Async phase for database rules" and the dependency part of "FastAPI integration" in [ADR 042](042-fluent-validation-rules.md). The rule builder, messages, `MessageBag`, `ValidationException` and the 422 body from ADR 042 are unchanged. + +## Problem + +ADR 042 split validation in two. Synchronous rules ran inside pydantic's `model_validator`, and `AsyncRule`s ran in a second phase, `await model.validate_async(context)`, only after the whole synchronous phase passed. The framework is async-first (async ORM, async providers), and the split leaks into every use: + +- The core entry point is synchronous. Anything that needs I/O (a database lookup, the current user, a remote check) can only be a separate `AsyncRule` in a separate phase, so a chain like `Field().required().unique("users").min(3)` does not run in the order it is written: `unique` always runs last, after every field's sync rules. +- There are no FormRequest hooks. Laravel's `authorize`, `prepareForValidation` and `after` callbacks have nowhere to run: the synchronous phase cannot await, and the async phase runs after the model is already built, too late to authorize or to normalise input. +- A rule cannot be a plain function, sync or async. Every check needs a `Rule` or `AsyncRule` subclass. +- Errors from the two phases are reported separately. A request with a bad `username` and a taken `email` reports only `username`, then `email` on the next attempt. + +## Alternatives + +1. **Sync core with an async adapter (ADR 042).** Pydantic stays the engine for sync rules and an async phase is bolted on. It keeps `model_validate` validating rules, but hooks cannot be async, rule order is not the written order, and every new async feature needs another phase. +2. **Dual sync and async API.** `model_validate` runs every rule, `await validate_input` runs every rule and hook, and async rules raise in the sync path. Every rule, hook and closure needs two code paths and two test matrices, and the sync path either rejects async rules at runtime or silently skips them. Running async rules from the sync path would need `asyncio.run` or a nested loop, which blocks or fails inside a running loop. +3. **Async-only rule engine, pydantic for types only.** Pydantic parses and coerces the input. One awaited pass runs every rule of every field in written order, awaiting a rule's result only when it is awaitable, so sync rules need no boilerplate. Hooks and closures may be sync or async. + +## Decision + +Option 3. Validation has one entry point, `await Model.validate_input(data, context=None, request=None)`, used by `Validated[Model]`, commands, jobs and tests. Pydantic construction (`Model(...)`, `model_validate`, a plain `data: Model` FastAPI body) only parses types and no longer runs rules. + +### The awaited pass + +`validate_input` creates a `Validator` for the call (`data`, `context`, `request`, `errors: MessageBag`). Nothing is stored on the class, so concurrent requests validate independently. + +1. `await cls.authorize(validator)`. A false result raises `AuthorizationException` (403, "This action is unauthorized.") before the input is read. +2. Input that is not a mapping is parsed by pydantic and its errors are raised as `ValidationException` (the `model_type` error, as before). +3. `await cls.prepare_for_validation(validator)` may change `validator.data` (`validator.merge({...})`), for example to trim or lower-case input. +4. Pydantic parses the prepared data. Missing keys of `required`/`required_if` fields are filled with `None` first, so the rule's message is reported instead of pydantic's `Field required`. Pydantic's type errors are added to the bag. The coerced values of the fields that did parse are recorded through the validation context, so the rest of the request is still checked. +5. `validator.data` becomes the context, then the field defaults, the raw input and the coerced values, in that order. Every field with rules is then checked in declaration order, so cross-field rules see fields declared later. Within a field, rules run in the order they are written. A rule's result is awaited only if it is awaitable. An empty value skips non-implicit rules, and the first failing rule stops the field (Laravel `bail`). A field with a type error runs no rules. With `stop_on_first_failure = True` on the model, no further field is checked after the first error, which keeps invalid input away from later database rules. +6. `await cls.after(validator)` runs last and can add errors (`validator.errors.add(field, message)`) using the validated data. +7. If the bag has errors, `ValidationException(bag)` is raised. Otherwise the parsed model is returned. + +Hooks are classmethods with defaults that do nothing. Each may be a plain or an `async` method; the engine awaits the result only when it is awaitable. Fields are checked one after another, not with `asyncio.gather`, because database rules on one request share the task's connection and the error order must follow the declaration order. + +### Rules and closures + +`BaseRule.passes` returns `bool | Awaitable[bool]`. `Rule` (sync) and `AsyncRule` (async) stay as typed bases for custom rules, but the engine treats both the same way, so `unique` and `exists` sit in the chain where they are written. `Field().using(callback, msg=None)` adds a closure rule: `callback(attribute, value, data)` returns a `bool` or an awaitable `bool`. `unique(..., ignore=...)` also accepts an async callable. `unique` and `exists` still use the async `QueryBuilder` (`where(...).exists()`), so no request blocks the event loop and no sync database access exists in the request path. + +### Pydantic schema + +`Field.__get_pydantic_core_schema__` is unchanged: `required`/`required_if`/`nullable` accept `None` at the type level and the JSON schema of a `required` field stays non-nullable. The `FormRequest` wrap `model_validator` only marks the scope (so `Field` on a plain `BaseModel` still raises `TypeError`) and records coerced values. It no longer fills missing keys or runs rules. Rules on nested models still raise `TypeError` at class definition. + +### FastAPI integration + +`Validated[Model]` is still `Annotated[Model, Depends(dependency)]`, and the dependency still reads the route's typed path parameters as context (ADR 042). FastAPI no longer parses the body into the model. The dependency's body parameter is `Annotated[Any, RawInput(Model), Body()]`. `RawInput` gives it a pass-through core schema whose `json_schema_input_schema` is the model's schema, so OpenAPI still shows `$ref: Model` and the embed key under other body parameters is unchanged. The dependency then returns `await Model.validate_input(body, context, request)`. Type errors and rule errors are now reported together in one 422 with the ADR 042 body shape. If a declared path parameter is invalid, the dependency returns `None` without validating, and FastAPI reports its own path 422 before the endpoint runs. + +A plain `data: Model` body parameter only parses types. Validation that should run rules goes through `Validated[Model]` or `await Model.validate_input(...)`. This replaces ADR 042's "a plain body runs only the sync phase", which half-validated without saying so. + +`validate_async` is removed. The PR has not been released, so there are no callers to keep working. + +## Validation + +- `tests/validation/test_form_request.py`: sync rules, custom `Rule`/`AsyncRule`, sync and async closures, written order of mixed sync and async rules, type errors combined with rule errors, cross-field rules regardless of declaration order, non-mapping input, `stop_on_first_failure`, sync and async `authorize` (403), `prepare_for_validation`, `after` adding errors, `model_validate` parsing types only, and two concurrent `validate_input` calls with different inputs and contexts that keep their own errors. +- `tests/validation/test_database_rules.py` and `test_validated_database.py`: `unique`/`exists` against SQLite through the async ORM in the same pass as sync rules, an async `ignore` callable, and the typed path parameter bound as `int`. `tests/masoniteorm/postgres/test_validated_unique.py` keeps the asyncpg case. +- `tests/validation/test_fastapi_validation.py`: the 422 body for sync, async and combined failures, a 403 from an async `authorize` that reads the request, OpenAPI `$ref` and embed key, path-parameter context, an invalid path parameter that never reaches rules, and a plain body that only parses types. +- A grep shows no `asyncio.run`, `run_until_complete` or sync database call in `fastapi_startkit/validation`. +- `uv run pytest --ignore=tests/masoniteorm/postgres --cov` passes with total coverage at least `fail_under`, and `ruff check`, `ruff format --check` and `basedpyright` are clean for the changed files. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 6a2d2556..d87e9f4c 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -39,3 +39,4 @@ Read this index first, then open the records relevant to the change. Before impl | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | | [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query; path params are collected by walking the public `Dependant` tree (no `get_flat_dependant`, removed in FastAPI 0.140) and cached per route. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | +| [048](048-async-first-validation.md) | 2026-10-30 | Async-first validation: one awaited rule pass, async hooks and closures | Supersedes ADR 042's two-phase design (sync rules in pydantic, `AsyncRule`s in `validate_async`). `await Model.validate_input(data, context=None, request=None)` is the only validation entry point: a per-call `Validator` runs `authorize` (false → 403 `AuthorizationException`), `prepare_for_validation`, a pydantic type-only parse (type errors added to the bag), then every field's rules in declaration and written order, awaiting a rule's result only when it is awaitable (sync rules unchanged, `unique`/`exists` in place via the async `QueryBuilder`), then `after`, and raises one `ValidationException`. Hooks may be sync or async classmethods; `Field().using(callback)` adds sync/async closure rules; `unique(ignore=...)` accepts async callables; `stop_on_first_failure` stops after the first error. `model_validate`/plain body params only parse types. `Validated[Model]` takes the raw body (`RawInput` keeps the OpenAPI `$ref` via `json_schema_input_schema`) and awaits `validate_input` with the typed path params, so type and rule errors share one 422 with the unchanged ADR 042 shape. `validate_async` is removed. | From face2a4d7255aa1e3f2def1df3bf6ed609bf6cff Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 30 Oct 2026 15:09:09 -0700 Subject: [PATCH 7/8] Make validation async-first with one awaited rule pass 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 --- .../fastapi_startkit/validation/__init__.py | 2 + .../src/fastapi_startkit/validation/field.py | 22 +- .../validation/form_request.py | 145 ++++++---- .../src/fastapi_startkit/validation/rules.py | 24 +- .../fastapi_startkit/validation/validator.py | 25 ++ .../tests/validation/test_database_rules.py | 71 ++++- .../validation/test_fastapi_validation.py | 56 +++- .../tests/validation/test_form_request.py | 254 +++++++++++++----- .../tests/validation/test_rules.py | 3 +- 9 files changed, 443 insertions(+), 159 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/validation/validator.py diff --git a/fastapi_startkit/src/fastapi_startkit/validation/__init__.py b/fastapi_startkit/src/fastapi_startkit/validation/__init__.py index 30d933f2..d7c14aac 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/__init__.py @@ -3,6 +3,7 @@ from fastapi_startkit.validation.form_request import FormRequest, Validated from fastapi_startkit.validation.message_bag import MessageBag from fastapi_startkit.validation.rules import AsyncRule, BaseRule, Message, Rule +from fastapi_startkit.validation.validator import Validator __all__ = [ "AsyncRule", @@ -14,5 +15,6 @@ "MessageBag", "Rule", "Validated", + "Validator", "ValidationException", ] diff --git a/fastapi_startkit/src/fastapi_startkit/validation/field.py b/fastapi_startkit/src/fastapi_startkit/validation/field.py index 2739707e..56b8fc6d 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/field.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/field.py @@ -9,10 +9,10 @@ from pydantic_core import CoreSchema, core_schema from fastapi_startkit.validation.rules import ( - AsyncRule, BaseRule, Between, Boolean, + Closure, Confirmed, EmailRule, Exists, @@ -25,16 +25,17 @@ NotIn, Nullable, Numeric, + Passes, Regex, Required, RequiredIf, - Rule, SameAs, Size, String, Unique, Url, is_empty, + resolve, ) if TYPE_CHECKING: @@ -128,25 +129,20 @@ def unique( def exists(self, table: str | type[Model], column: str | None = None, msg: Message | None = None) -> Self: return self.add(Exists(table, column, msg)) + def using(self, callback: Passes, msg: Message | None = None) -> Self: + return self.add(Closure(callback, msg)) + def accepts_none(self) -> bool: return any(isinstance(rule, (Required, RequiredIf, Nullable)) for rule in self.rules) def defaults_to_none(self) -> bool: return any(isinstance(rule, (Required, RequiredIf)) for rule in self.rules) - def check(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str | None: - for rule in self.rules: - if not isinstance(rule, Rule) or self.skips(rule, value): - continue - if not rule.passes(attribute, value, data): - return rule.format_message(attribute, value, data) - return None - - async def check_async(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str | None: + async def check(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str | None: for rule in self.rules: - if not isinstance(rule, AsyncRule) or self.skips(rule, value): + if self.skips(rule, value): continue - if not await rule.passes(attribute, value, data): + if not await resolve(rule.passes(attribute, value, data)): return rule.format_message(attribute, value, data) return None diff --git a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py index bffd05fa..201905af 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py @@ -1,24 +1,27 @@ from __future__ import annotations import inspect -from collections.abc import Iterator, Mapping +from collections.abc import Awaitable, Iterator, Mapping +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Self, get_args from weakref import finalize -from typing import TYPE_CHECKING, Annotated, Any, LiteralString, Self, cast, get_args from inflection import underscore from pydantic import ( BaseModel, + GetCoreSchemaHandler, ModelWrapValidatorHandler, ValidationError, ValidationInfo, field_validator, model_validator, ) -from pydantic_core import InitErrorDetails, PydanticCustomError +from pydantic_core import CoreSchema, core_schema -from fastapi_startkit.exceptions import ValidationException +from fastapi_startkit.exceptions import AuthorizationException, ValidationException from fastapi_startkit.validation.field import Field, coerced_values from fastapi_startkit.validation.message_bag import MessageBag +from fastapi_startkit.validation.rules import resolve +from fastapi_startkit.validation.validator import Validator if TYPE_CHECKING: from fastapi.dependencies.models import Dependant @@ -26,6 +29,7 @@ from starlette.requests import Request REQUEST_PARAMETER = "__validated_request" +RECORDED = "fastapi_startkit.validation.recorded" def has_rules(model: type[BaseModel]) -> bool: @@ -57,6 +61,20 @@ def rule_fields(cls) -> Iterator[tuple[str, Field]]: if isinstance(metadata, Field): yield name, metadata + stop_on_first_failure: ClassVar[bool] = False + + @classmethod + def authorize(cls, validator: Validator) -> bool | Awaitable[bool]: + return True + + @classmethod + def prepare_for_validation(cls, validator: Validator) -> None | Awaitable[None]: + return None + + @classmethod + def after(cls, validator: Validator) -> None | Awaitable[None]: + return None + @field_validator("*", mode="after") @classmethod def record_value(cls, value: Any, info: ValidationInfo) -> Any: @@ -67,24 +85,57 @@ def record_value(cls, value: Any, info: ValidationInfo) -> Any: @model_validator(mode="wrap") @classmethod - def run_rules(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> Self: + def record_values(cls, data: Any, handler: ModelWrapValidatorHandler[Self], info: ValidationInfo) -> Self: values: dict[str, Any] = {} token = coerced_values.set(values) try: - instance = handler(cls.fill_missing(data)) - except ValidationError as error: - raise cls.combine(data, values, error.errors(include_url=False)) from None + return handler(data) finally: coerced_values.reset(token) - failures = cls.rule_failures(dict(instance), set()) - if failures: - raise ValidationError.from_exception_data(cls.__name__, failures) + if isinstance(info.context, dict): + info.context[RECORDED] = values + + @classmethod + async def validate_input( + cls, data: Any, context: Mapping[str, Any] | None = None, request: Request | None = None + ) -> Self: + validator = Validator({}, context or {}, request) + if not await resolve(cls.authorize(validator)): + raise AuthorizationException("This action is unauthorized.") + validator.data = dict(cls.as_mapping(data)) + await resolve(cls.prepare_for_validation(validator)) + instance, failed = cls.parse(validator) + await cls.apply_rules(validator, failed) + await resolve(cls.after(validator)) + if validator.fails() or instance is None: + raise ValidationException(validator.errors) return instance @classmethod - def fill_missing(cls, data: Any) -> Any: - if not isinstance(data, Mapping): + def as_mapping(cls, data: Any) -> Mapping[str, Any]: + if isinstance(data, Mapping): return data + try: + return dict(cls.model_validate(data)) + except ValidationError as error: + raise ValidationException(MessageBag.from_errors(error.errors(include_url=False))) from None + + @classmethod + def parse(cls, validator: Validator) -> tuple[Self | None, set[str]]: + data = validator.data + recorded: dict[str, Any] = {} + try: + instance = cls.model_validate(cls.fill_missing(data), context=recorded) + except ValidationError as error: + errors = error.errors(include_url=False) + validator.errors.merge(MessageBag.from_errors(errors)) + validator.data = {**validator.context, **cls.partial_data(data, recorded.get(RECORDED, {}))} + return None, {str(error["loc"][0]) for error in errors if error["loc"]} + validator.data = {**validator.context, **cls.partial_data(data, dict(instance))} + return instance, set() + + @classmethod + def fill_missing(cls, data: Mapping[str, Any]) -> Mapping[str, Any]: missing: dict[str, None] = {} for name, field in cls.rule_fields(): info = cls.model_fields[name] @@ -103,50 +154,26 @@ def partial_data(cls, data: Mapping[str, Any], values: dict[str, Any]) -> dict[s return {**defaults, **data, **values} @classmethod - def combine(cls, data: Any, values: dict[str, Any], errors: list[Any]) -> ValidationError: - line_errors: list[InitErrorDetails] = [ - { - "type": PydanticCustomError(error["type"], error["msg"]), - "loc": tuple(error["loc"]), - "input": error["input"], - } - for error in errors - ] - if isinstance(data, Mapping): - failed = {error["loc"][0] for error in errors if error["loc"]} - line_errors += cls.rule_failures(cls.partial_data(data, values), failed) - return ValidationError.from_exception_data(cls.__name__, line_errors) - - @classmethod - def rule_failures(cls, data: Mapping[str, Any], failed: set[Any]) -> list[InitErrorDetails]: - failures: list[InitErrorDetails] = [] + async def apply_rules(cls, validator: Validator, failed: set[str]) -> None: + data = validator.data for name, field in cls.rule_fields(): + if cls.stop_on_first_failure and validator.fails(): + return if name in failed: continue - message = field.check(name, data.get(name), data) + message = await field.check(name, data.get(name), data) if message is not None: - error = PydanticCustomError("rule", cast("LiteralString", message)) - failures.append({"type": error, "loc": (name,), "input": data.get(name)}) - return failures - - async def validate_async(self, context: Mapping[str, Any] | None = None) -> Self: - data = {**(context or {}), **dict(self)} - bag = MessageBag() - for name, field in self.rule_fields(): - message = await field.check_async(name, data[name], data) - if message is not None: - bag.add(name, message) - if bag.any(): - raise ValidationException(bag) - return self + validator.errors.add(name, message) - @classmethod - async def validate_input(cls, data: Any, context: Mapping[str, Any] | None = None) -> Self: - try: - instance = cls.model_validate(data) - except ValidationError as error: - raise ValidationException(MessageBag.from_errors(error.errors(include_url=False))) from None - return await instance.validate_async(context) + +class RawInput: + def __init__(self, model: type[FormRequest]) -> None: + self.model = model + + def __get_pydantic_core_schema__(self, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: + return core_schema.no_info_plain_validator_function( + lambda value: value, json_schema_input_schema=handler.generate_schema(self.model) + ) def declared_path_params(dependant: Dependant) -> list[Any]: @@ -176,16 +203,16 @@ def typed_path_params(request: Request) -> dict[str, Any] | None: def validated_dependency(model: type[FormRequest]) -> Any: - from fastapi import Request + from fastapi import Body, Request key = underscore(model.__name__) async def dependency(**parameters: Any) -> Any: - payload = parameters[key] - context = typed_path_params(parameters[REQUEST_PARAMETER]) + request = parameters[REQUEST_PARAMETER] + context = typed_path_params(request) if context is None: - return payload - return await payload.validate_async(context) + return None + return await model.validate_input(parameters[key], context, request) setattr( dependency, @@ -193,7 +220,9 @@ async def dependency(**parameters: Any) -> Any: inspect.Signature( [ inspect.Parameter(REQUEST_PARAMETER, inspect.Parameter.KEYWORD_ONLY, annotation=Request), - inspect.Parameter(key, inspect.Parameter.KEYWORD_ONLY, annotation=model), + inspect.Parameter( + key, inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Any, RawInput(model), Body()] + ), ] ), ) diff --git a/fastapi_startkit/src/fastapi_startkit/validation/rules.py b/fastapi_startkit/src/fastapi_startkit/validation/rules.py index edb6c7a7..d77bbc3d 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/rules.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/rules.py @@ -1,7 +1,8 @@ from __future__ import annotations +import inspect import re -from collections.abc import Callable, Collection, Mapping +from collections.abc import Awaitable, Callable, Collection, Mapping from decimal import Decimal, InvalidOperation from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlparse @@ -12,10 +13,17 @@ type Message = str | Callable[[str, Any, Mapping[str, Any]], str] type Ignore = Any | Callable[[Mapping[str, Any]], Any] +type Passes = Callable[[str, Any, Mapping[str, Any]], bool | Awaitable[bool]] PLACEHOLDER = re.compile(r":(\w+)") +async def resolve[T](value: T | Awaitable[T]) -> T: + if inspect.isawaitable(value): + return await value + return value + + def is_empty(value: Any) -> bool: if value is None: return True @@ -41,6 +49,9 @@ class BaseRule: def __init__(self, message: Message | None = None) -> None: self.custom_message: Message | None = message + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool | Awaitable[bool]: + return True + def default_message(self, value: Any) -> str: return self.message @@ -65,6 +76,15 @@ async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> b return True +class Closure(BaseRule): + def __init__(self, callback: Passes, message: Message | None = None) -> None: + super().__init__(message) + self.callback = callback + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool | Awaitable[bool]: + return self.callback(attribute, value, data) + + class Required(Rule): message = "The :attribute field is required." implicit = True @@ -313,7 +333,7 @@ def __init__( async def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: query = self.query(attribute, value) - ignore = self.ignore(data) if callable(self.ignore) else self.ignore + ignore = await resolve(self.ignore(data)) if callable(self.ignore) else self.ignore if ignore is not None: query = query.where(self.ignore_column, "!=", ignore) return not await query.exists() diff --git a/fastapi_startkit/src/fastapi_startkit/validation/validator.py b/fastapi_startkit/src/fastapi_startkit/validation/validator.py new file mode 100644 index 00000000..5145f691 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/validator.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Self + +from fastapi_startkit.validation.message_bag import MessageBag + +if TYPE_CHECKING: + from starlette.requests import Request + + +@dataclass +class Validator: + data: dict[str, Any] + context: Mapping[str, Any] = field(default_factory=dict) + request: Request | None = None + errors: MessageBag = field(default_factory=MessageBag) + + def merge(self, values: Mapping[str, Any]) -> Self: + self.data.update(values) + return self + + def fails(self) -> bool: + return self.errors.any() diff --git a/fastapi_startkit/tests/validation/test_database_rules.py b/fastapi_startkit/tests/validation/test_database_rules.py index 9e54f750..0bffc1a2 100644 --- a/fastapi_startkit/tests/validation/test_database_rules.py +++ b/fastapi_startkit/tests/validation/test_database_rules.py @@ -25,6 +25,21 @@ class UpdateAccount(FormRequest): email: Annotated[str, Field().required().unique("accounts", ignore=lambda data: data["account_id"])] +async def account_id(data): + return data["account_id"] + + +class UpdateAccountAsync(FormRequest): + email: Annotated[str, Field().required().unique("accounts", ignore=account_id)] + + +class StrictSignup(FormRequest): + stop_on_first_failure = True + + email: Annotated[str, Email().required()] + handle: Annotated[str, Field().required().unique("accounts", "username")] + + class UpdateByHandle(FormRequest): email: Annotated[str, Field().unique(Account, ignore="jane", ignore_column="username")] @@ -59,38 +74,68 @@ async def asyncTearDown(self): await self.manager.clear() self.tmp.cleanup() - async def assert_errors(self, request: FormRequest, errors: dict[str, list[str]], context=None): + async def assert_errors(self, model: type[FormRequest], data: dict, errors: dict[str, list[str]], context=None): with self.assertRaises(ValidationException) as caught: - await request.validate_async(context) + await model.validate_input(data, context) self.assertEqual(caught.exception.errors, errors) async def test_unique_passes_for_new_values(self): - request = Signup(email="john@example.com", handle="john") + request = await Signup.validate_input({"email": "john@example.com", "handle": "john"}) - self.assertIs(await request.validate_async(), request) + self.assertEqual(request.handle, "john") async def test_unique_fails_for_taken_values_by_table_and_model(self): await self.assert_errors( - Signup(email="jane@example.com", handle="jane"), + Signup, + {"email": "jane@example.com", "handle": "jane"}, {"email": ["The email has already been taken."], "handle": ["The handle has already been taken."]}, ) + async def test_database_rules_run_with_sync_rule_failures_in_one_pass(self): + await self.assert_errors( + Signup, + {"email": "not-an-email", "handle": "jane"}, + { + "email": ["The email field must be a valid email address."], + "handle": ["The handle has already been taken."], + }, + ) + async def test_unique_ignores_the_row_from_context(self): - request = UpdateAccount(email="jane@example.com") + data = {"email": "jane@example.com"} + + self.assertEqual((await UpdateAccount.validate_input(data, {"account_id": 1})).email, "jane@example.com") + await self.assert_errors( + UpdateAccount, data, {"email": ["The email has already been taken."]}, {"account_id": 2} + ) - self.assertIs(await request.validate_async({"account_id": 1}), request) - await self.assert_errors(request, {"email": ["The email has already been taken."]}, {"account_id": 2}) + async def test_unique_ignore_can_be_async(self): + data = {"email": "jane@example.com"} + + self.assertIsNotNone(await UpdateAccountAsync.validate_input(data, {"account_id": 1})) + await self.assert_errors( + UpdateAccountAsync, data, {"email": ["The email has already been taken."]}, {"account_id": 2} + ) async def test_unique_ignore_value_and_column(self): - self.assertIsNotNone(await UpdateByHandle(email="jane@example.com").validate_async()) + self.assertIsNotNone(await UpdateByHandle.validate_input({"email": "jane@example.com"})) async def test_unique_skips_empty_values(self): - self.assertIsNotNone(await UpdateByHandle(email="").validate_async()) + self.assertIsNotNone(await UpdateByHandle.validate_input({"email": ""})) async def test_exists(self): - self.assertIsNotNone(await Transfer(account="jane", owner="jane@example.com").validate_async()) - self.assertIsNotNone(await Transfer(account="jane").validate_async()) + self.assertIsNotNone(await Transfer.validate_input({"account": "jane", "owner": "jane@example.com"})) + self.assertIsNotNone(await Transfer.validate_input({"account": "jane"})) await self.assert_errors( - Transfer(account="john", owner="john@example.com"), + Transfer, + {"account": "john", "owner": "john@example.com"}, {"account": ["The selected account is invalid."], "owner": ["Unknown owner owner."]}, ) + + async def test_stop_on_first_failure_skips_later_database_rules(self): + with patch.object(DB, "table", side_effect=AssertionError("queried")): + await self.assert_errors( + StrictSignup, + {"email": "nope", "handle": "jane"}, + {"email": ["The email field must be a valid email address."]}, + ) diff --git a/fastapi_startkit/tests/validation/test_fastapi_validation.py b/fastapi_startkit/tests/validation/test_fastapi_validation.py index 1e137298..9d62d140 100644 --- a/fastapi_startkit/tests/validation/test_fastapi_validation.py +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -10,7 +10,7 @@ from fastapi_startkit.container.container import Container from fastapi_startkit.fastapi import FastAPIProvider from fastapi_startkit.fastapi.testing import HttpTestCase -from fastapi_startkit.validation import AsyncRule, Email, Field, FormRequest, Validated +from fastapi_startkit.validation import AsyncRule, Email, Field, FormRequest, Validated, Validator from fastapi_startkit.validation.form_request import typed_path_params TAKEN = {"jane@example.com": 1} @@ -47,9 +47,19 @@ class Request(FormRequest): class TeamMember(FormRequest): email: Annotated[str, Email().required()] - async def validate_async(self, context: Mapping[str, Any] | None = None): - TEAM_CONTEXT.append(dict(context or {})) - return await super().validate_async(context) + @classmethod + async def authorize(cls, validator: Validator) -> bool: + TEAM_CONTEXT.append(dict(validator.context)) + return True + + +class Publish(FormRequest): + title: Annotated[str, Field().required().min(3)] + priority: int = 0 + + @classmethod + async def authorize(cls, validator: Validator) -> bool: + return validator.request is not None and validator.request.headers.get("x-role") == "editor" async def tenant(tenant_id: int) -> int: @@ -82,6 +92,10 @@ async def update(user_id: int, data: Validated[UpdateUser]): async def note(user_id: int, data: Validated[UpdateUser], note: Annotated[str, Body()]): return {"id": user_id, "email": data.email, "note": note} + @app.fastapi.post("/publish") + async def publish(data: Validated[Publish]): + return {"title": data.title} + @app.fastapi.post("/plain") async def plain(data: RegisterUser): return {"username": data.username} @@ -171,13 +185,39 @@ async def test_validated_body_is_embedded_under_the_model_name_with_other_body_p self.assertEqual(taken.json()["errors"], {"email": ["The email has already been taken."]}) self.assertEqual(sorted(body["properties"]), ["note", "update_user"]) - async def test_plain_body_parameter_runs_only_sync_rules(self): - accepted = await self.post("/plain", json={**VALID, "email": "jane@example.com"}) - rejected = await self.post("/plain", json={**VALID, "username": "jo"}) + async def test_plain_body_parameter_only_parses_types(self): + accepted = await self.post("/plain", json={**VALID, "username": "jo"}) + rejected = await self.post("/plain", json={**VALID, "username": 5}) accepted.assert_status(200) rejected.assert_status(422) - self.assertEqual(rejected.json()["errors"], {"username": ["The username field must be at least 3 characters."]}) + self.assertEqual(rejected.json()["errors"], {"username": ["Input should be a valid string"]}) + + async def test_type_errors_and_rule_errors_share_one_response(self): + response = await self.post("/publish", json={"title": "ab", "priority": "high"}, headers={"x-role": "editor"}) + + response.assert_status(422) + self.assertEqual( + response.json()["errors"], + { + "priority": ["Input should be a valid integer, unable to parse string as an integer"], + "title": ["The title field must be at least 3 characters."], + }, + ) + + async def test_async_authorize_reads_the_request(self): + allowed = await self.post("/publish", json={"title": "News"}, headers={"x-role": "editor"}) + denied = await self.post("/publish", json={"title": "News"}, headers={"x-role": "viewer"}) + + allowed.assert_status(200) + self.assertEqual(allowed.json(), {"title": "News"}) + denied.assert_status(403) + + async def test_missing_body_is_reported(self): + response = await self.post("/register", headers={"accept": "application/json"}) + + response.assert_status(422) + self.assertEqual(list(response.json()["errors"]), [""]) async def test_openapi_keeps_the_request_body_schema(self): schema = (await self.get("/openapi.json")).json() diff --git a/fastapi_startkit/tests/validation/test_form_request.py b/fastapi_startkit/tests/validation/test_form_request.py index 248ff25e..185ffa30 100644 --- a/fastapi_startkit/tests/validation/test_form_request.py +++ b/fastapi_startkit/tests/validation/test_form_request.py @@ -1,11 +1,21 @@ from collections.abc import Mapping from typing import Annotated, Any +import asyncio + import pytest from pydantic import BaseModel, ValidationError -from fastapi_startkit.exceptions import HttpException -from fastapi_startkit.validation import AsyncRule, Email, Field, FormRequest, MessageBag, Rule, ValidationException +from fastapi_startkit.exceptions import AuthorizationException, HttpException +from fastapi_startkit.validation import ( + AsyncRule, + Email, + Field, + FormRequest, + Rule, + ValidationException, + Validator, +) class Captcha(Rule): @@ -57,22 +67,22 @@ class RegisterUser(FormRequest): } -def errors_of(data: dict[str, Any]) -> dict[str, list[str]]: - with pytest.raises(ValidationError) as caught: - RegisterUser.model_validate(data) - return MessageBag.from_errors(caught.value.errors()).messages +async def errors_of(data: Any, model: type[FormRequest] = RegisterUser, context=None) -> dict[str, list[str]]: + with pytest.raises(ValidationException) as caught: + await model.validate_input(data, context) + return caught.value.errors -def test_valid_data_builds_the_model(): - user = RegisterUser.model_validate({**VALID, "age": "20", "nickname": None}) +async def test_valid_data_builds_the_model(): + user = await RegisterUser.validate_input({**VALID, "age": "20", "nickname": None}) assert user.age == 20 assert user.nickname is None assert user.tax_id is None -def test_missing_required_fields_use_rule_messages(): - assert errors_of({}) == { +async def test_missing_required_fields_use_rule_messages(): + assert await errors_of({}) == { "password_confirmation": ["The password confirmation field is required."], "email": ["The email field is required."], "username": ["username is required but you provided None"], @@ -80,29 +90,31 @@ def test_missing_required_fields_use_rule_messages(): } -def test_required_failure_stops_the_field(): - assert errors_of({**VALID, "username": ""})["username"] == ["username is required but you provided ''"] +async def test_required_failure_stops_the_field(): + assert (await errors_of({**VALID, "username": ""}))["username"] == ["username is required but you provided ''"] -def test_first_failing_rule_stops_the_field(): - assert errors_of({**VALID, "username": "ab"})["username"] == ["The username field must be at least 3 characters."] +async def test_first_failing_rule_stops_the_field(): + assert (await errors_of({**VALID, "username": "ab"}))["username"] == [ + "The username field must be at least 3 characters." + ] -def test_missing_optional_field_skips_its_rules(): - assert RegisterUser.model_validate({**VALID, "nickname": ""}).nickname == "" +async def test_missing_optional_field_skips_its_rules(): + assert (await RegisterUser.validate_input({**VALID, "nickname": ""})).nickname == "" -def test_msg_replaces_last_rule_message(): - assert errors_of({**VALID, "age": 17}) == {"age": ["Adults only."]} +async def test_msg_replaces_last_rule_message(): + assert await errors_of({**VALID, "age": 17}) == {"age": ["Adults only."]} -def test_custom_rule(): - assert errors_of({**VALID, "captcha": "41"}) == {"captcha": ["The captcha is wrong."]} - assert RegisterUser.model_validate({**VALID, "captcha": "42"}).captcha == "42" +async def test_custom_rule(): + assert await errors_of({**VALID, "captcha": "41"}) == {"captcha": ["The captcha is wrong."]} + assert (await RegisterUser.validate_input({**VALID, "captcha": "42"})).captcha == "42" -def test_cross_field_rules_see_fields_declared_later(): - errors = errors_of({**VALID, "password_confirmation": "different", "is_business": True}) +async def test_cross_field_rules_see_fields_declared_later(): + errors = await errors_of({**VALID, "password_confirmation": "different", "is_business": True}) assert errors == { "password_confirmation": ["The password confirmation field must match password."], @@ -110,8 +122,8 @@ def test_cross_field_rules_see_fields_declared_later(): } -def test_type_errors_are_reported_with_rule_errors(): - errors = errors_of({**VALID, "age": "old", "email": "nope", "is_business": "true"}) +async def test_type_errors_are_reported_with_rule_errors(): + errors = await errors_of({**VALID, "age": "old", "email": "nope", "is_business": "true"}) assert errors == { "age": ["Input should be a valid integer, unable to parse string as an integer"], @@ -120,27 +132,21 @@ def test_type_errors_are_reported_with_rule_errors(): } -def test_plain_type_errors_are_reraised_unchanged_when_no_rule_fails(): - assert errors_of({**VALID, "is_business": "maybe"}) == { +async def test_plain_type_errors_are_reraised_unchanged_when_no_rule_fails(): + assert await errors_of({**VALID, "is_business": "maybe"}) == { "is_business": ["Input should be a valid boolean, unable to interpret input"] } -def test_non_mapping_input_keeps_pydantic_error(): - with pytest.raises(ValidationError) as caught: - RegisterUser.model_validate("not a dict") - - assert [error["type"] for error in caught.value.errors()] == ["model_type"] +async def test_non_mapping_input_keeps_pydantic_error(): + assert await errors_of("not a dict") == {"": ["Input should be a valid dictionary or instance of RegisterUser"]} -def test_field_without_nullable_rejects_none(): +async def test_field_without_nullable_rejects_none(): class Profile(FormRequest): bio: Annotated[str, Field().max(5)] = "hi" - with pytest.raises(ValidationError) as caught: - Profile.model_validate({"bio": None}) - - assert caught.value.errors()[0]["type"] == "string_type" + assert await errors_of({"bio": None}, Profile) == {"bio": ["Input should be a valid string"]} def test_field_rules_need_a_form_request(): @@ -177,29 +183,23 @@ class Order(FormRequest): assert Order.model_validate({"address": {"code": "ab"}, "name": "x"}).address.code == "ab" -def test_numeric_fields_compare_size_by_value(): +async def test_numeric_fields_compare_size_by_value(): class Payment(FormRequest): amount: Annotated[str, Field().required().max(10).numeric()] count: Annotated[str | None, Field().integer().between(1, 3)] = None - assert Payment.model_validate({"amount": "9.5", "count": "2"}).amount == "9.5" - with pytest.raises(ValidationError) as caught: - Payment.model_validate({"amount": "25", "count": "12"}) - - assert MessageBag.from_errors(caught.value.errors()).messages == { + assert (await Payment.validate_input({"amount": "9.5", "count": "2"})).amount == "9.5" + assert await errors_of({"amount": "25", "count": "12"}, Payment) == { "amount": ["The amount field must not be greater than 10."], "count": ["The count field must be between 1 and 3."], } -def test_in_rule_rejects_unhashable_values(): +async def test_in_rule_rejects_unhashable_values(): class Pick(FormRequest): choice: Annotated[Any, Field().required().in_({"a", "b"})] - with pytest.raises(ValidationError) as caught: - Pick.model_validate({"choice": ["a"]}) - - assert MessageBag.from_errors(caught.value.errors()).messages == {"choice": ["The selected choice is invalid."]} + assert await errors_of({"choice": ["a"]}, Pick) == {"choice": ["The selected choice is invalid."]} def test_json_schema_keeps_required_fields_non_nullable(): @@ -215,31 +215,157 @@ def test_msg_without_rule_raises(): Field().msg("nothing to replace") -async def test_validate_async_runs_async_rules(): - user = RegisterUser.model_validate({**VALID, "username": "admin"}) +async def test_async_rules_run_in_the_same_pass_as_sync_rules(): + assert await errors_of({**VALID, "username": "admin", "email": "nope"}) == { + "email": ["The email field must be a valid email address."], + "username": ["The username admin is reserved."], + } - with pytest.raises(ValidationException) as caught: - await user.validate_async() - assert caught.value.errors == {"username": ["The username admin is reserved."]} +async def test_validate_input_passes_context_to_rules(): + class Member(FormRequest): + role: Annotated[str, Field().required().using(lambda attribute, value, data: data["team_id"] == 3)] + assert (await Member.validate_input({"role": "x"}, {"team_id": 3})).role == "x" + assert await errors_of({"role": "x"}, Member, {"team_id": 4}) == {"role": ["The role field is invalid."]} -async def test_validate_async_returns_model_when_rules_pass(): - user = RegisterUser.model_validate(VALID) - assert await user.validate_async({"user_id": 1}) is user +async def test_closure_rules_can_be_sync_or_async(): + async def not_taken(attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + await asyncio.sleep(0) + return value != "taken" + + class Profile(FormRequest): + handle: Annotated[str, Field().required().using(not_taken, "The :attribute is taken.")] + code: Annotated[str, Field().required().using(lambda attribute, value, data: value.isdigit())] + + assert (await Profile.validate_input({"handle": "free", "code": "12"})).code == "12" + assert await errors_of({"handle": "taken", "code": "ab"}, Profile) == { + "handle": ["The handle is taken."], + "code": ["The code field is invalid."], + } + + +async def test_rules_run_in_written_order_across_sync_and_async(): + calls: list[str] = [] + + async def first(attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + calls.append("async") + return False + + class Ordered(FormRequest): + name: Annotated[str, Field().required().using(first).using(lambda *_: calls.append("sync") is None)] + + assert await errors_of({"name": "x"}, Ordered) == {"name": ["The name field is invalid."]} + assert calls == ["async"] + + +async def test_stop_on_first_failure_stops_after_the_first_field(): + class Strict(FormRequest): + stop_on_first_failure = True + + first: Annotated[str, Field().required()] + second: Annotated[str, Field().required()] + + assert await errors_of({}, Strict) == {"first": ["The first field is required."]} + + +class Comment(FormRequest): + body: Annotated[str, Field().required().min(3)] + author: str = "guest" + + @classmethod + async def authorize(cls, validator: Validator) -> bool: + await asyncio.sleep(0) + return validator.context.get("user") != "banned" + @classmethod + async def prepare_for_validation(cls, validator: Validator) -> None: + await asyncio.sleep(0) + validator.merge({"body": str(validator.data.get("body", "")).strip()}) + + @classmethod + async def after(cls, validator: Validator) -> None: + await asyncio.sleep(0) + if "spam" in str(validator.data.get("body")): + validator.errors.add("body", "No spam.") + if validator.data["author"] == validator.context.get("blocked"): + validator.errors.add("author", "Blocked.") + + +async def test_async_authorize_rejects_before_reading_input(): + with pytest.raises(AuthorizationException) as caught: + await Comment.validate_input("not even a dict", {"user": "banned"}) + + assert caught.value.get_status() == 403 + assert caught.value.get_response() == "This action is unauthorized." + + +async def test_async_prepare_for_validation_changes_the_input(): + assert (await Comment.validate_input({"body": " hello "})).body == "hello" + assert await errors_of({"body": " hi "}, Comment) == {"body": ["The body field must be at least 3 characters."]} + + +async def test_async_after_hook_adds_errors_with_validated_data(): + assert await errors_of({"body": "spam here"}, Comment, {"blocked": "guest"}) == { + "body": ["No spam."], + "author": ["Blocked."], + } + + +async def test_sync_hooks_are_supported(): + class Sync(FormRequest): + name: Annotated[str, Field().required()] + + @classmethod + def authorize(cls, validator: Validator) -> bool: + return validator.request is None + + @classmethod + def prepare_for_validation(cls, validator: Validator) -> None: + validator.merge({"name": "filled"}) + + @classmethod + def after(cls, validator: Validator) -> None: + validator.errors.add("name", "After.") if validator.data["name"] != "filled" else None + + assert (await Sync.validate_input({})).name == "filled" + + +async def test_concurrent_validations_keep_their_own_errors(): + class Slow(FormRequest): + name: Annotated[str, Field().required().using(lambda *_: asyncio.sleep(0.01, result=False))] + team: Annotated[str, Field().required().using(lambda attribute, value, data: data["allowed"] == value)] + + results = await asyncio.gather( + Slow.validate_input({"name": "a", "team": "x"}, {"allowed": "x"}), + Slow.validate_input({"name": "", "team": "y"}, {"allowed": "x"}), + Slow.validate_input({"name": "c", "team": "z"}, {"allowed": "z"}), + return_exceptions=True, + ) + + assert [result.errors for result in results if isinstance(result, ValidationException)] == [ + {"name": ["The name field is invalid."]}, + {"name": ["The name field is required."], "team": ["The team field is invalid."]}, + {"name": ["The name field is invalid."]}, + ] + + +async def test_validate_input_accepts_a_model_instance(): + user = RegisterUser.model_validate(VALID) + + assert (await RegisterUser.validate_input(user)).username == "jane" + assert await errors_of(RegisterUser.model_validate({**VALID, "username": "admin"})) == { + "username": ["The username admin is reserved."] + } -async def test_validate_input_runs_both_phases(): - assert (await RegisterUser.validate_input(VALID)).username == "jane" - with pytest.raises(ValidationException) as sync_failure: - await RegisterUser.validate_input({**VALID, "email": "nope"}) - assert sync_failure.value.errors == {"email": ["The email field must be a valid email address."]} +def test_model_validate_only_parses_types(): + user = RegisterUser.model_validate({**VALID, "username": "ab"}) - with pytest.raises(ValidationException) as async_failure: - await RegisterUser.validate_input({**VALID, "username": "admin"}) - assert async_failure.value.errors == {"username": ["The username admin is reserved."]} + assert user.username == "ab" + with pytest.raises(ValidationError): + RegisterUser.model_validate({**VALID, "age": "old"}) def test_validation_exception_is_a_422_http_exception(): diff --git a/fastapi_startkit/tests/validation/test_rules.py b/fastapi_startkit/tests/validation/test_rules.py index a0051b35..242260f6 100644 --- a/fastapi_startkit/tests/validation/test_rules.py +++ b/fastapi_startkit/tests/validation/test_rules.py @@ -4,7 +4,7 @@ import pytest -from fastapi_startkit.validation import AsyncRule, Rule +from fastapi_startkit.validation import AsyncRule, BaseRule, Rule from fastapi_startkit.validation.rules import ( Between, Boolean, @@ -140,6 +140,7 @@ def test_custom_callable_message_receives_field_value_and_data(): def test_base_rules_pass_by_default(): assert Rule().passes("field", "value", {}) + assert BaseRule().passes("field", "value", {}) assert Rule().format_message("field", "value", {}) == "The field field is invalid." From c5351d4d7a06184b99fe32328d2e73c4d3d4fdf7 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 30 Oct 2026 15:19:49 -0700 Subject: [PATCH 8/8] Combine errors of several Validated params under their embed keys 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 --- docs/adr/048-async-first-validation.md | 26 ++++++- docs/adr/AGENTS.md | 2 +- .../fastapi_startkit/fastapi/exceptions.py | 7 +- .../validation/form_request.py | 73 +++++++++++++++---- .../validation/message_bag.py | 4 +- .../validation/test_fastapi_validation.py | 60 ++++++++++++++- 6 files changed, 151 insertions(+), 21 deletions(-) diff --git a/docs/adr/048-async-first-validation.md b/docs/adr/048-async-first-validation.md index e31bbcd0..0760943e 100644 --- a/docs/adr/048-async-first-validation.md +++ b/docs/adr/048-async-first-validation.md @@ -2,7 +2,7 @@ Date: 2026-10-30 -Supersedes the "Pydantic schema and short-circuit", "Async phase for database rules" and the dependency part of "FastAPI integration" in [ADR 042](042-fluent-validation-rules.md). The rule builder, messages, `MessageBag`, `ValidationException` and the 422 body from ADR 042 are unchanged. +Supersedes the "Pydantic schema and short-circuit", "Async phase for database rules" and the dependency part of "FastAPI integration" in [ADR 042](042-fluent-validation-rules.md). The rule builder, messages, `MessageBag` and `ValidationException` from ADR 042 are unchanged, and so is ADR 042's 422 body, which is not the body on `main` (see [Response shape](#response-shape)). ## Problem @@ -47,7 +47,27 @@ Hooks are classmethods with defaults that do nothing. Each may be a plain or an ### FastAPI integration -`Validated[Model]` is still `Annotated[Model, Depends(dependency)]`, and the dependency still reads the route's typed path parameters as context (ADR 042). FastAPI no longer parses the body into the model. The dependency's body parameter is `Annotated[Any, RawInput(Model), Body()]`. `RawInput` gives it a pass-through core schema whose `json_schema_input_schema` is the model's schema, so OpenAPI still shows `$ref: Model` and the embed key under other body parameters is unchanged. The dependency then returns `await Model.validate_input(body, context, request)`. Type errors and rule errors are now reported together in one 422 with the ADR 042 body shape. If a declared path parameter is invalid, the dependency returns `None` without validating, and FastAPI reports its own path 422 before the endpoint runs. +`Validated[Model]` is still `Annotated[Model, Depends(dependency)]`, and the dependency still reads the route's typed path parameters as context (ADR 042). FastAPI no longer parses the body into the model. The dependency's body parameter is `Annotated[Any, RawInput(Model), Body()]`. `RawInput` gives it a pass-through core schema whose `json_schema_input_schema` is the model's schema, so OpenAPI still shows `$ref: Model` and the embed key under other body parameters is unchanged. The dependency then returns `await Model.validate_input(body, context, request)`. Type errors and rule errors are now reported together in one 422. If a declared path parameter is invalid, the dependency returns `None` without validating, and FastAPI reports its own path 422 before the endpoint runs. + +### Several `Validated` parameters on one route + +When a route has more than one body parameter, or one declared with `Body(embed=True)`, FastAPI embeds each body under its parameter name. For `Validated` that name is the snake-cased model name (ADR 042), so `async def checkout(billing: Validated[Billing], shipping: Validated[Shipping])` expects `{"billing": {...}, "shipping": {...}}`. Each `Validated` parameter is its own dependency, so before this change the first failing model raised immediately: the second was never validated, and the errors were keyed by the model's field names (`name`), so two models with the same field could not be told apart. + +Per route (cached with the path parameters, keyed by `id(route)`), the dependency now records whether the body is embedded (FastAPI's own rule: more than one distinct body parameter name across the dependency tree, or an `embed` body) and which `Validated` dependency FastAPI calls last (post-order over `dependant.dependencies`, the order `solve_dependencies` calls them). Each `Validated` dependency validates its model and merges any errors into a `MessageBag` kept in the request scope, prefixed with its embed key when the body is embedded (`billing.name`, `shipping.name`, the same dotted keys FastAPI's own embedded errors get). Only the last one raises `ValidationException` with every recorded error, so all models are validated and reported in one 422. A non-embedded single `Validated` body keeps unprefixed keys. + +FastAPI does not call a dependency whose own parameters failed (for example a missing `shipping` key), and then raises `RequestValidationError` instead. `ValidationExceptionHandler` therefore merges the errors recorded in the request scope in front of FastAPI's own errors, so `{"billing": {"name": "a"}}` returns both `billing.name` and `shipping`. This needs `FastAPIProvider`'s handler; with FastAPI's default handler only FastAPI's errors are returned. Errors of the endpoint's own non-`Validated` parameters are checked by FastAPI after the dependencies run, so they are not added to a 422 already raised by the last `Validated` dependency. + +Alternatives considered: one `Validated` per route only (documented limitation), which leaves the collision for any route that needs two payloads; and a custom `APIRoute` class that validates every model before the endpoint, which only works for routers that opt in (as ADR 042 found for path parameters). + +### Response shape + +The 422 body is ADR 042's, and it differs from `main`. For apps that register `FastAPIProvider`, `ValidationExceptionHandler` renders both `RequestValidationError` (FastAPI's own type and path errors, including routes that do not use `Validated`) and `ValidationException` as: + +```json +{"message": "The name field must be at least 3 characters. (and 1 more error)", "errors": {"billing.name": ["..."], "shipping.name": ["..."]}} +``` + +On `main` the body is `{"errors": {...}}`. The `errors` mapping keeps its keys and lists, and `message` is a new key, so clients that read `errors` keep working, but clients or tests that compare the whole body see the extra key. Inertia and session redirects are unchanged, because they only use `errors`. Apps without `FastAPIProvider` keep FastAPI's default `{"detail": [...]}`. A plain `data: Model` body parameter only parses types. Validation that should run rules goes through `Validated[Model]` or `await Model.validate_input(...)`. This replaces ADR 042's "a plain body runs only the sync phase", which half-validated without saying so. @@ -57,6 +77,6 @@ A plain `data: Model` body parameter only parses types. Validation that should r - `tests/validation/test_form_request.py`: sync rules, custom `Rule`/`AsyncRule`, sync and async closures, written order of mixed sync and async rules, type errors combined with rule errors, cross-field rules regardless of declaration order, non-mapping input, `stop_on_first_failure`, sync and async `authorize` (403), `prepare_for_validation`, `after` adding errors, `model_validate` parsing types only, and two concurrent `validate_input` calls with different inputs and contexts that keep their own errors. - `tests/validation/test_database_rules.py` and `test_validated_database.py`: `unique`/`exists` against SQLite through the async ORM in the same pass as sync rules, an async `ignore` callable, and the typed path parameter bound as `int`. `tests/masoniteorm/postgres/test_validated_unique.py` keeps the asyncpg case. -- `tests/validation/test_fastapi_validation.py`: the 422 body for sync, async and combined failures, a 403 from an async `authorize` that reads the request, OpenAPI `$ref` and embed key, path-parameter context, an invalid path parameter that never reaches rules, and a plain body that only parses types. +- `tests/validation/test_fastapi_validation.py`: the 422 body for sync, async and combined failures, two `Validated` models with the same field name reported together under `billing.name`/`shipping.name`, a missing embedded body merged with the other model's rule errors, the embedded OpenAPI schema for two models, the `update_user.` prefix next to another body parameter, a 403 from an async `authorize` that reads the request, OpenAPI `$ref` and embed key, path-parameter context, an invalid path parameter that never reaches rules, and a plain body that only parses types. - A grep shows no `asyncio.run`, `run_until_complete` or sync database call in `fastapi_startkit/validation`. - `uv run pytest --ignore=tests/masoniteorm/postgres --cov` passes with total coverage at least `fail_under`, and `ruff check`, `ruff format --check` and `basedpyright` are clean for the changed files. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index f6f40287..f147ace9 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -41,4 +41,4 @@ Read this index first, then open the records relevant to the change. Before impl | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | | [042](042-fluent-validation-rules.md) | 2026-10-29 | Fluent validation rules with an async phase and a Laravel 422 shape | New `fastapi_startkit.validation`: `Field()`/`Email()` builders used as `Annotated` metadata on a `FormRequest` model (`required`, `nullable`, `string`, `integer`, `numeric`, `boolean`, `min`, `max`, `between`, `email`, `url`, `regex`, `in_`, `not_in`, `same_as`, `confirmed`, `required_if`, `password`, `unique`, `exists`, `add`, `msg`). A wildcard `field_validator` only records coerced values and the wrap `model_validator` fills missing `required` keys with `None`; it then runs sync rules per field in order with all values (so cross-field rules see later fields), skipping empty values for non-implicit rules and stopping at the first failure. `AsyncRule`s (`unique`/`exists` via the async `QueryBuilder`) run in an awaited second phase, `validate_async(context)`, triggered by the `Validated[Model]` FastAPI dependency with the route's typed path params as context (`ignore=lambda data: data["user_id"]`); an invalid path param skips the async phase so FastAPI returns its path 422 before any query; path params are collected by walking the public `Dependant` tree (no `get_flat_dependant`, removed in FastAPI 0.140) and cached per route. The dependency body key is the snake-cased model name (visible only when the endpoint has other body params). Rules on nested models raise `TypeError` at class definition. The existing unused `ValidationException` becomes a 422 `HttpException` carrying a `MessageBag`; `FastAPIProvider`'s existing `ValidationExceptionHandler` renders both phases as `{"message", "errors"}` (only the `message` key is new). | | [047](047-ai-bedrock-provider.md) | 2026-10-29 | AWS Bedrock provider for the AI module | Adds `provider="bedrock"` via LangChain's `bedrock_converse` (`ChatBedrockConverse`), the same `init_chat_model` path as other providers. `BedrockConfig` reads `AWS_REGION`/`AWS_DEFAULT_REGION`, IAM keys, session token, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK` and `AWS_BEDROCK_MODEL`. `Lab.get_client_options()` maps them to ChatBedrockConverse arguments and drops empty values so boto3's default credential chain applies; other providers return `{}`. The `bedrock` extra is `fastapi-startkit[ai]` plus `langchain-aws`, because `langchain-aws` does not pull in `langchain`. `langchain-aws` is imported only by `init_chat_model` when a Bedrock model is built. | -| [048](048-async-first-validation.md) | 2026-10-30 | Async-first validation: one awaited rule pass, async hooks and closures | Supersedes ADR 042's two-phase design (sync rules in pydantic, `AsyncRule`s in `validate_async`). `await Model.validate_input(data, context=None, request=None)` is the only validation entry point: a per-call `Validator` runs `authorize` (false → 403 `AuthorizationException`), `prepare_for_validation`, a pydantic type-only parse (type errors added to the bag), then every field's rules in declaration and written order, awaiting a rule's result only when it is awaitable (sync rules unchanged, `unique`/`exists` in place via the async `QueryBuilder`), then `after`, and raises one `ValidationException`. Hooks may be sync or async classmethods; `Field().using(callback)` adds sync/async closure rules; `unique(ignore=...)` accepts async callables; `stop_on_first_failure` stops after the first error. `model_validate`/plain body params only parse types. `Validated[Model]` takes the raw body (`RawInput` keeps the OpenAPI `$ref` via `json_schema_input_schema`) and awaits `validate_input` with the typed path params, so type and rule errors share one 422 with the unchanged ADR 042 shape. `validate_async` is removed. | +| [048](048-async-first-validation.md) | 2026-10-30 | Async-first validation: one awaited rule pass, async hooks and closures | Supersedes ADR 042's two-phase design (sync rules in pydantic, `AsyncRule`s in `validate_async`). `await Model.validate_input(data, context=None, request=None)` is the only validation entry point: a per-call `Validator` runs `authorize` (false → 403 `AuthorizationException`), `prepare_for_validation`, a pydantic type-only parse (type errors added to the bag), then every field's rules in declaration and written order, awaiting a rule's result only when it is awaitable (sync rules unchanged, `unique`/`exists` in place via the async `QueryBuilder`), then `after`, and raises one `ValidationException`. Hooks may be sync or async classmethods; `Field().using(callback)` adds sync/async closure rules; `unique(ignore=...)` accepts async callables; `stop_on_first_failure` stops after the first error. `model_validate`/plain body params only parse types. `Validated[Model]` takes the raw body (`RawInput` keeps the OpenAPI `$ref` via `json_schema_input_schema`) and awaits `validate_input` with the typed path params, so type and rule errors share one 422. Several `Validated` params on one route are all validated: each records its errors in the request scope (prefixed with its embed key, e.g. `shipping.name`, when the body is embedded) and the route's last `Validated` dependency raises them together; `ValidationExceptionHandler` merges recorded errors into a `RequestValidationError` too. With `FastAPIProvider`, every 422 (also plain `RequestValidationError`) is `{"message", "errors"}`, a superset of `main`'s `{"errors"}`. `validate_async` is removed. | diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py index 7423baa3..f8977285 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py @@ -85,9 +85,14 @@ async def render(self, request, exc): wants_json = "application/json" in accept or content_type.startswith("application/json") from fastapi_startkit.exceptions import ValidationException + from fastapi_startkit.validation.form_request import PENDING_ERRORS from fastapi_startkit.validation.message_bag import MessageBag - bag = exc.bag if isinstance(exc, ValidationException) else MessageBag.from_errors(exc.errors(), skip=1) + if isinstance(exc, ValidationException): + bag = exc.bag + else: + bag = MessageBag().merge(request.scope.get(PENDING_ERRORS, MessageBag())) + bag.merge(MessageBag.from_errors(exc.errors(), skip=1)) errors = bag.messages if request.headers.get(Header.INERTIA): diff --git a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py index 201905af..c4c0d1f9 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/form_request.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +from dataclasses import dataclass from collections.abc import Awaitable, Iterator, Mapping from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Self, get_args from weakref import finalize @@ -30,6 +31,8 @@ REQUEST_PARAMETER = "__validated_request" RECORDED = "fastapi_startkit.validation.recorded" +PENDING_ERRORS = "fastapi_startkit.validation.errors" +VALIDATED_KEY = "__validated_key__" def has_rules(model: type[BaseModel]) -> bool: @@ -176,32 +179,66 @@ def __get_pydantic_core_schema__(self, source_type: Any, handler: GetCoreSchemaH ) -def declared_path_params(dependant: Dependant) -> list[Any]: - return [*dependant.path_params, *(param for sub in dependant.dependencies for param in declared_path_params(sub))] +def declared(dependant: Dependant, attribute: str) -> list[Any]: + return [ + *getattr(dependant, attribute), + *(item for sub in dependant.dependencies for item in declared(sub, attribute)), + ] -route_path_params: dict[int, list[Any]] = {} +def validated_calls(dependant: Dependant) -> list[Any]: + calls = [call for sub in dependant.dependencies for call in validated_calls(sub)] + return [*calls, dependant.call] if hasattr(dependant.call, VALIDATED_KEY) else calls -def path_params_of(route: APIRoute) -> list[Any]: +@dataclass(frozen=True) +class RouteValidation: + path_params: list[Any] + embedded: bool + last: Any + + @classmethod + def of(cls, dependant: Dependant) -> RouteValidation: + body_params = declared(dependant, "body_params") + embedded = len({param.name for param in body_params}) > 1 or any( + getattr(param.field_info, "embed", False) for param in body_params + ) + calls = validated_calls(dependant) + return cls(declared(dependant, "path_params"), embedded, calls[-1] if calls else None) + + +routes: dict[int, RouteValidation] = {} + + +def route_validation(route: APIRoute) -> RouteValidation: key = id(route) - if key not in route_path_params: - route_path_params[key] = declared_path_params(route.dependant) - finalize(route, route_path_params.pop, key, None) - return route_path_params[key] + if key not in routes: + routes[key] = RouteValidation.of(route.dependant) + finalize(route, routes.pop, key, None) + return routes[key] -def typed_path_params(request: Request) -> dict[str, Any] | None: - from fastapi.dependencies.utils import request_params_to_args +def matched_route(request: Request) -> RouteValidation | None: from fastapi.routing import APIRoute route = request.scope.get("route") - if not isinstance(route, APIRoute): + return route_validation(route) if isinstance(route, APIRoute) else None + + +def typed_path_params(request: Request) -> dict[str, Any] | None: + from fastapi.dependencies.utils import request_params_to_args + + route = matched_route(request) + if route is None: return dict(request.path_params) - values, errors = request_params_to_args(path_params_of(route), request.path_params) + values, errors = request_params_to_args(route.path_params, request.path_params) return None if errors else {**request.path_params, **values} +def pending_errors(request: Request) -> MessageBag: + return request.scope.setdefault(PENDING_ERRORS, MessageBag()) + + def validated_dependency(model: type[FormRequest]) -> Any: from fastapi import Body, Request @@ -212,8 +249,18 @@ async def dependency(**parameters: Any) -> Any: context = typed_path_params(request) if context is None: return None - return await model.validate_input(parameters[key], context, request) + route = matched_route(request) + errors = pending_errors(request) + try: + instance = await model.validate_input(parameters[key], context, request) + except ValidationException as error: + instance = None + errors.merge(error.bag, prefix=key if route is not None and route.embedded else None) + if errors.any() and (route is None or route.last is dependency): + raise ValidationException(errors) + return instance + setattr(dependency, VALIDATED_KEY, key) setattr( dependency, "__signature__", diff --git a/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py b/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py index 4a0fbd43..31531b73 100644 --- a/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py +++ b/fastapi_startkit/src/fastapi_startkit/validation/message_bag.py @@ -22,10 +22,10 @@ def add(self, field: str, message: str) -> Self: self.messages.setdefault(field, []).append(message) return self - def merge(self, other: MessageBag) -> Self: + def merge(self, other: MessageBag, prefix: str | None = None) -> Self: for field, messages in other.messages.items(): for message in messages: - self.add(field, message) + self.add(f"{prefix}.{field}" if prefix else field, message) return self def has(self, field: str) -> bool: diff --git a/fastapi_startkit/tests/validation/test_fastapi_validation.py b/fastapi_startkit/tests/validation/test_fastapi_validation.py index 9d62d140..b6f0d0bc 100644 --- a/fastapi_startkit/tests/validation/test_fastapi_validation.py +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -62,6 +62,14 @@ async def authorize(cls, validator: Validator) -> bool: return validator.request is not None and validator.request.headers.get("x-role") == "editor" +class Billing(FormRequest): + name: Annotated[str, Field().required().min(3)] + + +class Shipping(FormRequest): + name: Annotated[str, Field().required().min(5)] + + async def tenant(tenant_id: int) -> int: return tenant_id @@ -92,6 +100,10 @@ async def update(user_id: int, data: Validated[UpdateUser]): async def note(user_id: int, data: Validated[UpdateUser], note: Annotated[str, Body()]): return {"id": user_id, "email": data.email, "note": note} + @app.fastapi.post("/checkout") + async def checkout(billing: Validated[Billing], shipping: Validated[Shipping]): + return {"billing": billing.name, "shipping": shipping.name} + @app.fastapi.post("/publish") async def publish(data: Validated[Publish]): return {"title": data.title} @@ -182,9 +194,55 @@ async def test_validated_body_is_embedded_under_the_model_name_with_other_body_p valid.assert_status(200) self.assertEqual(valid.json(), {"id": 1, "email": "jane@example.com", "note": "hi"}) taken.assert_status(422) - self.assertEqual(taken.json()["errors"], {"email": ["The email has already been taken."]}) + self.assertEqual(taken.json()["errors"], {"update_user.email": ["The email has already been taken."]}) self.assertEqual(sorted(body["properties"]), ["note", "update_user"]) + async def test_multiple_validated_params_are_combined_under_their_embed_keys(self): + both = await self.post("/checkout", json={"billing": {"name": "a"}, "shipping": {"name": "ab"}}) + second = await self.post("/checkout", json={"billing": {"name": "abcd"}, "shipping": {"name": "ab"}}) + valid = await self.post("/checkout", json={"billing": {"name": "abcd"}, "shipping": {"name": "abcdef"}}) + + both.assert_status(422) + self.assertEqual( + both.json(), + { + "message": "The name field must be at least 3 characters. (and 1 more error)", + "errors": { + "billing.name": ["The name field must be at least 3 characters."], + "shipping.name": ["The name field must be at least 5 characters."], + }, + }, + ) + second.assert_status(422) + self.assertEqual(second.json()["errors"], {"shipping.name": ["The name field must be at least 5 characters."]}) + valid.assert_status(200) + self.assertEqual(valid.json(), {"billing": "abcd", "shipping": "abcdef"}) + + async def test_missing_embedded_body_is_combined_with_rule_errors(self): + response = await self.post("/checkout", json={"billing": {"name": "a"}}) + + response.assert_status(422) + self.assertEqual( + response.json()["errors"], + { + "billing.name": ["The name field must be at least 3 characters."], + "shipping": ["Field required"], + }, + ) + + async def test_multiple_validated_params_keep_the_embedded_openapi_schema(self): + schema = (await self.get("/openapi.json")).json() + body = schema["components"]["schemas"]["Body_checkout_checkout_post"] + + self.assertEqual( + body["properties"], + { + "billing": {"$ref": "#/components/schemas/Billing"}, + "shipping": {"$ref": "#/components/schemas/Shipping"}, + }, + ) + self.assertEqual(sorted(body["required"]), ["billing", "shipping"]) + async def test_plain_body_parameter_only_parses_types(self): accepted = await self.post("/plain", json={**VALID, "username": "jo"}) rejected = await self.post("/plain", json={**VALID, "username": 5})