Min 21-22 — API Docs 📘¶
Heaven's docs page is in-house: no CDN script, no third-party widget. It's powered by the same route discovery heaven routes/heaven handlers use, so every registered route shows up, schema'd or not, not just the ones you explicitly documented on app.schema.
Turn it on¶
app.DOCS('/docs', title="Orders API", version="1.2.0")
That mounts two routes:
| Route | Serves |
|---|---|
GET /docs | A self-contained, dark-themed reference UI, no external requests |
GET /docs/openapi.json | The raw OpenAPI 3.1 document, for Postman/codegen/gateways |
Docs are off by default: nothing is generated or served until you call DOCS(). The page works fully offline, including on an air-gapped network or under a locked-down CSP.
Undocumented routes still show up¶
Every route heaven knows about, via app.GET(...), app.POST(...), and so on, appears on the docs page, whether or not you gave it a matching app.schema.GET(...)/app.schema.POST(...) entry. Routes with no schema registration are shown with an undocumented badge and no request/response schema, rather than being silently missing.
Keeping a route off the page¶
A route you serve but do not want in the reference (a health check, an internal webhook, a verification file a vendor asks you to host) is registered with docs=False:
app.GET('/.well-known/verification.json', 'handlers.vendor.verification', docs=False)
api.POST('/internal/reindex', 'handlers.search.reindex', docs=False)
app.SOCKET('/ops/live', 'handlers.ops.live', docs=False)
It is served exactly as before. It is left out of openapi() and the docs page, and it still appears in app.discover() and heaven routes, flagged documented: False, so the route listing stays truthful about what is wired. The flag survives mount(), and registering the route again without it (after Routes.remove()) puts it back on the page. A schema registration does not override it: app.schema.POST('/internal/reindex', ...) still validates the request, and the route stays hidden.
Heaven's own plumbing is registered this way: the two routes DOCS() mounts, and the OPTIONS /* preflight cors() adds so browsers find a route to preflight against.
Keeping a family off the page¶
docs=False is the right unit for one route and the wrong one for a family. An app that delivers 175 events over 175 routes would carry 175 flags, and the 176th route arrives without one: it is published by the omission rather than by a decision. Give the prefixes to DOCS() instead and the family goes in a line:
api.doc('/docs', exclude=('/events/', '/v/admin/', '/webhooks/'))
A prefix is matched with str.startswith against the route as you registered it, and nothing else: no globbing, no regular expression, so what a prefix covers can be read off the string. /events/ covers /events/order-placed and not /events itself, so write /events if you want both. A single prefix can be given as a bare string, exclude='/events/'.
A covered route behaves exactly as one registered docs=False: served as before, left out of openapi() and the page, and listed by app.discover() and heaven routes with documented: False. Both are folded into that one flag, so the page, the spec, the route listing and anything deriving tools from the route tree cannot disagree about what the reference publishes.
The exclusions belong to the subdomain the page is mounted on, because a reference describes that engine and nothing else. api.doc('/docs', exclude=('/events/',)) hides api's event routes and leaves an unrelated /events/newsletter on www alone, and openapi() with no argument, the full export, applies each reference's exclusions to its own engine.
A prefix that no route could begin with raises UrlError at registration rather than quietly covering nothing, which is the failure the argument exists to prevent: an author who believes a family is hidden and publishes it.
What the page does not say¶
The reference describes the endpoint, not the code behind it. It does not name the Python function serving a route: that is your app's business, and on a public reference it is a list of internal names for a reader who cannot call any of them. app.discover() and heaven routes still carry handler_name, which is where you look for it.
Describing an endpoint¶
Everything the UI shows comes from the schema registration:
class CreateItem(TypedDict):
name: Annotated[str, "min_len=1"]
price: Annotated[float, "min=0"]
class ItemOut(TypedDict):
id: int
name: str
app.schema.POST('/items',
expects=CreateItem,
returns=ItemOut,
summary="Create an item",
description="Adds an item to the inventory and returns it with its new ID.",
group="Inventory",
)
| Argument | Shows up as |
|---|---|
expects | The request body schema |
returns | The 200 response schema |
summary | The endpoint's one-line title |
description | The longer prose beneath it |
group | The tag the endpoint is filed under |
Grouping¶
Without a group, Heaven tags each endpoint by its first path segment, so /users and /users/:id land together under users. Override it when the URL doesn't match how you want the docs organized:
app.schema.GET('/system/health', returns=Health, group="Monitoring")
Parameters come from the route¶
You don't declare parameters on app.schema.*; they're read off the route string you already wrote. Path segments become in: path parameters and the query hints after ? become in: query ones, typed from the same table both use:
app.GET('/reports/:id:int?page:int&active:bool&since:date', fetch)
| Parameter | In | Type | Required |
|---|---|---|---|
id | path | integer | yes |
page | query | integer | no |
active | query | boolean | no |
since | query | string, format date | no |
Query parameters are never marked required: a hint says how to read a value that arrives, not that one has to. An untyped path segment (:id) documents as a string, since that's what your handler is handed.
A query parameter is declared exactly the way a path parameter is, and reading it is literally the same code: first colon separates name from type, type names are case-insensitive, and a parameter without one is untyped — ?size is to a query string what :size is to a path. Untyped means converted by nothing and documented as the string it arrives as.
Which also means the two are refused for the same reasons, when you register the route rather than at some later surprise:
app.GET('/items/:id:banana', handler) # UrlError: Unknown type "banana" for route parameter…
app.GET('/items?page:banana', handler) # UrlError: Unknown type "banana" for query parameter…
One check has no path equivalent, and it's about the query string rather than about Heaven: a name that can't be a key on the wire can never match anything a client sends, so pasting a URL into the route is refused rather than documented.
app.GET('/items?page=1', handler)
# UrlError: Query parameter "page=1" in /items?page=1 cannot be a query key: no key
# containing '=' can reach it. A route declares the types of its query
# parameters, not their values - `?page:int`, not `?page=1`.
Exporting the spec¶
For CI checks, client generation, or uploading to a gateway:
$ heaven schema
Success! OpenAPI spec exported to swagger.json
$ heaven schema openapi-v1.json --app main:app
Success! OpenAPI spec exported to openapi-v1.json
Subdomain docs¶
A subdomain gets its own reference, containing only its own routes:
api = app.subdomain('api')
api.doc('/docs', title="Public API") # note: .doc() on a subdomain, .DOCS() on the app
The page and its openapi.json describe the subdomain they are mounted on and nothing else. A request to api.example.com is routed by the api engine alone, so a reference there listing the routes of www or of the wildcard subdomain would be describing endpoints that 404 on the host serving it. app.DOCS('/docs') on the app itself documents the default subdomain (www), by the same rule.
app.openapi() takes the same choice as an argument: app.openapi('api') is what the subdomain page serves, and app.openapi() with no argument spans every subdomain, which is what heaven schema exports.
Known limitations¶
The generated document is genuinely useful for request/response bodies, and genuinely incomplete elsewhere. Rather than let you discover this in front of a customer, here's what's absent from the generated spec today:
- No
securitySchemesand nosecurity: authentication is never described, so "Authorize" is unavailable in the UI. - Only the
200response is emitted. Your422validation failures and any error responses are undocumented. - No
examples,operationId,deprecated, orservers. - Subdomain routes collide in the full export. The path key drops the subdomain, so in
app.openapi()with no argument (and so inheaven schema) the same route registered onwwwandapiproduces one entry, and the second silently overwrites the first. A subdomain's own page, andapp.openapi('api'), cover one subdomain and cannot collide.
If your published contract has to be complete, treat heaven schema as a starting point and post-process the JSON.
Next: Does any of it actually work? → Min 23-24 — Testing with Earth