diff --git a/docs/adr/042-fluent-validation-rules.md b/docs/adr/042-fluent-validation-rules.md new file mode 100644 index 00000000..a654b258 --- /dev/null +++ b/docs/adr/042-fluent-validation-rules.md @@ -0,0 +1,95 @@ +# 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. 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. 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. 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. 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 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(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, 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. + +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": ["..."]}} +``` + +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 (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. +- 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/048-async-first-validation.md b/docs/adr/048-async-first-validation.md new file mode 100644 index 00000000..0760943e --- /dev/null +++ b/docs/adr/048-async-first-validation.md @@ -0,0 +1,82 @@ +# 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` 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 + +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. 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. + +`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, 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 7ce87eec..f147ace9 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -39,4 +39,6 @@ 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. | | [040](040-deprecate-application-env-argument.md) | 2026-10-25 | Deprecate the `env` argument of `Application` | The deployment environment must come from outside the code: `APP_ENV`, the CLI `--env` option (turned into `APP_ENV` before Cleo parses arguments) or pytest detection (`testing`). `Application(env=...)` now emits a `DeprecationWarning` at the caller (`stacklevel=2`) and will be removed in 1.0; behaviour and precedence are unchanged until then. `set_environment()` stays. Framework tests no longer pass `env=`, and pytest `filterwarnings` turns this warning into an error so the suite cannot reintroduce it. | | [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. 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/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..f8977285 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,16 @@ 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.form_request import PENDING_ERRORS + from fastapi_startkit.validation.message_bag import MessageBag + + 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): return self.redirect_back_with_errors(request, errors) @@ -95,7 +101,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..d7c14aac --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/__init__.py @@ -0,0 +1,20 @@ +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 +from fastapi_startkit.validation.validator import Validator + +__all__ = [ + "AsyncRule", + "BaseRule", + "Email", + "Field", + "FormRequest", + "Message", + "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 new file mode 100644 index 00000000..56b8fc6d --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/field.py @@ -0,0 +1,176 @@ +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 ( + BaseRule, + Between, + Boolean, + Closure, + Confirmed, + EmailRule, + Exists, + Ignore, + In, + Integer, + Max, + Message, + Min, + NotIn, + Nullable, + Numeric, + Passes, + Regex, + Required, + RequiredIf, + SameAs, + Size, + String, + Unique, + Url, + is_empty, + resolve, +) + +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) + 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: + 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 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) + + async def check(self, attribute: str, value: Any, data: Mapping[str, Any]) -> str | None: + for rule in self.rules: + if self.skips(rule, value): + continue + if not await resolve(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, 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: + 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..c4c0d1f9 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/form_request.py @@ -0,0 +1,287 @@ +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 + +from inflection import underscore +from pydantic import ( + BaseModel, + GetCoreSchemaHandler, + ModelWrapValidatorHandler, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) +from pydantic_core import CoreSchema, core_schema + +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 + from fastapi.routing import APIRoute + from starlette.requests import Request + +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: + 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(): + for metadata in info.metadata: + 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: + values = coerced_values.get() + if values is not None: + values[str(info.field_name)] = value + return value + + @model_validator(mode="wrap") + @classmethod + def record_values(cls, data: Any, handler: ModelWrapValidatorHandler[Self], info: ValidationInfo) -> Self: + values: dict[str, Any] = {} + token = coerced_values.set(values) + try: + return handler(data) + finally: + coerced_values.reset(token) + 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 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] + 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: 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() + } + return {**defaults, **data, **values} + + @classmethod + 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 = await field.check(name, data.get(name), data) + if message is not None: + validator.errors.add(name, message) + + +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(dependant: Dependant, attribute: str) -> list[Any]: + return [ + *getattr(dependant, attribute), + *(item for sub in dependant.dependencies for item in declared(sub, attribute)), + ] + + +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 + + +@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 routes: + routes[key] = RouteValidation.of(route.dependant) + finalize(route, routes.pop, key, None) + return routes[key] + + +def matched_route(request: Request) -> RouteValidation | None: + from fastapi.routing import APIRoute + + route = request.scope.get("route") + 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(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 + + key = underscore(model.__name__) + + async def dependency(**parameters: Any) -> Any: + request = parameters[REQUEST_PARAMETER] + context = typed_path_params(request) + if context is None: + return None + 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__", + inspect.Signature( + [ + inspect.Parameter(REQUEST_PARAMETER, inspect.Parameter.KEYWORD_ONLY, annotation=Request), + inspect.Parameter( + key, inspect.Parameter.KEYWORD_ONLY, annotation=Annotated[Any, RawInput(model), Body()] + ), + ] + ), + ) + 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..31531b73 --- /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, 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] if isinstance(field_messages, str) else 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, prefix: str | None = None) -> Self: + for field, messages in other.messages.items(): + for message in messages: + self.add(f"{prefix}.{field}" if prefix else 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..d77bbc3d --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/validation/rules.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import inspect +import re +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 + +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] +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 + if isinstance(value, str): + return value.strip() == "" + if isinstance(value, (list, tuple, set, dict)): + return len(value) == 0 + 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() + + +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 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 + + 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 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 + + 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": 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) + + +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 + 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): + return self.messages["array"] + return self.messages["numeric"] + + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + 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) + + +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 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 self.contains(value) + + +class NotIn(In): + def passes(self, attribute: str, value: Any, data: Mapping[str, Any]) -> bool: + return not self.contains(value) + + +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 = 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() + + +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/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/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/masoniteorm/postgres/test_validated_unique.py b/fastapi_startkit/tests/masoniteorm/postgres/test_validated_unique.py new file mode 100644 index 00000000..c4cce3f2 --- /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, 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/__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..0bffc1a2 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_database_rules.py @@ -0,0 +1,141 @@ +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"])] + + +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")] + + +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, model: type[FormRequest], data: dict, errors: dict[str, list[str]], context=None): + with self.assertRaises(ValidationException) as caught: + await model.validate_input(data, context) + self.assertEqual(caught.exception.errors, errors) + + async def test_unique_passes_for_new_values(self): + request = await Signup.validate_input({"email": "john@example.com", "handle": "john"}) + + 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"}, + {"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): + 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} + ) + + 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.validate_input({"email": "jane@example.com"})) + + async def test_unique_skips_empty_values(self): + self.assertIsNotNone(await UpdateByHandle.validate_input({"email": ""})) + + async def test_exists(self): + 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"}, + {"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 new file mode 100644 index 00000000..b6f0d0bc --- /dev/null +++ b/fastapi_startkit/tests/validation/test_fastapi_validation.py @@ -0,0 +1,306 @@ +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Annotated, Any + +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 +from fastapi_startkit.fastapi import FastAPIProvider +from fastapi_startkit.fastapi.testing import HttpTestCase +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} +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 owner == 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())] + + +class Request(FormRequest): + email: Annotated[str, Email().required()] + + +TEAM_CONTEXT: list[dict[str, Any]] = [] + + +class TeamMember(FormRequest): + email: Annotated[str, Email().required()] + + @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" + + +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 + + +def make_application(base_path: Path) -> Application: + app = Application(base_path=base_path, 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]): + 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("/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("/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} + + @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_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"], {"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}) + + accepted.assert_status(200) + rejected.assert_status(422) + 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() + body = schema["paths"]["/register"]["post"]["requestBody"]["content"]["application/json"]["schema"] + + 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 = StarletteRequest({"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 new file mode 100644 index 00000000..185ffa30 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_form_request.py @@ -0,0 +1,396 @@ +from collections.abc import Mapping +from typing import Annotated, Any + +import asyncio + +import pytest +from pydantic import BaseModel, ValidationError + +from fastapi_startkit.exceptions import AuthorizationException, HttpException +from fastapi_startkit.validation import ( + AsyncRule, + Email, + Field, + FormRequest, + Rule, + ValidationException, + Validator, +) + + +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", +} + + +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 + + +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 + + +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"], + "password": ["The password field is required."], + } + + +async def test_required_failure_stops_the_field(): + assert (await errors_of({**VALID, "username": ""}))["username"] == ["username is required but you provided ''"] + + +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." + ] + + +async def test_missing_optional_field_skips_its_rules(): + assert (await RegisterUser.validate_input({**VALID, "nickname": ""})).nickname == "" + + +async def test_msg_replaces_last_rule_message(): + assert await errors_of({**VALID, "age": 17}) == {"age": ["Adults only."]} + + +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" + + +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."], + "tax_id": ["The tax id field is required when is business is 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"], + "email": ["The email field must be a valid email address."], + "tax_id": ["The tax id field is required when is business is true."], + } + + +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"] + } + + +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"]} + + +async def test_field_without_nullable_rejects_none(): + class Profile(FormRequest): + bio: Annotated[str, Field().max(5)] = "hi" + + assert await errors_of({"bio": None}, Profile) == {"bio": ["Input should be a valid string"]} + + +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_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" + + +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 (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."], + } + + +async def test_in_rule_rejects_unhashable_values(): + class Pick(FormRequest): + choice: Annotated[Any, Field().required().in_({"a", "b"})] + + assert await errors_of({"choice": ["a"]}, Pick) == {"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") + + +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."], + } + + +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_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."] + } + + +def test_model_validate_only_parses_types(): + user = RegisterUser.model_validate({**VALID, "username": "ab"}) + + assert user.username == "ab" + with pytest.raises(ValidationError): + RegisterUser.model_validate({**VALID, "age": "old"}) + + +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..b13eab61 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_message_bag.py @@ -0,0 +1,56 @@ +from fastapi_startkit.validation import MessageBag, ValidationException + + +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."], + } + + +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 new file mode 100644 index 00000000..242260f6 --- /dev/null +++ b/fastapi_startkit/tests/validation/test_rules.py @@ -0,0 +1,167 @@ +import re +from decimal import Decimal +from typing import Any + +import pytest + +from fastapi_startkit.validation import AsyncRule, BaseRule, 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]), + (In({"a", "b"}), ["a"], [["a"], {"a": 1}]), + (NotIn({"a", "b"}), ["c", ["a"], {"a": 1}], ["a"]), + (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 BaseRule().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", {}) + + +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..5b4befe7 --- /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, 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()