Min 05-06 — The Router 🛣️¶
The router decides where a request goes, what runs before it, and what runs after. It is the one object you configure; everything else follows from it.
from heaven import App
app = App() # App, Application, Router and Server are the same class
Registering routes¶
app.GET ('/users', get_users)
app.POST ('/users', create_user)
app.PUT ('/users/:id', replace_user)
app.PATCH ('/users/:id', update_user)
app.DELETE('/users/:id', delete_user)
Also available: HEAD, OPTIONS, TRACE, CONNECT, plus HTTP(route, handler) to register one handler across every method, and SOCKET / WS / WEBSOCKET for websockets.
HEAD is answered by the matching GET route automatically, with the same status and headers but no body, so you only need app.HEAD() when a HEAD request should be handled differently from its GET.
A method mismatch returns 405 with an Allow header listing the methods that route does accept. A path that matches nothing returns 404.
OPTIONS is not auto-answered
Unless you call app.cors(), which registers a catch-all for you, OPTIONS requests are only served if you register a handler yourself.
Path parameters¶
Prefix a segment with : to capture it. Read it back from req.params.
app.GET('/users/:id/orders/:order_id', handler)
async def handler(req, res, ctx):
user_id = req.params.get('id') # '42' (a string)
order_id = req.params.get('order_id') # 'abc' (a string)
Append a type to have Heaven convert it for you:
app.GET('/users/:id:int', handler) # req.params.get('id') -> 42, an int
The same seven type names work in path segments and in query strings:
| Type | Example segment | You get |
|---|---|---|
int | /users/:id:int | 42 |
float | /price/:amount:float | 19.99 |
bool | /flag/:on:bool | True, from true/false/1/0 |
str | /tag/:name:str | 'sale', the no-op |
date | /report/:day:date | date(2026, 8, 1) |
datetime | /log/:at:datetime | datetime(2026, 8, 1, 10, 30) |
uuid | /item/:sku:uuid | UUID('3f2504e0-...') |
date and datetime parse ISO 8601, the same format date.fromisoformat accepts.
A value that will not convert is a miss, not a string
/users/:id:int does not match /users/abc, so the request falls through to whatever else matches, or 404. Your handler never receives an unconverted string where it asked for a type. If a wildcard covers the same prefix, that wildcard picks it up:
app.GET('/users/:id:int', by_id)
app.GET('/users/*', by_slug) # /users/abc lands here
Unknown type names are rejected at registration
/users/:id:uuidd raises UrlError when the route is registered rather than quietly handing back a string, so a typo surfaces at startup instead of in production.
Wildcards¶
A trailing * captures the rest of the path into req.params['*'].
app.GET('/files/*', serve)
async def serve(req, res, ctx):
rest = req.params.get('*') # 'reports/2026/q1.pdf'
Typed query strings¶
Declare query parameters in the route string and Heaven coerces them on the way in. Unlike path params, all six types work here.
app.GET('/search?page:int&since:date&exact:bool', search)
async def search(req, res, ctx):
page = req.queries.get('page') # 3 int
since = req.queries.get('since') # date(2026,1,1)
exact = req.queries.get('exact') # True bool
Supported: :int, :float, :bool, :str, :date, :datetime, :uuid.
Coercion failures are silent
If a client sends ?page=banana, Heaven does not raise — you get the raw string 'banana' back. Validate anything you actually depend on.
The same declaration documents the endpoint: each hint becomes an in: query parameter in openapi() and on the docs page, and appears on the route's app.discover() entry as queries, {'page': 'int', 'since': 'date', 'exact': 'bool'}.
A route you serve but would rather keep off the reference takes docs=False at registration, app.GET('/healthz', check, docs=False). It is served as before, left out of openapi() and the docs page, and still listed by app.discover() with documented: False. A whole family goes in one line instead, at the page rather than at each route: api.doc('/docs', exclude=('/events/', '/webhooks/')), prefix-matched, scoped to the subdomain the page is mounted on, and reaching the same documented flag. See keeping a route off the page.
Declared like a path parameter, refused like one
A query parameter is read by the same code as a :name:type path segment, so ?page:INT works exactly as :id:INT does, and ?size with no type is untyped rather than an error — converted by nothing, documented as a string. An unknown type (?page:banana) or a parameter with no name (?:int) raises UrlError at registration, as it does in a path. So does ?page=1: no query key can contain =, so it could never have matched anything a client sent.
How a request finds its handler¶
Heaven keeps one route trie per HTTP method per subdomain and walks it segment by segment. There is no regex matching and no linear scan through a route list, which is a large part of why it is fast.
flowchart TD
A["Request arrives"] --> B{"Which subdomain?<br/><code>api.site.com</code>"}
B -->|"registered"| C["that subdomain's trie"]
B -->|"unknown"| D["wildcard <code>*</code>, else <code>www</code>"]
C --> E{"Walk path segments<br/>/users/42/orders"}
D --> E
E -->|"exact segment"| F["descend"]
E -->|":param"| F
E -->|"trailing *"| F
F --> G{"Handler at this node?"}
G -->|"no"| H["404 Not found"]
G -->|"yes"| I["BEFORE hooks"]
I --> J["handler(req, res, ctx)"]
J --> K["AFTER hooks"]
K --> L["Response sent"] Trailing slashes are insignificant: /users/, /users and //users all match the same route. No redirect is issued.
The string paradigm¶
Every place Heaven takes a handler, it also takes a dotted import path. The module is imported when the route is registered.
app.GET('/users', 'controllers.users.index')
app.POST('/users', 'controllers.users.create')
app.BEFORE('/dashboard/*', 'middleware.auth.check_token')
app.ON('startup', 'db.connect')
Why it's worth using:
- No import blocks. Your
app.pystays a routing table instead of an import manifest. - No circular imports. Handlers that need the app no longer import the module that imports them.
- Readable diffs. A new endpoint is one line in one file.
Grouping handlers in a class¶
When a set of routes shares a subject, Class#method registers a method instead of a function. The class subclasses heaven.Handler:
# controllers/orders.py
from heaven import Handler
class Orders(Handler):
async def index(self):
self.res.body = await self.req.app.peek('db').orders()
async def show(self):
self.res.body = await self.find(self.req.params.get('id'))
async def find(self, reference): # a plain helper, not a route
...
app.GET('/orders', 'controllers.orders.Orders#index')
app.GET('/orders/:id', 'controllers.orders.Orders#show')
The three objects a function handler is handed arrive as self.req, self.res and self.ctx, so both styles are the same contract written differently. Methods can be sync or async, hooks accept the same form, and everything else (streaming, schemas, subdomains) works exactly as it does for functions.
Heaven builds one instance per request. self belongs to the request being served and is thrown away afterwards, so two requests in flight never share it. That also means instance attributes do not persist between requests: put shared state on the app with app.keep(), and per-request state on self or ctx.
Subclassing is what types it
Handler.__init__ carries the annotations, so self.req, self.res and self.ctx autocomplete in your editor without you writing any. Naming the schema, as in class CreateOrder(Handler[Order]), types self.req.data the same way Request[Order] does for a function.
Do not override __init__: Heaven constructs the instance with exactly (req, res, ctx). Per-request setup belongs at the top of the method.
Anything wrong with the string is raised at registration, not on the first request that reaches the route: a class that is not a Handler subclass, a method that does not exist or is not callable, or a spec missing its module path or method name all raise HandlerError while the app is still booting.
Application lifecycle¶
Run code when the server boots and when it shuts down. Both callbacks receive the app, not a request.
async def connect_db(app):
app.keep('db', await Database.connect()) # store on the app
async def close_db(app):
await app.peek('db').close()
app.ONCE(connect_db) # same as app.ON('startup', connect_db)
app.ON('shutdown', close_db)
Read it back inside any handler through req.app:
async def list_users(req, res, ctx):
db = req.app.peek('db')
res.body = await db.fetch('SELECT * FROM users')
Startup failures do not stop the server
If a startup callback raises, Heaven prints a notice and starts anyway. A failed database connection produces a running server that 500s on every request rather than a server that refuses to boot. If boot-time correctness matters, assert it yourself:
import sys
#... more code ...
async def connect_db(app):
try: app.keep('db', await Database.connect())
except Exception: sys.exit(1) # fail loudly
Application state: keep, peek, unkeep¶
app.keep() is Heaven's answer to dependency injection — one shared bucket, set at startup, read anywhere.
app.keep('db', pool) # store
pool = app.peek('db') # read
pool = app.unkeep('db') # read and remove
For type safety, use a Key:
from heaven import Key
Pool = Key[Database]('db')
app.keep(Pool, pool)
db = app.peek(Pool) # your type checker knows this is Database | None
App state vs context
app.keep lives for the life of the process — connection pools, config, clients. ctx.keep lives for the life of one request — the current user, a request id. Never put per-request data on the app; it leaks across requests.
CORS¶
app.cors() # allow everything
app.cors(
origin=['https://myapp.com'], # a list reflects matching origins
methods=['GET', 'POST'],
headers=['Authorization', 'Content-Type'],
credentials=True,
max_age=3600,
)
app.cors() simply registers a BEFORE('/*') hook plus a catch-all OPTIONS route. Argument names are forgiving — max_age, maxAge and MAX_AGE all work, as do origin/origins.
credentials=True needs an explicit origin
The defaults are origin='*', methods='*', headers='*'. Browsers reject Allow-Origin: * together with credentials, and Heaven does not catch that combination for you. Pass a real origin list whenever you use cookies.
Sessions¶
app.sessions(secret_key='keep-it-secret', max_age=86400)
Then in any handler:
ctx.session.user_id = 123 # write
uid = ctx.session.user_id # read
Sessions are signed cookies — see Min 27-28 — Security for the details and the caveats.
Where next¶
Hooks, mounting and daemons each get their own chapter:
- Middleware → Min 17-18 — Hooks
- Mounting and subdomains → Min 07-08 — Subdomains & Mounting
- Background work → Min 25-26 — Background Work
Next: One app, many hostnames → Min 07-08 — Subdomains & Mounting