API Event¶
An API Event is a normal EDRI Event that is exposed through the API layer.
The core event model does not change. The API layer acts as an adapter between external transports (HTTP, WebSocket, HTML) and the internal event/router system.
The API layer is responsible for:
creating events from incoming requests/messages
populating fields from headers, cookies, scope, path, query, and body
running validation and transformation
invoking middleware
putting the event into the router (the router performs routing)
sending the outgoing response/event back to the client
This document describes API-specific behavior that does not belong in Event.
Exposing an event via @api¶
Use the @api(...) decorator to expose an event through REST, WebSocket (WS),
and/or HTML.
@api(
url=None,
resource=None,
handling=None,
exclude=None,
template=None,
shareable=False,
method=None,
)
class MyApiEvent(Event):
...
Parameters¶
Name |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
REST/HTML URL pattern |
|
|
|
WebSocket resource name |
|
|
|
HTML template |
|
|
|
HTTP method for REST |
|
|
|
WS delivery behavior |
|
|
|
API types to exclude |
|
|
|
Allow event to leave the appliance |
excludeaccepts aset[ApiType]orlist[ApiType]. Lists are automatically converted to sets.
API types and exposure rules¶
@unique
class ApiType(IntEnum):
WS = 0
REST = auto()
HTML = auto()
GRPC = auto()
ApiType values:
WS — WebSocket (bidirectional, persistent connection)
REST — HTTP request/response (JSON serialization)
HTML — server-rendered templates (Jinja2) + static assets
GRPC — gRPC unary + shared bidi-streaming rpc, schema generated at runtime — see gRPC
Rules:
REST
requires
method;urldefaults to/{snake_case_class_name}if not provided
WebSocket
resourcedefaults to{kebab-case-class-name}if not providedWS-only events are allowed
HTML
requires
methodandtemplate;urldefaults to/{snake_case_class_name}if not providedWithout
method, both REST and HTML are automatically excluded (WS only)
GRPC
on by default, like REST/WS; excluded automatically for a file-only event, and for any event whose field types
edri.api.grpc.type_mappingcan't represent (logged as a warning at schema-generation time, not a startup failure)
If
methodis not provided and not set on the class, REST and HTML API types are excluded automatically. The event becomes WS-only. Ifurlorresourceare not provided, they are derived from the class name usingcamel2snake.
Excluding API types¶
@api(
url="/health",
method=HTTPMethod.GET,
exclude={ApiType.WS, ApiType.HTML},
)
class HealthCheck(Event):
response: EmptyResponse
Excluded API types are not generated and not routable through that API surface.
Routing and URL variables¶
Path placeholders¶
URL variables use {name} placeholders.
@api(url="/users/{user_id}", method=HTTPMethod.GET)
class UserDetail(Event):
user_id: int
response: UserDetailResponse
A placeholder must be declared as a dataclass field so its type is known.
Matching rules¶
URL is matched per segment
URL is decoded before matching
Supported placeholder types¶
EDRI can automatically generate matching regex for these types:
intstrfloatboolUUIDLiteral[...]Enum
Notes:
floatsupports decimal form (e.g.-12.34)boolmatchestrue | false | 1 | 0in URL path segmentsFor query-string and body parameters,
boolconversion accepts a set of case-insensitive tokens:true,1,yes,on→True;false,0,no,off→False. Any other value raises a conversion error (→ 400 Bad Request).
Note: Unlike Python's bare
bool()constructor (wherebool("0")isTrue), EDRI's converter maps the string"0"toFalse. The truthy/falsy token sets above are matched after lower-casing and stripping whitespace.
Custom types in URL¶
For custom datatypes used in the URL, you must supply a regex so the router can match the segment.
A common pattern is to pass a regex through inject(...) for the URL field:
@api(url="/items/{item_id}", method=HTTPMethod.GET)
class ItemDetail(Event):
item_id: inject(StringValidator, regex=re_identifier)
response: ItemDetailResponse
Optional path parameters¶
The last path parameter may be made optional by typing it as T | None. The route then matches both with and without that trailing segment:
@api(url="/users/{user_id}/{group_id}", method=HTTPMethod.GET)
class UserGroup(Event):
user_id: int
group_id: int | None = None
response: GroupResponse
# matches /users/42 -> group_id = None
# matches /users/42/12 -> group_id = 12
Rule:
Only the trailing path segment may be optional.
An optional parameter that is not the last segment — including a second optional parameter following the first — raises ValueError at registration time (Optional URL parameter {…} is supported only at the end of URL). In other words, at most one path segment (the last) can be optional; you cannot chain several optional segments.
Content negotiation (Accept header)¶
EDRI’s API layer uses the HTTP Accept header to decide which response format to produce.
Accept header¶
To explicitly request a JSON response, send:
Accept: application/json
If
Acceptrequests a different format, the API may choose a different renderer/representation (depending on the endpoint and enabled API types).If the event defines no HTML template, EDRI falls back to JSON whenever the client tolerates it — the
Acceptlist contains*/*(including a browser's default list) or there is no header. A template-less event fails with 406 Not Acceptable only when the request strictly demands HTML (Accept: text/htmlwith no*/*; see Content Negotiation).
Practical guidance¶
Setting
Accept: application/jsonis recommended for REST clients — it is explicit — but a template-less endpoint also answers a bareAccept: */*(curl's default) with JSON.Browser requests send a comma-separated
Acceptlist, which EDRI parses per media range. An endpoint with a template renders HTML; a template-less endpoint falls back to JSON because the browser's list carries a trailing*/*. (If the list containsapplication/json, the endpoint answers with JSON as a REST response.)
See Content Negotiation for the complete selection order.
Interaction with caching¶
Accept is part of the default Vary set:
Vary: Accept,Accept-Encoding
This ensures caches separate responses by representation (e.g. JSON vs HTML).
Input extraction (API-only)¶
The API layer populates event fields from these sources:
Headers
Cookies
Scope
Path parameters
Query parameters
Body (
application/json,application/x-www-form-urlencoded)
Priority order¶
Sources are applied in this order:
Query parameters (parsed first, camelCase → snake_case)
Path parameters (from URL tree matching)
Body parameters (JSON, form, or multipart)
Headers (only if the key is not already set by query/path/body)
Cookies (only if the key is not already set by query/path/body/headers)
Scope (always overwrites — applied last via
.update())
Key behavior: Headers and Cookies are additive — they only fill in missing values. Scope is the only source that unconditionally overwrites earlier values. If the same field name appears in query and body, the body value wins (query is set first, body overwrites).
See Validation & Injection for how the values are validated and coerced once extracted.
Validation and transformation¶
Validation and coercion are performed in the Listener process while the incoming request is parsed (before the event reaches the Broker and middleware).
Validators and transformers use inject(...)¶
All validators — built-in or custom — are applied via inject(...).
Built-in validators include:
StringValidatorIntegerValidatorFloatValidatorDateTimeValidatorTimeValidatorDateValidatorListValidator
Simple validation example¶
@api(url="/search", method=HTTPMethod.GET)
class Search(Event):
term: inject(StringValidator, minimum_length=3)
response: SearchResponse
Transformers (normalization)¶
Transformers behave like validators but primarily normalize values.
@api(url="/search", method=HTTPMethod.GET)
class NormalizedSearch(Event):
term: inject(StringTransformer, lower=True)
response: SearchResponse
Validation or transformation failures result in 400 Bad Request.
See Validation & Injection for details.
Responses, directives, and HTTP status codes¶
Response status (OK / FAILED) does not control HTTP status codes.
Rules:
Default HTTP status is 200
HTTP status is controlled by response directives
If multiple directives apply, last wins
Directives on non-HTTP transports¶
Directives are API-layer features.
It is valid to attach HTTP directives to WS events; they simply have no effect on the WebSocket transport.
See Response.
WebSocket events¶
WebSocket events use resource.
@api(resource="notifications", exclude={ApiType.REST, ApiType.HTML})
class Notifications(Event):
message: str
Subscription uses:
event: WebSocket resource name, matching@api(resource=...)param_set: field-value match map
Register/unregister are WS-only resources.
See WebSocket.
HTML events¶
HTML events require template and method; url is optional.
@api(
url="/login",
method=HTTPMethod.GET,
template="auth/login.html",
exclude={ApiType.WS},
)
class LoginPage(Event):
response: HtmlResponse
Complete example¶
@api(
url="/information/action/soc/{information}",
method=HTTPMethod.GET,
exclude={ApiType.HTML, ApiType.WS},
)
class InformationActionSOC(SessionEvent):
information: inject(ForeignKeyValidator[Information], regex=re_identifier)
response: EmptyResponse
Gotchas & Notes¶
excludeacceptsset[ApiType]orlist[ApiType]URL matching is per segment; regex conflicts cannot always be detected
Optional path parameters must be trailing
List query parameters must use
[]syntax (?ids[]=1&ids[]=2)Unsupported body formats result in 400
Default HTTP status is 200 unless directives override it