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:

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 /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:

@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_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:

<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 types

  • API — 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