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

url

str | None

None

REST/HTML URL pattern

resource

str | None

None

WebSocket resource name

template

str | None

None

HTML template

method

HTTPMethod | None

None

HTTP method for REST

handling

EventHandlingType | None

None

WS delivery behavior

exclude

set[ApiType] | list[ApiType] | None

None

API types to exclude

shareable

bool

False

Allow event to leave the appliance

exclude accepts a set[ApiType] or list[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; url defaults to /{snake_case_class_name} if not provided

  • WebSocket

    • resource defaults to {kebab-case-class-name} if not provided

    • WS-only events are allowed

  • HTML

    • requires method and template; url defaults to /{snake_case_class_name} if not provided

    • Without 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_mapping can't represent (logged as a warning at schema-generation time, not a startup failure)

If method is not provided and not set on the class, REST and HTML API types are excluded automatically. The event becomes WS-only. If url or resource are not provided, they are derived from the class name using camel2snake.

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.

Shared URL/resource prefixes (PrefixBase)

To put a group of endpoints under a common prefix without repeating it on every event, define a base class that extends PrefixBase and implements url_prefix(), then have your events inherit it. The prefix is prepended to both the URL and the resource name (so it applies across REST, WebSocket, and HTML), regardless of whether url/resource were given explicitly or derived from the class name.

from edri.api.extensions.url_prefix import PrefixBase


class ApiV1(PrefixBase):
    @staticmethod
    def url_prefix() -> str:
        return "/api/v1"          # must start with "/"


@api(url="/users", method=HTTPMethod.GET)
class ListUsers(ApiV1, Event):
    response: UserListResponse
# -> URL:      /api/v1/users          (prefix + the given url)
# -> resource: api/v1-list-users      (prefix + the resource, here derived from the class name)
  • url_prefix() must start with /; the resource prefix is derived from it (leading slash stripped, - appended — e.g. /api/v1 → api/v1-).

  • The prefix is applied to whatever the URL and resource would otherwise be — an explicitly given url/resource, or the values derived from the class name.

  • Multiple PrefixBase ancestors compose — their prefixes are concatenated, so you can nest groups (e.g. an ApiV1 base and an Admin base).

  • PrefixBase is a plain marker/mixin, not a dataclass field source — it adds no fields and is removed from the event's bases when the @api event is built.


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:

  • int

  • str

  • float

  • bool

  • UUID

  • Literal[...]

  • Enum

Notes:

  • float supports decimal form (e.g. -12.34)

  • bool matches true | false | 1 | 0 in URL path segments

  • For query-string and body parameters, bool conversion 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 (where bool("0") is True), EDRI's converter maps the string "0" to False. 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 Accept requests 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 Accept list 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/html with no */*; see Content Negotiation).

Practical guidance

  • Setting Accept: application/json is recommended for REST clients — it is explicit — but a template-less endpoint also answers a bare Accept: */* (curl's default) with JSON.

  • Browser requests send a comma-separated Accept list, 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 contains application/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:

  1. Query parameters (parsed first, camelCase → snake_case)

  2. Path parameters (from URL tree matching)

  3. Body parameters (JSON, form, or multipart)

  4. Headers (only if the key is not already set by query/path/body)

  5. Cookies (only if the key is not already set by query/path/body/headers)

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


Headers, cookies, and scope

API-sourced fields are commonly declared as keyword-only fields with defaults that describe where the value comes from.

Headers are list-valued

HTTP headers may appear multiple times.

from dataclasses import dataclass, field

@dataclass
class ConditionalRequest(Event):
    etag: list[str] | None = field(
        default=Header(name="If-None-Match"),
        kw_only=True,
    )

Cookies

from dataclasses import field

session_id: str | None = field(
    default=Cookie("session"),
    kw_only=True,
)

Chunked cookies

When a cookie value exceeds the browser's ~4 KB limit, it is commonly split into numbered siblings — session-token.0, .1, … — and the browser sends each back as a separate cookie. EDRI reassembles these automatically before your event fields are populated: a contiguous family starting at .0 is concatenated, in index order, back into a single cookie under the base name.

So you only ever declare the base name and receive the full value, regardless of how many chunks the browser sent:

session_token: str | None = field(
    default=Cookie("session-token"),
    kw_only=True,
)

The reassembly is conservative: a non-chunked cookie of the same base name takes precedence, and a family with a missing .0 or an index gap is left as-is (the individual chunk cookies stay readable), so nothing is silently lost.

Scope

Scope("key") sources a field straight from the raw ASGI connection scope — the per-connection dictionary the ASGI server hands to the application. This is how you reach transport-level facts that are not headers, cookies, or body: the client address, the scheme (and thus TLS), the HTTP version, the raw path/query, etc. The key is matched case-insensitively against the scope dictionary.

Common scope keys (see the ASGI spec for the full list):

Key

Value

client

(host, port) pair of the peer — or None

scheme

"http" / "https" (or "ws" / "wss")

server

(host, port) the request was received on

http_version

"1.1", "2", …

path

the decoded request path

query_string

the raw query bytes

from typing import Any
from dataclasses import field


@api(url="/whoami", method=HTTPMethod.GET)
class WhoAmI(Event):
    # `client` is a (host, port) pair, not a scalar — annotate it `Any` so it is
    # passed through unchanged instead of being coerced to a primitive type.
    client: Any = field(default=Scope("client"), kw_only=True)
    # `scheme` is a plain string ("https" means the connection is TLS-terminated).
    scheme: str | None = field(default=Scope("scheme"), kw_only=True)
    response: WhoAmIResponse

In the handler, event.client[0] is the client IP and event.scheme == "https" tells you the request arrived over TLS.

Note: the value is taken from the scope as-is and then run through the field's type conversion. Annotate non-scalar scope values (like the client pair) as Any to skip coercion; scalar values such as scheme can use str.

Precedence: Scope is applied last and unconditionally overwrites any earlier value for the same field name (unlike headers/cookies, which only fill in missing values). See Priority order.


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:

  • StringValidator

  • IntegerValidator

  • FloatValidator

  • DateTimeValidator

  • TimeValidator

  • DateValidator

  • ListValidator

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

  • exclude accepts set[ApiType] or list[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