# 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 {doc}`/event`. --- ## Exposing an event via `@api` Use the `@api(...)` decorator to expose an event through REST, WebSocket (WS), and/or HTML. ```python @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 ```python @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 {doc}`/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 ```python @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)= ### 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. ```python 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. ```python @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: ```python @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: ```python @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 {doc}`/api_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 {doc}`/api_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)= ## 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 {doc}`/validation` 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. ```python from dataclasses import dataclass, field @dataclass class ConditionalRequest(Event): etag: list[str] | None = field( default=Header(name="If-None-Match"), kw_only=True, ) ``` ### Cookies ```python 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: ```python 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](https://asgi.readthedocs.io/en/latest/specs/www.html) 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 | ```python 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](#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 ```python @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. ```python @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 {doc}`/validation` for details. --- (responses-directives-and-http-status-codes)= ## 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 {doc}`/response`. --- ## WebSocket events WebSocket events use `resource`. ```python @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 {doc}`/websocket`. --- ## HTML events HTML events require `template` and `method`; `url` is optional. ```python @api( url="/login", method=HTTPMethod.GET, template="auth/login.html", exclude={ApiType.WS}, ) class LoginPage(Event): response: HtmlResponse ``` --- ## Complete example ```python @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