Why gazebo¶
Three things every OGC-style service ends up hand-rolling — request-dependent links, pagination, resource lifetimes — shown breaking in a plain FastAPI app, then handled by gazebo.
Machinery is easiest to judge by the code it replaces. This page takes one small plain-FastAPI service and shows the three places it goes wrong. Every snippet is a runnable, tested example: the "before" failures are asserted in this repo's test suite, not imagined.
Links make the request leak everywhere¶
An OGC response carries links, and a link's URL depends on the request that asked for it: its scheme, its host, its query string. In a plain app the only way to build a link is to hold the request — so the request goes wherever links go:
from fastapi import FastAPI, Request
PLANTS = [{'id': 1, 'name': 'fern'}, {'id': 2, 'name': 'ivy'}, {'id': 3, 'name': 'oak'}]
plain = FastAPI()
def plant_page(request: Request, limit: int) -> dict:
# the request is here only because the links need it
return {
'plants': PLANTS[:limit],
'links': [
{'rel': 'self', 'href': str(request.url)},
{'rel': 'root', 'href': str(request.base_url)},
],
}
@plain.get('/plants')
async def list_plants(request: Request, limit: int = 10) -> dict:
return plant_page(request, limit)
Two things are wrong here, one structural and one operational.
The structural one: plant_page is business logic, but it takes a Request —
and so will every function below it that ever needs to emit a link. The web
framework has leaked into the layer that should know nothing about it, and none
of that code can run (or be tested) without a request in hand.
The operational one: deploy this behind a TLS-terminating load balancer and every
link says http://internal-host/.... TLS ends at the proxy, so the app never
sees the public scheme or host. The test backing this snippet sends
X-Forwarded-Proto: https and asserts that it's ignored.
gazebo's answer is to defer the URL. Link.self_link() returns a link whose
href is a callable, resolved when the response serializes — against a request
the framework glue supplies ambiently. Business logic builds complete responses,
links included, with no request in sight. The same glue applies X-Forwarded-*
headers from proxies you explicitly trust, so the resolved URLs are also correct
behind the load balancer:
from gazebo.collection import LinkedCollection
from gazebo.ext.fastapi import GazeboApp, GazeboRouter, Overrides, Providers
from gazebo.link import Link
from gazebo.pagination import paginate
PLANTS = [{'id': 1, 'name': 'fern'}, {'id': 2, 'name': 'ivy'}, {'id': 3, 'name': 'oak'}]
class Plants(LinkedCollection[dict], items_alias='plants'):
pass
def plant_page(limit: int, token: str | None = None) -> Plants:
# business logic builds the whole response, links included -- no request in sight
start = int(token or 0)
links = [Link.self_link(), Link.root_link()]
if start + limit < len(PLANTS):
links += paginate(next_token=str(start + limit), limit=limit)
return Plants(items=PLANTS[start : start + limit], links=links)
router = GazeboRouter()
@router.get('/plants', response_model=Plants)
async def list_plants(limit: int = 2, token: str | None = None) -> Plants:
return plant_page(limit, token)
The tests behind this version assert the flip side: the same forwarded headers
now yield https://api.example.com/plants. How the deferral works is the
request context; the model and its factories are
Links; the trust policies are
Proxy & context.
Pagination is URL surgery, repeated per endpoint¶
A next link is the current URL with the paging parameters swapped out and
everything else kept. The "everything else kept" is where hand-rolled versions
quietly fail:
PAGE_SIZE = 2
@plain.get('/search')
async def search(request: Request, token: str = '0') -> dict:
start = int(token)
next_url = request.url.replace_query_params(token=str(start + PAGE_SIZE))
return {
'plants': PLANTS[start : start + PAGE_SIZE],
'links': [{'rel': 'next', 'href': str(next_url)}],
}
Search ?q=fern, follow the next link, and the filter is gone —
replace_query_params replaced the whole query string, so page two returns
different results than page one. The fix is to merge the existing params first,
remember to drop token on the last page, and then repeat all of that in every
paginated endpoint. It's the kind of code that's correct the day you write it
and wrong after the next refactor.
paginate() owns that surgery. It emits deferred next/prev (and on request
first/last/self) links that rewrite only the paging params of whatever
URL the client actually called, preserving the rest. It's the two-line branch in
the snippet above:
The token's meaning stays yours — opaque cursor, offset, keyset. See Collections for the cursor helpers, offset paging, and POST-body pagination for stateless search.
Resources have lifetimes FastAPI doesn't model¶
A real service holds resources that outlive any one request: a connection pool
opened at startup, a session per request, a user derived from headers. FastAPI's
Depends covers the per-request slice; for the rest, apps improvise — state
stashed on the app, wiring restated at each route, and tests that substitute
dependencies by mutating the application object:
from typing import Annotated
from fastapi import Depends
class Pool:
def __init__(self, dsn: str) -> None:
self.dsn = dsn
def get_pool(request: Request) -> Pool:
return request.app.state.pool # created at startup, reached through untyped state
@plain.get('/pool')
async def pool_info(pool: Annotated[Pool, Depends(get_pool)]) -> dict:
return {'dsn': pool.dsn}
# tests substitute by mutating the application object itself
plain.dependency_overrides[get_pool] = lambda: Pool('sqlite://')
Each line is a small liability. app.state.pool is untyped and invisible —
nothing declares it exists, nothing manages its teardown, and a typo'd attribute
fails at request time. Every route restates the wiring with Depends(get_pool).
And dependency_overrides is mutation of a shared global: forget to clean it up
and the fake leaks into the next test.
gazebo gives resources one typed registry that says what builds each type and how long it lives, delivers them to handlers as plain typed parameters, and runs teardown when the scope closes:
from gazebo.asgi import trust_all
class Database:
def __init__(self, dsn: str) -> None:
self.dsn = dsn
@classmethod
def __provide__(cls) -> Database:
return cls('postgres://prod/app')
def create_app(overrides: Overrides | None = None) -> GazeboApp:
providers = Providers().app(Database) # what builds it, and how long it lives
app = GazeboApp(providers, overrides=overrides, trust=trust_all)
app.include_router(router)
@app.get('/', name='landing')
async def landing() -> dict:
return {'service': 'plants'}
return app
Tests substitute by parameter — an Overrides passed into the factory, never
a mutated global — so they stay isolated and parallel-safe:
from fastapi.testclient import TestClient
def test_db() -> None:
overrides = Overrides().set(Database, Database('sqlite://')) # by parameter, typed
with TestClient(create_app(overrides)) as client:
assert client.get('/db').json() == {'dsn': 'sqlite://'}
The container is Dependency injection; the by-type route parameters are Routers & injection.
What it doesn't cost¶
gazebo is a toolkit, not a framework: you still write a FastAPI app and your own handlers, and each piece above is usable without the others. The core — links, collections, problems, landing pages — is pure pydantic with no framework import, so you can adopt a single model class and stop there. When these three pains aren't yours, you don't need gazebo; when they are, Getting started is a working app in ~40 lines.