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:

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:

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:

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:

name: inject(orm_validator(User.name, maximum_length=64, minimum_length=2))

See SQLAlchemy (edri.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.

@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):

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)

@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 Input extraction (API-only))

  • StringValidator validates the raw value

  • the validated result is assigned to event.term

2) Integer parsing and validation

@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):

@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

@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.

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:

@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 Optional integrations.

Example: IP address validator

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:

@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.

@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 <EventType>
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 Middleware. See Environment Variables.


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 above ships as edri.integrations.sqlalchemy:

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 Example: serializing ORM models with a session.

For the integration module contract and a template for adding new optional helpers, see Writing your own integration.

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.