# Validation & Injection Validation and coercion in EDRI is performed by **wrapping a validator/transformer in `inject(...)`**. > Middleware is executed **after the event instance has been fully created and populated** (including validation and transformation). Validators therefore always run **before** any middleware logic. * This applies to **built-in** validators and **custom** validators. * Validators/transformers run in the **Listener** process when an API event is constructed (during request parsing, before the event is sent to the Broker). `inject(...)` can take **multiple** validators/transformers. * They are executed **in the same order** as they are provided to `inject(...)`. * Keyword arguments are passed through and are available to the validators as needed. > If you do not use `inject(...)`, the system falls back to basic type construction/coercion rules. --- ## `inject(...)` in one sentence `inject(...)` tells the API layer: **“take the raw incoming value, run it through one or more validators/transformers, and store the final returned value in the event field.”** A validator/transformer must: * accept the raw incoming value (often a `str`) * return the converted value * raise an exception (typically `ValueError` / `TypeError`) on failure --- ## Built-in validators EDRI includes these built-in validators: * `StringValidator` * `IntegerValidator` * `FloatValidator` * `DateTimeValidator` * `TimeValidator` * `DateValidator` * `ListValidator` These are used through `inject(...)`. ### `StringValidator` maximum length `StringValidator` accepts an integer `maximum_length`: ```python name: inject(StringValidator, minimum_length=1, maximum_length=100) ``` It can also derive the maximum length from a column-like object with `.type.length`. This works with SQLAlchemy mapped attributes and table columns without importing SQLAlchemy in EDRI core: ```python name: inject(StringValidator, minimum_length=1, maximum_length=User.name) title: inject(StringValidator, maximum_length=User.__table__.c.title) ``` The referenced column must expose a defined integer length, for example a `String(100)` column. Unbounded text columns are rejected because they do not define a maximum length. To derive the validator type as well as its available constraints, use the SQLAlchemy integration's factory instead: ```python from edri.integrations.sqlalchemy import orm_validator name: inject(orm_validator(User.name)) ``` Extra keyword arguments are forwarded to the derived validator and take precedence over constraints read from the column: ```python name: inject(orm_validator(User.name, maximum_length=64, minimum_length=2)) ``` See {doc}`/integrations/sqlalchemy` for supported ORM types, enum handling, and event optionality. --- ## Nullable injection (`| None`) Use ``| None`` to make an injected field optional. The field **must** have a default value (typically ``= None``) — without it, the field is still treated as required. ```python @api(url="/search", method=HTTPMethod.GET) class Search(Event): term: inject(StringValidator, minimum_length=3) optional_filter: inject(StringValidator, minimum_length=2) | None = None response: SearchResponse ``` Here: * ``term`` is **required** — ``None`` raises a validation error. * ``optional_filter`` is **optional** — if no value is provided it defaults to ``None``; if a value is provided it is validated against the validator. > **Important:** ``inject(...) | None`` without ``= None`` does **not** make the field optional in the current release. The field must have an explicit default to be treated as optional. This works for **any** validator or transformer used with ``inject(...)``. --- ## Order of execution `inject(...)` may contain multiple validators/transformers. They execute strictly **left-to-right**, in the same order they are provided to `inject(...)`. Example (illustrative): ```python field: inject(FirstStep, SecondStep, ThirdStep) ``` Execution order: 1. `FirstStep` receives the raw incoming value 2. its return value is passed into `SecondStep` 3. its return value is passed into `ThirdStep` 4. the final return value is assigned to the event field --- ## Supported placeholder types for URL segments When a field is used as a URL placeholder, EDRI matches the corresponding URL segment using a per-segment regex. For these base types, EDRI can derive a matching regex automatically: | Type | Segment regex (core part) | Notes | | -------------- | -------------------------------------- | ------------------------------------------- | | `int` | `\d+` | digits only | | `str` | `[-A-Za-z0-9._~]+` | URL-safe characters (no `/`) | | `float` | `-?\d+\.\d+` | decimal with optional leading `-` (no `.1`) | | `bool` | `true\|false\|1\|0` | both `"0"` and `"false"` → `False` (see note) | | `UUID` | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | lowercase hex UUID format | | `Literal[...]` | `a\|b\|c` | alternation of allowed literal values | | `Enum` | `v1\|v2\|...` | alternation of enum member values | > **Bool conversion note:** EDRI does not use Python's bare `bool()` constructor (where `bool("0")` would be `True`). Its converter maps the case-insensitive tokens `true`, `1`, `yes`, `on` to `True` and `false`, `0`, `no`, `off` to `False`; any other value raises a conversion error. In the URL path the regex only allows `true|false|1|0`. If you use a custom type and it does **not** inherit from one of the supported base types above, supply an explicit `regex=` via `inject(...)` so the URL segment can be matched. --- ## Examples ### 1) Basic string validation (query/body) ```python @api(url="/search", method=HTTPMethod.GET) class Search(Event): term: inject(StringValidator, minimum_length=3) response: SearchResponse ``` What happens: * `term` is taken from the API input sources (see {ref}`input-extraction-api-only`) * `StringValidator` validates the raw value * the validated result is assigned to `event.term` ### 2) Integer parsing and validation ```python @api(url="/items", method=HTTPMethod.GET) class ListItems(Event): limit: inject(IntegerValidator, minimum=1, maximum=100) response: ListItemsResponse ``` ### 3) URL parameter matching: when regex is required If a field is used as a URL placeholder, the router needs a per-segment regex to match the incoming path. * For supported placeholder base types, EDRI can derive the regex automatically (see **Supported placeholder types for URL segments**). * If you use a custom datatype and it does **not** inherit from a supported placeholder base type, you must supply a `regex=`. Example (explicit regex): ```python @api(url="/items/{item_id}", method=HTTPMethod.GET) class ItemDetail(Event): item_id: inject(StringValidator, regex=re_identifier) response: ItemDetailResponse ``` If your validator/transformer inherits from a supported placeholder base type (for example a `str` subclass), EDRI can use that base type for URL matching and you may not need to provide a `regex=`. --- ## Transformers (normalization) Some callables act like validators but are primarily used to **normalize** values. ### Example: `StringTransformer` ```python @api(url="/search", method=HTTPMethod.GET) class NormalizedSearch(Event): term: inject(StringTransformer, lower=True) response: SearchResponse ``` Notes: * `StringTransformer` is a `str` subclass. * It transforms the incoming value during construction (`lower`, `upper`, or a custom `transform=` function). --- ## Custom validators Any callable that matches the “validator contract” can be used. ### Example: foreign key loader (`ForeignKeyValidator[T]`) A common pattern is to convert an incoming identifier into a loaded ORM model. ```python from typing import Any, Generic, TypeVar, get_args, get_origin T = TypeVar("T") class ForeignKeyValidator(Generic[T]): """Validator that turns a FK id into a loaded SQLAlchemy model instance.""" def __new__( cls, raw_value: Any, *, generics: list[Base], ) -> T: model = generics[0] try: pk = int(raw_value) except (TypeError, ValueError) as e: raise ValueError("Invalid integer") from e with SessionFactory() as session: obj = session.get(model, pk) if obj is None: raise ValueError(f"{model.__name__}=={pk} doesn't exist") return obj def __repr__(self) -> str: orig = getattr(self, "__orig_class__", None) if orig is None: return type(self).__name__ origin = get_origin(orig) or type(self) args = get_args(orig) args_str = ", ".join(getattr(a, "__name__", repr(a)) for a in args) return f"{origin.__name__}[{args_str}]" ``` Usage: ```python @api(url="/classifications/{classification}", method=HTTPMethod.GET) class GetClassification(Event): classification: inject(ForeignKeyValidator[Classification], regex=re_identifier) response: GetClassificationResponse ``` Notes: * The router uses regex to match the URL segment. * The validator then converts the raw value to `int` and loads the model. > A ready-made SQLAlchemy version ships as an **opt-in integration**: > `from edri.integrations.sqlalchemy import ForeignKeyValidator`. It is not a > core dependency — install it with `pip install edri[sqlalchemy]`. The model is > taken from the subscript and the session is passed per field, e.g. > `inject(ForeignKeyValidator[User], session_factory=SessionLocal)`. Unlike the > minimal example above, it coerces the id to the model's primary-key type read > from the SQLAlchemy mapper (`int`, `uuid.UUID`, `str`, …); composite keys are > unsupported. See {ref}`optional-integrations`. ### Example: IP address validator ```python class IPAddressValidator: def __new__( cls, raw_value: Any, ) -> IPv4Address | IPv6Address: try: return IPv4Address(raw_value) except (AddressValueError, NetmaskValueError): pass try: return IPv6Address(raw_value) except (AddressValueError, NetmaskValueError): pass raise ValueError(f"{raw_value!r} does not appear to be an IPv4 or IPv6 address") ``` Usage: ```python @api(url="/audit", method=HTTPMethod.POST) class Audit(Event): client_ip: inject(IPAddressValidator) response: EmptyResponse ``` --- ## Lists and `ListValidator` Lists commonly appear in API-sourced fields such as headers and query parameters. ### Query parameters Lists in **query parameters** must use `[]` syntax: ``` ?ids[]=1&ids[]=2 ``` ### Example: list values sourced from headers In many API integrations, headers may be repeated and are represented as lists. `ListValidator[...]` can be used to validate the list and validate/transform each element. ```python @api(url="/file/upload", exclude={ApiType.HTML, ApiType.WS}, method=HTTPMethod.POST) class FileUpload(SessionEvent): file: File language: inject( ListValidator[inject(ForeignKeyValidator[Language])], minimum_length=1, maximum_length=1, ) = Header("language") ocr_strategy: inject( ListValidator[Literal["OCR_ONLY", "OCR_AND_TEXT_EXTRACTION", "NO_OCR", "AUTO"]], minimum_length=1, maximum_length=1, ) = Header("ocrstrategy") response: FileUploadResponse ``` Notes: * The `Header(...)` default makes the field API-sourced. * `ListValidator[...]` validates the list itself (e.g., min/max length). * The inner validator/type validates each element. * This pattern is especially useful for multi-valued headers. (Keep parameter names exactly as implemented by your `ListValidator`.) --- ## Error behavior If validation fails, the API layer returns **400 Bad Request** with a standardized error response. Example (illustrative): ``` Error 400 Oops! Something went wrong. Status Code: 400 - BAD_REQUEST Error 400: Cannot create ValueError("Wrong type ...") TypeError("Value ... cannot be converted ...") ValueError("Invalid data provided ...") ``` ### Gotcha: how much detail is exposed depends on the environment The error payload carries the validation failures as a `reasons` list. How much is returned is controlled by **`EDRI_API_EXPOSE_ERROR_DETAILS`** (default: follows `ENVIRONMENT`): * **development** (`true`) — the full chain of reasons (raw exception strings, the failing field) is returned, as shown above. Great for debugging. * **production** (`false`) — only the **first** reason is returned, so raw internals are not leaked to clients. So you do not need to strip exception strings yourself — production already shows just the first reason. To override per environment, set the variable explicitly; to reshape the body further, use a response {doc}`/middleware`. See {doc}`/env`. --- (optional-integrations)= ## Optional integrations Some validators are genuinely useful but only make sense with a third-party library (an ORM, a cloud SDK, …). Bundling such a library into core EDRI would force the dependency on every user, so these helpers live under `edri.integrations.*` as **opt-in** modules. The contract: * The dependency is declared as a setuptools **extra**, not a core requirement — install it explicitly, e.g. `pip install edri[sqlalchemy]`. * `import edri` never imports anything from `edri.integrations`, so the optional dependency stays optional. You import the specific module yourself. * If the extra is missing, importing the module raises a clear `ImportError` naming the extra to install — not a bare `ModuleNotFoundError`. This mirrors how EDRI already treats the ASGI servers (`uvicorn`, `hypercorn`, `daphne`): each is an extra, detected and imported only when actually used. ### `ForeignKeyValidator` (SQLAlchemy) A ready-made version of the [foreign key loader](#example-foreign-key-loader-foreignkeyvalidatort) above ships as `edri.integrations.sqlalchemy`: ```python from edri.integrations.sqlalchemy import ForeignKeyValidator from app.db import SessionLocal # a sessionmaker @api(url="/users/{user}", method=HTTPMethod.GET) class GetUser(Event): user: inject(ForeignKeyValidator[User], session_factory=SessionLocal) response: GetUserResponse ``` * The target model comes from the subscript (`ForeignKeyValidator[User]`), which EDRI passes to the validator as `generics`. * The session is supplied **per field** via the `session_factory` keyword, so there is no global state — different fields can use different sessions. * The id is coerced to the model's primary-key type, read from the SQLAlchemy mapper — `int`, `uuid.UUID`, `str` and similar scalar keys all work. A composite primary key is unsupported and raises `ValueError`. * An id that cannot be coerced to that type, or a missing row, raises `ValueError`, which EDRI turns into a validation failure (HTTP 400). The same module also ships `SessionJSONEncoder` for serializing detached ORM instances — see {ref}`example-serializing-orm-models-with-a-session`. For the integration module contract and a template for adding new optional helpers, see {doc}`/integrations/custom`. ## Gotchas & Notes * Validators/transformers are applied via `inject(...)` (built-in and custom). * For URL placeholders, regex may be required to allow routing to match the segment. * Query-list fields require `[]` syntax. * Validation failures produce HTTP 400.