Skip to content

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:

  1. No import blocks. Your app.py stays a routing table instead of an import manifest.
  2. No circular imports. Handlers that need the app no longer import the module that imports them.
  3. 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:


Next: One app, many hostnames → Min 07-08 — Subdomains & Mounting