InteractiveFrameworks

Content negotiation

One resource, several representations. Answer the list as JSON or CSV depending on what the client's Accept header prefers, refuse what you cannot produce or read with 406 and 415, and tell caches the answer depends on the header.

What you'll learn

  • Tell a resource from its representations, and pick one from the Accept header, weighing q-values
  • Refuse with 406 when nothing acceptable can be produced and 415 when a request body cannot be read
  • Say Vary: Accept on a negotiated answer, so a cache does not hand one client's format to another

The URL /cats names the list of cats, not a JSON document. JSON is one way to write that list down; a spreadsheet user would rather have CSV, and a browser would rather have HTML. HTTP separates the two ideas: a resource is the thing a URL names, and a representation is one form of it on the wire. The client says which forms it can read, the server picks one, and the same URL serves them all. This lesson builds that choice, and the two refusals that go with it.

The client says what it can read

The Accept header lists media types, most wanted first, optionally weighted by a q-value from 0 to 1 (1 when absent):

Accept: text/csv;q=0.5, application/json

This client can read CSV, but prefers JSON, because application/json carries the default weight of 1 and CSV only 0.5. Order in the header breaks ties; weight decides otherwise. Wildcards exist too: */* means anything, text/* any text type. A request without Accept means the client takes whatever the server's default is.

Picking a representation, then, is: read the list, sort by weight, and take the first type you can produce. Here is the parsing half, for a header that is not Accept but works the same way, Accept-Language:

// "fr;q=0.8, en" → [{ tag: 'en', q: 1 }, { tag: 'fr', q: 0.8 }]
const languages = (header: string) =>
  header
    .split(',')
    .map((part) => {
      const [tag, ...params] = part.split(';').map((s) => s.trim());
      const q = params.find((p) => p.startsWith('q='));
      return { tag, q: q ? Number(q.slice(2)) : 1 };
    })
    .sort((a, b) => b.q - a.q);

When nothing fits: 406

If the client accepts nothing the server can produce, the honest answer is 406 Not Acceptable, with a body saying what is available. (A server may instead ignore Accept and send its default anyway; HTTP allows both. Refusing is kinder to a client that would otherwise try to parse the wrong format.)

Vary: telling caches

Between the client and your server there may be caches: the browser's own, a company proxy, a CDN. A cache stores a response under its URL. If the first client asked /cats for CSV and the cache stored that, the next client asking /cats for JSON would get CSV. The Vary header prevents it:

Vary: Accept

It tells every cache that the answer depends on the request's Accept header, so responses are stored per value of it. Every negotiated answer carries it, refusals included.

The other direction: Content-Type and 415

A request body has a representation too, declared by its Content-Type. A server that only reads JSON checks the label before reading and answers 415 Unsupported Media Type otherwise. The label is what counts, not whether the bytes happen to parse: a body labelled text/plain is text, even when it looks like JSON, and a server that sniffs content instead of trusting the label invites confusion and security bugs.

Media types may carry parameters after a semicolon, most often a charset: application/json; charset=utf-8 is still JSON. Compare the part before the semicolon.

Writing CSV

CSV is a header line naming the columns, then one line per row, values separated by commas. Its media type is text/csv. In Hono, c.body(text, 200, { 'content-type': 'text/csv; charset=utf-8' }) sends any text with the type you choose. Real CSV quotes a value that contains a comma or a quote ("Tom, the elder"); this lesson's names have neither, so plain joining is enough.

Your task

  1. GET /cats answers in the representation the Accept header prefers, weighing q-values: JSON for application/json, for */* or when there is no Accept; CSV for text/csv, as the line id,name,age then one line per cat, every line ending in a newline, with content-type: text/csv; charset=utf-8.
  2. When the client accepts neither, it answers 406 with { "error": "This resource is available as application/json or text/csv" }.
  3. Every one of those answers carries Vary: Accept.
  4. POST /cats answers 415 with { "error": "Send the cat as application/json" } unless the body's content-type is application/json, with or without parameters.

When it fails

  • "Weighed preferences" answers CSV: the first type in the header won. Sort by q first.
  • "First supported choice" answers 406: the loop gave up at the first unsupported type. Skip it and try the next.
  • The CSV body is missing its last newline, or has a space after the commas: the grader compares the text exactly.
  • "JSON with a charset" answers 415: the whole header was compared. Split at ; and trim.

Remember

  • A URL names a resource; JSON and CSV are representations of it.
  • Accept lists what the client reads, weighted by q; pick the first you can produce, or answer 406.
  • Vary: Accept on every negotiated answer, so caches keep the representations apart.
  • Content-Type labels a request body; refuse what you cannot read with 415, and trust the label.
Stuck? Show a hint

Split the Accept header on commas; each part is a type and optional parameters after semicolons, one of which may be q=<number> (1 when absent). Sort by q, highest first, and take the first type you support; */* means your default. For the content-type check, compare only what comes before a semicolon. c.body(text, 200, { 'content-type': '…' }) answers with a type of your choosing.