Skip to content

GazeboApp & upgrade

Two ways to get gazebo's machinery onto a FastAPI app: construct a GazeboApp, or upgrade() an app you didn't create.

GazeboApp

GazeboApp(providers, *, overrides=None, trust=trust_none, health_path='/health', **fastapi_kwargs) is a thin FastAPI subclass wired from a registry. It opens the app scope in its lifespan, installs the request-scope and proxy-headers middleware, registers the problem handlers, and mounts /health — and otherwise is a FastAPI app, so any FastAPI(...) keyword passes straight through. It exposes .container and .app_state for introspection. Build it behind a create_app() factory so tests can pass Overrides:

from gazebo.ext.fastapi import GazeboApp, GazeboRouter, Overrides, Providers

router = GazeboRouter()


@router.get('/ping')
async def ping() -> dict[str, str]:
    return {'pong': 'ok'}


def create_app(overrides: Overrides | None = None) -> GazeboApp:
    providers = Providers()
    app = GazeboApp(providers, overrides=overrides)
    app.include_router(router)
    return app

upgrade() an existing app

When you don't construct the app — it's made by a framework, or needs custom FastAPI(...) config you'd rather not route through a subclass — call upgrade(app, providers, ...) to apply the same machinery in place: it wraps the lifespan, installs the middleware, registers the handlers, rewrites @app.get routes for injection, and adds /health. Same options as GazeboApp, and idempotent (calling it twice is a no-op).

from fastapi import FastAPI

from gazebo.ext.fastapi import Providers, upgrade

existing = FastAPI()  # someone else's app
existing.include_router(router)
upgrade(existing, Providers())  # add gazebo's machinery in place; idempotent

Problem & validation responses

GazeboApp and upgrade() register two exception handlers so every error response is application/problem+json (see Problems):

  • problem_exception_handler renders any ProblemException you raise with its status and detail.
  • validation_exception_handler maps FastAPI's RequestValidationError to a 422 problem, carrying the field-level errors under an errors extension member (RFC 9457). Without this, bad input would return FastAPI's default {"detail": [...]} shape and break problem+json uniformity.

The second is automatic — you write nothing for it:

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

validating = GazeboRouter()


@validating.get('/widgets')
async def list_widgets(limit: int = 10) -> dict[str, int]:
    return {'limit': limit}


vapp = GazeboApp(Providers())
vapp.include_router(validating)

# Now GET /widgets?limit=nope fails request validation and the glue returns an
# application/problem+json 422 (see the response shape below) — no handler needed.

A bad request then yields:

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "request validation failed: 1 error(s)",
  "errors": [{"type": "int_parsing", "loc": ["query", "limit"], "msg": "..."}]
}

CORS

OGC APIs are usually consumed from a browser, so cross-origin requests matter. CORS is off by default (an open policy is a security smell to ship silently); opt in with the cors= argument to GazeboApp/upgrade():

  • cors=True — a permissive policy (any origin, no credentials), fine for local development.
  • cors=['https://app.example.com', ...] — restrict to an explicit origin list.
  • cors=CorsConfig(...) — full control (methods, headers, credentials, max_age). Note that allow_origins=['*'] with allow_credentials=True is rejected by browsers, so credentials default off.

The middleware is installed outermost, so even a problem+json error response carries the CORS headers.

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

# cors=True is permissive (allow all origins) — handy for local development.
dev_app = GazeboApp(Providers(), cors=True)

# A list restricts to specific origins; a CorsConfig gives full control (here,
# allowing credentialed requests, which `*` cannot).
prod_app = GazeboApp(
    Providers(),
    cors=CorsConfig(
        allow_origins=['https://app.example.com'],
        allow_credentials=True,
    ),
)

The same navigational links gazebo resolves into a JSON body can also be emitted as an RFC 8288 Link: header, so crawlers and non-JSON clients can follow self/next/prev/alternate without parsing the body. Call set_link_header inside an endpoint with the links you're returning — it's the companion to set_cache_headers, stamping a header onto the injected Response.

It takes any sequence of Link (a collection's .links, or a hand-built list) and resolves the deferred hrefs against the active request. By default only navigational rels (NAV_RELS) are emitted, capped at max_links, so a large collection can't produce an oversized header; pass rels= to narrow further (e.g. rels=['self', 'next', 'prev']) or rels=None for every rel.

from fastapi import Response

from gazebo.collection import LinkedCollection
from gazebo.ext.fastapi import GazeboApp, Providers, set_link_header
from gazebo.link import Link

hdr_app = GazeboApp(Providers())


class Items(LinkedCollection[dict], items_alias='items'):
    pass


@hdr_app.get('/items', response_model=Items)
async def list_items(response: Response) -> Items:
    links = [Link.self_link()]
    # Mirror the navigational links into an RFC 8288 Link: header. Pass a rel list
    # (e.g. rels=['self', 'next', 'prev']) to narrow it further.
    set_link_header(response, links)
    return Items(items=[{'id': 1}], links=links)

Mounting under a root app

A mounted sub-app's lifespan isn't run automatically, so a mounted GazeboApp would never open its app scope. Set the root app's lifespan to forward_lifespans(sub_app) to run it. This is general framework behavior, not gazebo-specific — but it's the one wiring step that's easy to miss.

from fastapi import FastAPI

from gazebo.ext.fastapi import forward_lifespans

sub = create_app()  # a GazeboApp
root = FastAPI(lifespan=forward_lifespans(sub))  # run the sub-app's lifespan
root.mount('/api', sub)

Reference

See gazebo.ext.fastapi (GazeboApp, upgrade, forward_lifespans).