Content negotiation¶
?f=andAcceptsay 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
(ParamError → 400); 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.