Skip to content

Content negotiation

?f= and Accept say what the client wants; something has to pick a representation and link the alternates. Pure resolution — gazebo takes no position on HTML or templating.

OGC clients live on ?f=json|html, with the HTTP Accept header as the fallback. Every multi-format endpoint therefore needs the same two decisions made correctly: which representation to serve, and how to advertise the others. gazebo.negotiation is exactly that resolution logic — given the representations a resource offers, it picks one and builds the alternate links to the rest.

It deliberately ships no HTML renderer. Turning the chosen representation into bytes — a template, a callable — is the app's job; gazebo only tells you which representation won, and links the others.

Resolving a representation

A Representation pairs a ?f= key with a media type (JSON, GEOJSON, HTML are ready-made). negotiate() applies the OGC order — ?f= wins, then Accept, then the first offered (or an explicit default):

from gazebo.negotiation import HTML, JSON, negotiate

# ?f= wins; otherwise the Accept header; otherwise the first offered representation.
assert negotiate([JSON, HTML], f='html') is HTML
assert negotiate([JSON, HTML], accept='text/html;q=0.9, application/json;q=0.1') is HTML
assert negotiate([JSON, HTML]) is JSON

A ?f= naming a format that isn't offered is a client error (ParamError400); an Accept that lists nothing on offer is a 406 (ProblemException). Both already render as problem+json through the FastAPI glue, so a failed negotiation needs no extra handler.

In a route

The glue's Negotiate([...]) dependency resolves the representation from the request (?f= query + Accept header). The endpoint branches on it — render HTML or return the model — and attaches alternate_links() so each representation advertises the others. Inject Response semantics by returning an HTMLResponse for the HTML branch while keeping a response_model for the JSON one:

from gazebo import Link, OmitNullModel
from gazebo.ext.fastapi import GazeboApp, Negotiate, Providers
from gazebo.negotiation import HTML, JSON, Representation, alternate_links

app = GazeboApp(Providers())


class Doc(OmitNullModel):
    id: str
    links: list[Link] = Field(default_factory=list)


@app.get('/collections/{cid}', response_model=Doc)
async def collection(
    cid: str,
    rep: Annotated[Representation, Negotiate([JSON, HTML])],
) -> Doc | Response:
    # self for the current representation, alternate links to the others
    links = [Link.self_link(type=rep.media_type), *alternate_links(rep, [JSON, HTML])]
    if rep.key == 'html':
        return HTMLResponse(f'<h1>{cid}</h1>')
    return Doc(id=cid, links=links)

alternate_links(current, available) returns one deferred alternate link per other representation, each pointing at the current URL with ?f= switched — so a client on the JSON view can discover and follow the HTML one. Pair it with a normal self link for the current representation.

Documenting every negotiated media type

A negotiated route serves several media types, but FastAPI's OpenAPI only documents the response_model's application/json — the CSV or HTML branch goes unmentioned. The representation list already single-sources negotiation and the alternate links, so it single-sources the docs too: a route carrying a Negotiate([...]) dependency has its extra media types folded into the operation's responses automatically, with application/json left to the response_model (its $ref is preserved):

from gazebo.ext.fastapi import GazeboApp, Negotiate, Providers
from gazebo.negotiation import CSV, JSON, Representation

docs_app = GazeboApp(Providers())


@docs_app.get('/beds', response_model=Doc)
async def beds(rep: Annotated[Representation, Negotiate([JSON, CSV])]) -> Doc | Response:
    if rep.key == 'csv':
        return Response('id\n1\n', media_type='text/csv')
    return Doc(id='beds')

No wiring is needed — the fold happens at route registration on a GazeboApp/GazeboRouter. The extra media types default to a string body schema. When you want a richer schema (or to document a route without a Negotiate dependency), reach for the escape hatch: openapi_responses() builds the same content map for you to pass as responses=, and a {MediaType.JSON: None} entry keeps JSON owned by the model:

from gazebo.negotiation import openapi_responses
from gazebo.rels import MediaType

# The escape hatch: build the content map yourself and pass it as `responses=`. Keep
# application/json owned by the response_model with `{MediaType.JSON: None}`, and give a
# richer schema to another media type than the default string body.
responses = openapi_responses(
    [JSON, CSV],
    schemas={MediaType.JSON: None, MediaType.CSV: {'type': 'string', 'format': 'csv'}},
)

A responses= you pass yourself is never clobbered — the auto-fold only adds media types the route does not already document.

Folded into your own query model

When a route already takes a Pydantic query model, you can fold ?f= into it as a field rather than adding a separate dependency. The supported formats are a closed set you own, so — as with crs — gazebo gives you a base enum to subclass: FormatEnum, a StrEnum whose members are (?f= key, media type) pairs. It is a real class, so it drops onto your model as an ordinary field type (no type: ignore), pydantic validates the key natively, and FastAPI renders it as an enum query param whose OpenAPI description names your subclass's actual ?f= keys (not a stock example):

from pydantic import BaseModel

from gazebo.ext.fastapi import FormatEnum, GazeboApp, Providers


class DocFormat(FormatEnum):  # your closed ?f= set — a real class, a usable field type
    json = 'json', 'application/json'  # each member is (?f= key, media type)
    html = 'html', 'text/html'


class DocQuery(BaseModel):  # fold ?f= into your own query model as a field
    f: DocFormat = DocFormat.json  # a real enum field: no type: ignore, native validation


folded_app = GazeboApp(Providers())


@folded_app.get('/report')
async def report(query: Annotated[DocQuery, Query()]) -> dict:
    # query.f is a validated ?f= key (no Accept at model-validation time). The member
    # carries its media type — `.representation` needs no external {key: rep} map; an
    # absent ?f= falls back to the field default (json).
    return {'format': query.f.representation.key}

Give the field a default so an absent ?f= resolves to it. Because each member carries its media type, a member alone yields its Representation via .representation — no external {key: rep} dict — so drive rendering and alternate_links straight off it. An unknown ?f= is a 400 problem. A plain-default field like this negotiates on the query key alone; the next section adds Accept.

Getting Accept-aware negotiation

A plain-default FormatEnum field negotiates on the query key alone. For the full OGC order — ?f=Accept → default — make the field optional (f: MyFormat | None = None) and add a one-line negotiate(MyFormat.representations(), f=query.f) in the handler:

from gazebo.ext.fastapi import FormatEnum, GazeboApp, Providers
from gazebo.negotiation import negotiate


class ReportFormat(FormatEnum):
    json = 'json', 'application/json'
    html = 'html', 'text/html'


class ReportQuery(BaseModel):
    # Optional field: an absent ?f= leaves it None, so the handler can negotiate on Accept.
    f: ReportFormat | None = None


neg_app = GazeboApp(Providers())


@neg_app.get('/report')
async def negotiated_report(query: Annotated[ReportQuery, Query()]) -> dict:
    # One line for the full OGC order: ?f= wins, then the request's Accept (read ambiently
    # from the context — no header wrangling), then the default. Member order is
    # server-preferred; an unsatisfiable Accept raises a 406, an unknown ?f= a 400.
    rep = negotiate(
        ReportFormat.representations(),
        f=query.f,
        default=ReportFormat.json.representation,
    )
    return {'format': rep.key}

negotiate() applies the order for you: an explicit ?f= wins; otherwise the request's Accept — read ambiently from the active context, so you never pull the header yourself — with the enum's members (definition order is server-preferred) scored by their media types; otherwise the default. An unknown ?f= is a 400 problem (the enum field validates the key), and an Accept that lists nothing on offer is a 406.

Reach for the Negotiate dependency when negotiation is the route's primary input; reach for a FormatEnum field — plain-default for key-only, or optional + a one-line negotiate() call for the full Accept-aware order — when ?f= is one field among several in a query model you already have.

Reference

See gazebo.negotiation (Representation, negotiate, alternate_links, openapi_responses) and the glue's Negotiate dependency.