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:
StringValidatorIntegerValidatorFloatValidatorDateTimeValidatorTimeValidatorDateValidatorListValidator
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:
termis required —Noneraises a validation error.optional_filteris optional — if no value is provided it defaults toNone; if a value is provided it is validated against the validator.
Important:
inject(...) | Nonewithout= Nonedoes 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:
FirstStepreceives the raw incoming valueits return value is passed into
SecondStepits return value is passed into
ThirdStepthe 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 |
|---|---|---|
|
|
digits only |
|
|
URL-safe characters (no |
|
|
decimal with optional leading |
|
|
both |
|
|
lowercase hex UUID format |
|
|
alternation of allowed literal values |
|
|
alternation of enum member values |
Bool conversion note: EDRI does not use Python's bare
bool()constructor (wherebool("0")would beTrue). Its converter maps the case-insensitive tokenstrue,1,yes,ontoTrueandfalse,0,no,offtoFalse; any other value raises a conversion error. In the URL path the regex only allowstrue|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:
termis taken from the API input sources (see Input extraction (API-only))StringValidatorvalidates the raw valuethe 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:
StringTransformeris astrsubclass.It transforms the incoming value during construction (
lower,upper, or a customtransform=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
intand 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 withpip 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 edrinever imports anything fromedri.integrations, so the optional dependency stays optional. You import the specific module yourself.If the extra is missing, importing the module raises a clear
ImportErrornaming the extra to install — not a bareModuleNotFoundError.
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 asgenerics.The session is supplied per field via the
session_factorykeyword, 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,strand similar scalar keys all work. A composite primary key is unsupported and raisesValueError.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.