# Content Negotiation EDRI's API layer uses the HTTP `Accept` header to decide which response format to produce, and serves as the bridge between the three API types (REST, WebSocket, HTML). --- ## How the `Accept` header is used The Listener resolves the URL to its event **first**, then runs a single content-negotiation step: the event declares which representations it can produce, and the `Accept` header chooses among them. The event's **offers** are: * `application/json` — unless the event excludes REST (`exclude={ApiType.REST}`). * `text/html` — only when the event both allows HTML and defines a `template` (or the response is file-only). The `Accept` header then selects the best offer using proper `q`-value negotiation (quality, specificity and order are all honoured — not just membership): | Request | Result | API type | |-------------------------------------------------|--------------------------------------------------|----------| | `Accept` selects `text/html` (and a template exists) | Jinja2 template engine | HTML | | `Accept` selects `application/json`, or `X-Requested-With: XMLHttpRequest` | JSON serializer | REST | | no `Accept` header | the event's first offer (HTML when a template exists, else JSON) | REST/HTML | | `Accept` matches **none** of the event's offers | **406 Not Acceptable** | — | | *(WebSocket upgrade)* | WebSocket frame encoder | WS | > For WebSocket connections the `Accept` header is irrelevant — the WS upgrade handshake determines the transport. ### How REST vs HTML is selected For a resolved event (`Listener.App._negotiate`): 1. Build the event's offers from its capabilities — `application/json` unless REST is excluded, `text/html` only when HTML is allowed **and** a template is defined. 2. `X-Requested-With: XMLHttpRequest` selects **JSON** whenever JSON is on offer (an AJAX call wants data, not a page). 3. Otherwise the `Accept` header picks the best offer by `q`-value (`Accept.best_match`). An **absent** `Accept` header takes the event's first offer. 4. If the `Accept` header is present but matches none of the offers, the request gets **406 Not Acceptable**. This is a single decision driven by the `Accept` header and the event's template/exclusions — there is no separate "pick a handler, then maybe swap it" step. > Pre-event responses are simpler: `404`/`405` errors and static-asset lookups pick their format from a lightweight check (`X-Requested-With` or an explicit `Accept: application/json`), so a browser hitting an unknown URL still gets an HTML error page rather than JSON. ### REST clients Sending `Accept: application/json` is recommended — it is explicit and unambiguous. A REST-only endpoint (`exclude={ApiType.HTML}`, hence no template) offers only JSON, so `*/*` (curl's default) or a missing `Accept` header also yields JSON: ```bash curl -H "Accept: application/json" http://localhost:8080/items # explicit curl http://localhost:8080/items # no Accept — first offer (JSON) ``` A client that demands a representation the endpoint cannot produce — e.g. `Accept: text/html` (no `*/*`) against a JSON-only endpoint — receives **406 Not Acceptable**. ### Browser requests Browsers typically send a comma-separated list: ``` Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 ``` `q`-values are honoured per media range: * For an endpoint **with** a template, `text/html` (`q=1.0`) outranks the trailing `*/*` (`q=0.8`), so EDRI serves the rendered HTML. * For a **template-less** endpoint, the only offer is `application/json`; the trailing `*/*` matches it, so EDRI answers with **JSON**. --- ## Endpoints exposed to multiple API types A single event can be exposed via REST, HTML, and WS simultaneously by omitting `exclude`: ```python @api(url="/dashboard", method=HTTPMethod.GET, template="dashboard.html") class Dashboard(Event): response: DashboardResponse ``` * A browser GETting `/dashboard` with `Accept: text/html` receives the rendered template. * A REST client GETting `/dashboard` with `Accept: application/json` receives JSON. * A WS client can subscribe using the resource name derived from the class name. To restrict to a single type, use `exclude`: ```python @api(url="/api/data", method=HTTPMethod.GET, exclude={ApiType.HTML, ApiType.WS}) class DataEndpoint(Event): response: DataResponse ``` --- ## JSON response format ### Wrapped (default) With `API_RESPONSE_WRAPPED=True` (the default), JSON responses are wrapped in a `data` envelope: ```json { "data": { "id": 1, "name": "widget" }, "status": "OK" } ``` ### Unwrapped With `API_RESPONSE_WRAPPED=False`, all response fields appear at the top level alongside `status`: ```json { "id": 1, "name": "widget", "status": "OK" } ``` Field names are camelCase-transformed in both modes (e.g. `total_count` → `totalCount`). Configure via the environment variable `EDRI_API_RESPONSE_WRAPPED`: ```bash export EDRI_API_RESPONSE_WRAPPED=false ``` --- ## `Vary` header and caching `Vary` is set only when HTTP caching is active on a handler — i.e., when `@request(cache="key")` is used. Without that decorator, no `Vary` header is added. When caching is active, EDRI adds: ``` Vary: Accept,Accept-Encoding ``` This ensures HTTP caches store separate entries for `Accept: application/json` vs `Accept: text/html` responses from the same URL. Additional headers can be appended via `EDRI_API_CACHE_HEADERS`: ```bash export EDRI_API_CACHE_HEADERS=Authorization,X-Tenant ``` This results in: ``` Vary: Accept,Accept-Encoding,Authorization,X-Tenant ``` > **Note:** `EDRI_API_CACHE_HEADERS` defaults to the value of `EDRI_CORS_HEADERS` (not an empty string). If you have `EDRI_CORS_HEADERS` set, those headers are automatically included in `Vary` even without setting `EDRI_API_CACHE_HEADERS` explicitly. --- ## Interaction with ETag caching When `@request(cache="key")` is used, EDRI generates ETags in the format: ``` - ``` The timestamp is the last-change time as integer Unix seconds immediately followed by six digits of microseconds, so two changes within the same second still produce distinct ETags. The client sends: ``` If-None-Match: "information-1718444762000123" ``` If the ETag matches, EDRI responds with **304 Not Modified** and no body. The ETag and Cache-Control headers are set together. `Cache-Control` is controlled by `EDRI_API_CACHE_CONTROL`. --- ## See also * {doc}`/api_event` — `@api(...)` decorator, excluding API types * {doc}`/api` — API overview (REST, WS, HTML) * {doc}`/response` — directives, HTTP status * {doc}`/manager` — HTTP caching via `@request(cache=...)` * {doc}`/env` — `EDRI_API_RESPONSE_WRAPPED`, `EDRI_API_CACHE_HEADERS`, `EDRI_API_CACHE_CONTROL`