One resource can have an HTML view for people and a JSON representation for programs. Sharing a URL is useful only when the origin and its intermediaries preserve that distinction. The endpoint must select an available format, serialize it honestly, describe its cache variation, and validate the selected representation rather than some other version of the same resource.

Select before you serialize and validate

For a GET response, the request’s Accept field expresses desired response media types. The request’s Content-Type instead describes request content; setting it to JSON does not ask the server to return JSON. Use the framework’s media-range parser for quality and wildcard handling. Define an explicit default for an absent Accept field, and separately define what happens when a supplied field matches nothing.

This example defaults to HTML only when Accept is absent. A supplied preference with no acceptable offer produces 406 and lists the available types. That is a deliberate endpoint policy: HTTP also allows disregarding the preference. Do not substitute 415, which concerns unsupported request content. Distinguishing these outcomes makes an API client diagnose the correct direction of the contract.

After selection, generate Content-Type from the serializer actually used. HTML data needs appropriate escaping; JSON needs JSON serialization, not string interpolation into an HTML template. Set Vary: Accept on the route’s representations, including its default. Vary does not permit storage or protect private data; the public cache directive below is appropriate only because the fixture contains the same harmless status record for every caller.

When it breaks

Testing only the origin can miss a cache serving yesterday’s HTML to today’s JSON request. Check the metadata and parsed body through the delivery path, with both request orders. A correct Vary field on new responses does not retroactively fix an already cached response whose selection metadata was missing. Use a controlled test URL or an approved invalidation procedure rather than indiscriminately flushing a shared cache.

A subtler bug uses the database row’s revision as the same strong ETag for both formats. An ETag identifies the selected representation; distinct HTML and JSON bytes cannot share that strong validator merely because they describe the same record. If conditional processing runs before format selection, the server can incorrectly return 304 for a representation the client does not hold. Select, serialize, assign the representation’s validator, then evaluate the condition.

Check it quickly

Use Python 3.11 or newer. Create the temporary environment below, then run the Python block with "$media_lab/venv/bin/python". It starts a loopback-only Werkzeug development server on an automatically allocated port and closes it afterward. Installation needs network access; the six test requests do not. This is a local protocol fixture, not a production WSGI deployment or a shared-cache test.

media_lab=$(mktemp -d)
python3 -m venv "$media_lab/venv"
"$media_lab/venv/bin/python" -m pip install Werkzeug==3.1.8
import hashlib
import html
import json
from http.client import HTTPConnection
from threading import Thread

from werkzeug.serving import make_server
from werkzeug.wrappers import Request, Response

record = {"message": "ready"}
variants = {
    "text/html": f"<!doctype html><p>{html.escape(record['message'])}</p>\n".encode(),
    "application/json": json.dumps(record).encode("utf-8"),
}


@Request.application
def application(request):
    if request.path != "/status":
        return Response("not found\n", status=404)
    if request.method != "GET":
        return Response("GET only\n", status=405, headers={"Allow": "GET"})
    media = "text/html" if "Accept" not in request.headers else (
        request.accept_mimetypes.best_match(variants)
    )
    if media is None:
        response = Response("available: text/html, application/json\n", status=406)
        response.headers["Cache-Control"] = "no-store"
    else:
        body = variants[media]
        response = Response(body, mimetype=media)
        response.set_etag(hashlib.sha256(body).hexdigest())
        response.headers["Cache-Control"] = "public, max-age=60"
        response.make_conditional(request)
    response.vary.add("Accept")
    return response


server = make_server("127.0.0.1", 0, application)
worker = Thread(target=server.serve_forever)
worker.start()


def get(accept=None, etag=None):
    headers = {}
    if accept is not None:
        headers["Accept"] = accept
    if etag is not None:
        headers["If-None-Match"] = etag
    connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
    try:
        connection.request("GET", "/status", headers=headers)
        response = connection.getresponse()
        body = response.read()
        assert response.headers["Vary"] == "Accept"
        print(accept, "->", response.status, "bytes=", len(body))
        return response.status, response.headers, body
    finally:
        connection.close()


try:
    status, html_headers, body = get()
    assert status == 200 and body == variants["text/html"]
    assert html_headers.get_content_type() == "text/html"
    status, json_headers, body = get("application/json")
    assert status == 200 and json.loads(body) == record
    assert json_headers.get_content_type() == "application/json"
    assert html_headers["ETag"] != json_headers["ETag"]
    status, _, body = get("application/json", json_headers["ETag"])
    assert status == 304 and body == b""
    status, _, body = get("text/html", json_headers["ETag"])
    assert status == 200 and body == variants["text/html"]
    for accept in ["application/xml", "*/*;q=0"]:
        status, headers, _ = get(accept)
        assert status == 406 and headers["Cache-Control"] == "no-store"
finally:
    server.shutdown()
    server.server_close()
    worker.join()

Expected statuses are 200, 200, 304, 200, 406, and 406. The first two bodies contain the same status information in different formats, and their strong ETags differ. The third request revalidates JSON and receives no body. The fourth carries that JSON tag while requesting HTML, so it must receive HTML with 200. This is the critical negative check that a success-only format test would miss.

The final requests ask for unavailable XML and explicitly exclude every media type. Both exercise the chosen rejection policy, while no-store keeps those fixture errors out of caches. Vary is asserted on every tested response, including 304. Werkzeug supplies parsing and conditional-response handling; the example does not hand-roll header grammar or entity-tag comparisons.

Before adopting this design, ensure both serializers are part of the same resource contract and can be tested together. Distinct URLs may be operationally simpler when clients, access rules, or release schedules differ substantially. A single URL is not a reason to merge unrelated authorization rules. Whichever addressing scheme you choose, keep body verification and cross-representation validator tests in the integration suite.

Sources