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 atemplate(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 |
|---|---|---|
|
Jinja2 template engine |
HTML |
|
JSON serializer |
REST |
no |
the event's first offer (HTML when a template exists, else JSON) |
REST/HTML |
|
406 Not Acceptable |
— |
(WebSocket upgrade) |
WebSocket frame encoder |
WS |
For WebSocket connections the
Acceptheader is irrelevant — the WS upgrade handshake determines the transport.
How REST vs HTML is selected¶
For a resolved event (Listener.App._negotiate):
Build the event's offers from its capabilities —
application/jsonunless REST is excluded,text/htmlonly when HTML is allowed and a template is defined.X-Requested-With: XMLHttpRequestselects JSON whenever JSON is on offer (an AJAX call wants data, not a page).Otherwise the
Acceptheader picks the best offer byq-value (Accept.best_match). An absentAcceptheader takes the event's first offer.If the
Acceptheader 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/405errors and static-asset lookups pick their format from a lightweight check (X-Requested-Withor an explicitAccept: 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:
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:
@api(url="/dashboard", method=HTTPMethod.GET, template="dashboard.html")
class Dashboard(Event):
response: DashboardResponse
A browser GETting
/dashboardwithAccept: text/htmlreceives the rendered template.A REST client GETting
/dashboardwithAccept: application/jsonreceives JSON.A WS client can subscribe using the resource name derived from the class name.
To restrict to a single type, use exclude:
@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:
{
"data": {
"id": 1,
"name": "widget"
},
"status": "OK"
}
Unwrapped¶
With API_RESPONSE_WRAPPED=False, all response fields appear at the top level alongside status:
{
"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:
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:
export EDRI_API_CACHE_HEADERS=Authorization,X-Tenant
This results in:
Vary: Accept,Accept-Encoding,Authorization,X-Tenant
Note:
EDRI_API_CACHE_HEADERSdefaults to the value ofEDRI_CORS_HEADERS(not an empty string). If you haveEDRI_CORS_HEADERSset, those headers are automatically included inVaryeven without settingEDRI_API_CACHE_HEADERSexplicitly.
Interaction with ETag caching¶
When @request(cache="key") is used, EDRI generates ETags in the format:
<cache-key>-<last-change-timestamp>
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¶
API Event —
@api(...)decorator, excluding API typesAPI — API overview (REST, WS, HTML)
Response — directives, HTTP status
Manager — HTTP caching via
@request(cache=...)Environment Variables —
EDRI_API_RESPONSE_WRAPPED,EDRI_API_CACHE_HEADERS,EDRI_API_CACHE_CONTROL