Skip to content

Collections

LinkedCollection[T] — the OGC collection envelope: a list of items plus hypermedia links and counts.

The envelope

LinkedCollection[T] is the standard OGC collection wrapper: a Sequence[T] of items plus a links list and counts. numberReturned is computed from the items; numberMatched (the total across all pages) is optional and dropped when unset. Because the links are deferred, the whole envelope — items, links, and all — is built in business logic with no request in hand.

from gazebo.collection import LinkedCollection
from gazebo.link import Link


class FeatureCollection(LinkedCollection[dict], items_alias='features'):
    pass


collection = FeatureCollection(
    items=[{'id': 1}, {'id': 2}],
    links=[Link.self_link()],
    number_matched=42,
)

Naming the items field

OGC specs name the items array differently per resource type — features in Features, records in Records. Subclass and set the serialization alias once with the items_alias class keyword; the field stays items in Python but serializes under your name:

class FeatureCollection(LinkedCollection[Feature], items_alias='features'):
    pass

Both class keywords survive generic parametrization (FeatureCollection[P]).

Omitting numberReturned

numberReturned is emitted by default, but some OGC envelopes don't define it — the /collections listing, for one. Turn it off per subclass with the number_returned class keyword (this is exactly how the built-in Collections envelope is defined):

class Collections(LinkedCollection[Collection], items_alias='collections',
                  number_returned=False):
    pass

Omitting null members

OGC omits absent members rather than emitting null, so an unset numberMatched or Link.title simply doesn't appear. That behavior comes from OmitNullModel, the base both Link and LinkedCollection build on. Subclass it directly when you define your own resource models and want the same: optional fields left unset are dropped on JSON serialization, and — unlike a hand-rolled null-dropping serializer — the OpenAPI response schema still reflects the real fields rather than collapsing to an opaque object.

from gazebo import OmitNullModel


class Style(OmitNullModel):
    name: str
    description: str | None = None


# an unset optional member is omitted on the wire, not emitted as null
basic = Style(name='basic').model_dump_json()

It only drops top-level None members; nulls inside values (an open-ended temporal interval [start, null]) are preserved.

paginate() returns deferred next/prev links. At serialization each takes the current request URL and rewrites only the pagination query params — token and limit by default, both configurable — preserving every other param. You own the token semantics (opaque cursor, offset, keyset); gazebo just builds the links. The underlying with_query helper is public if you need to rewrite a URL yourself.

from gazebo.pagination import paginate

# next/prev links that, at serialization, rewrite only the pagination query params
# of the current request URL and preserve everything else.
collection.links.extend(paginate(next_token='abc', limit=10))

Opaque cursors

When you'd rather not invent a token format, encode_cursor/decode_cursor pack an arbitrary payload into one opaque, URL-safe string. The cursor is encoded, not signed — so it's opaque to clients, but always validate the decoded contents. A malformed cursor raises a ParamError (a 400 problem via the glue). paginate() also emits first/last/self links on request:

from gazebo.pagination import decode_cursor, encode_cursor, paginate

# Wrap an arbitrary token payload in one opaque, URL-safe cursor instead of
# hand-rolling a token format. encode/decode round-trip; a malformed cursor raises a
# ParamError (which the FastAPI glue renders as a 400 problem).
cursor = encode_cursor({'offset': 20})
assert decode_cursor(cursor) == {'offset': 20}

# paginate() also emits first/last/self when asked (token_param renames the param).
cursor_links = paginate(next_token=cursor, first=True, self_=True, token_param='cursor', limit=10)

Offset/limit

For classic offset paging, paginate_offset() derives the whole self/first/prev/next/last set from the current page position (and the total, when known) — so prev/first appear only past the first page, and next/ last only when another page follows:

from gazebo.pagination import paginate_offset

# Offset/limit pagination: say where you are (and the total, if known) and the
# self/first/prev/next/last links are derived from the page position.
offset_links = paginate_offset(offset=20, limit=10, total=55)

POST-body pagination (stateless servers)

The builders are a thin convenience over Link, not a lossy wrapper: every generated link can carry the full Link surface — a type, headers, a title (or any extra member, via **link_fields), and a method/body. That last pair is what makes pagination work for a POST search on a stateless server: with method='POST' the page token rides in the request body (merged into the body you pass) instead of the query string, so each next link re-states the whole search the server doesn't remember. The companion drive_pagination test driver follows these POST next links by reposting that body.

# POST pagination for a *stateless* server: the token rides in the request body (merged
# with the original criteria) rather than the query, so each `next` re-states the whole
# search. Any Link member (here `type`) can be set on every emitted link.
post_links = paginate(
    next_token='page-2',
    method='POST',
    body={'filter': {'collection': 'plants'}},
    type='application/json',
)
next_link = post_links[0]

Reference

See gazebo.collection, gazebo.pagination, and OmitNullModel.