Query parameters¶
bbox,datetime, andcrslook like simple query params and are anything but. Typed parsers that get the edge cases right — and turn bad input into a400problem instead of a wrong answer.
OGC APIs share a small set of standardized query parameters, and parsing them
correctly (RFC 3339 intervals, antimeridian-crossing bounding boxes, CRS
allow-lists) is the most-reimplemented, easiest-to-get-subtly-wrong slice of an
OGC service. gazebo.params is the framework-agnostic core: pydantic models with
parse classmethods that raise ParamError
on malformed input. The FastAPI adapters wire them into a route and
render that error as application/problem+json with a 400 status — the OGC
convention for a bad query parameter (distinct from request-body validation,
which is a 422).
Parsing directly¶
Each model parses a raw string. BBox accepts the 4-coordinate 2D form or the
6-coordinate 3D form, and allows minx > maxx to denote a box crossing the
antimeridian. DatetimeInterval accepts an RFC 3339 instant or a start/end
interval, where either side may be open (.. or empty):
from gazebo.params import BBox, DatetimeInterval
box = BBox.parse('-10,-20,10,20') # minx,miny,maxx,maxy (6 values for 3D)
interval = DatetimeInterval.parse('2020-01-01T00:00:00Z/..') # open-ended
assert interval.contains(datetime(2025, 1, 1, tzinfo=UTC))
DatetimeInterval.contains() answers whether a timestamp falls within the
(possibly half-open) interval; an instant is represented as start == end.
In a route¶
The glue ships ready-made adapters — BBoxParam, DatetimeParam, and the
CrsParam(allowed=[...]) factory — that drop into a route signature as Annotated
metadata. A malformed value short-circuits to a 400 problem before your handler
runs; a valid one arrives already typed:
from typing import Annotated
from gazebo.ext.fastapi import BBoxParam, CrsParam, DatetimeParam, GazeboApp, Providers
from gazebo.params import CRS84, BBox, DatetimeInterval
app = GazeboApp(Providers())
@app.get('/items')
async def items(
bbox: Annotated[BBox | None, BBoxParam] = None,
datetime: Annotated[DatetimeInterval | None, DatetimeParam] = None,
crs: Annotated[str, CrsParam(allowed=[CRS84])] = CRS84,
) -> dict:
# bbox/datetime are already parsed-and-validated (or None); crs is allow-listed.
return {'count': 0, 'crs': crs}
# A malformed value never reaches the body: it becomes a 400 problem+json.
CrsParam validates the supplied CRS URI against the allow-list (a value outside it
is a 400). Pass name='bbox-crs' for the companion parameter. When the parameter is
absent, what it resolves to depends on what a default can reasonably be:
- an explicit
default=(which must itself be inallowed), if you pass one; else CRS84(the OGC default output CRS: WGS 84, lon/lat) if it is inallowed; else- nothing — with a non-default allow-list and no marked default there is no safe
assumption, so
crsbecomes required and an absent value is a 400.
In other words: as soon as you offer CRSs that don't include CRS84, you must either
mark one as the default or require the caller to choose.
Validating a CRS is not reprojecting it
CrsParam only checks the requested CRS is in your allow-list; it does not
transform coordinates. If you add a second CRS to allowed=[...], your handler
must actually reproject its output (and the bbox input) into that CRS —
otherwise you will accept the request and return coordinates in the wrong
reference system. Only advertise a CRS you genuinely serve.
BBox is deliberately CRS-agnostic: it validates the coordinate count and that
miny <= maxy (and minz <= maxz), and it allows minx > maxx (a box crossing the
antimeridian). It does not range-check latitude/longitude, since the axis meanings
depend on the CRS.
Because the box owns the antimeridian-wrap rule, it also answers the containment
question so consumers don't re-derive it: BBox.contains(lon, lat) returns whether a
point falls within the box, handling the wrapped case. The garden example filters its
beds with it.
Both the Depends adapters and the field types below carry a description and
openapi_examples (and a closed enum for crs), so the generated OpenAPI
documents each parameter fully rather than exposing a bare, undocumented string.
Folded into your own query model¶
When you already have a Pydantic model for a route's query string, you can fold the
OGC parameters into it as fields rather than adding separate Depends. BBoxQuery
and DatetimeQuery are annotated field types (pydantic BeforeValidator + documented
Field) for their open value spaces; drop them onto your model and use it as
Annotated[MyQuery, Query()], and FastAPI explodes it into individual, documented query
parameters — each parsed by the same core parser. Although each parses into a model
(BBox, DatetimeInterval), the field advertises a string input schema, so Swagger UI
renders a plain text box (the value a client actually sends) rather than a nested object
editor.
The crs parameter is different: its allowed values are a closed set, and that set is
a decision only you can make. So instead of a helper, gazebo gives you a base enum to
subclass — CrsEnum, a StrEnum whose members
are the CRS URIs you support. Because it is a real class, it drops onto your model as an
ordinary field type — no type: ignore — and pydantic validates membership natively:
from typing import Annotated
from fastapi import Query
from pydantic import BaseModel
from gazebo.ext.fastapi import BBoxQuery, CrsEnum, DatetimeQuery, GazeboApp, Providers
from gazebo.params import CRS84, BBox, DatetimeInterval
class BedCrs(CrsEnum): # your closed CRS set — a real class, so it's a usable field type
CRS84 = CRS84
WEB_MERCATOR = 'http://www.opengis.net/def/crs/EPSG/0/3857'
class BedQuery(BaseModel): # your own query model — fold OGC fields in as fields
bbox: BBoxQuery = None
datetime: DatetimeQuery = None
crs: BedCrs = BedCrs.CRS84 # a real enum field: no type: ignore, native validation
limit: int = 10
folded_app = GazeboApp(Providers())
@folded_app.get('/beds')
async def beds(query: Annotated[BedQuery, Query()]) -> dict:
# FastAPI explodes the model into individual, documented query params; each OGC
# field arrives already parsed (bbox: BBox | None, datetime: DatetimeInterval | None).
assert query.bbox is None or isinstance(query.bbox, BBox)
return {'crs': query.crs, 'limit': query.limit}
# A malformed folded field is still a 400 problem+json — OGC's client-error semantics.
Give the field a default (typically CRS84) so an absent value resolves to it. Members
are real strings (the URIs), so the value flows downstream as a URI unchanged. FastAPI
renders the field as an enum query param, and the base injects the shared crs
description into the generated OpenAPI, so a folded field self-documents without your
adding a Field(description=...).
A malformed folded field — a bad bbox/datetime, or a crs outside the enum — fails
Pydantic validation, which FastAPI raises as a RequestValidationError. Under a
GazeboApp that renders as a 400 application/problem+json (a query error is an OGC
client error), citing the offending parameter — the same shape a Depends ParamError
produces, so folding a field does not change the error contract. (A bad request body
stays a 422; see the app handlers.)
Reference¶
See gazebo.params and the adapters in
gazebo.ext.fastapi.