Route Registration
Register routes with the@app.route()decorator:
@app.route("/")
def index():
return "Hello, World!"
@app.route("/about")
def about():
return Template("about.html")
Routes are registered during the setup phase. At freeze time, the route table compiles into an immutable trie-based structure for fast matching.
HTTP Methods
By default, routes acceptGETrequests. Specify methods explicitly:
@app.route("/users", methods=["GET"])
def list_users():
return Template("users.html", users=get_all_users())
@app.route("/users", methods=["POST"])
async def create_user(request: Request):
data = await request.json()
user = create(data)
return Response(body=b"Created").with_status(201)
@app.route("/users/{id:int}", methods=["GET", "DELETE"])
async def user(request: Request, id: int):
if request.method == "DELETE":
delete_user(id)
return Response(body=b"Deleted")
return Template("user.html", user=get_user(id))
If a request matches a path but not the method, Chirp returns 405 Method Not Allowed with an Allowheader listing the valid methods.
Path Parameters
Dynamic segments are defined with curly braces:
@app.route("/users/{id}")
def user(id: str):
return f"User: {id}"
Type Conversions
Add a type suffix to auto-convert parameters:
@app.route("/users/{id:int}")
def user(id: int): # id is an int, not a str
return get_user(id)
@app.route("/price/{amount:float}")
def price(amount: float): # amount is a float
return f"${amount:.2f}"
Supported types:
| Type | Pattern | Example |
|---|---|---|
str |
(default) any non-/chars |
/users/{name} |
int |
digits only | /users/{id:int} |
float |
digits with optional decimal | /price/{amount:float} |
path |
any chars including/ |
/files/{filepath:path} |
Catch-All Routes
Use{name:path}to match the rest of the URL:
@app.route("/files/{filepath:path}")
def serve_file(filepath: str):
return send_file(filepath) # filepath can contain slashes
Handler Signature Introspection
Chirp inspects your handler's signature to inject the right arguments:
# No arguments -- simplest case
@app.route("/")
def index():
return "Hello"
# Request only
@app.route("/search")
def search(request: Request):
q = request.query.get("q", "")
return Template("search.html", q=q)
# Path parameters only
@app.route("/users/{id:int}")
def user(id: int):
return get_user(id)
# Both
@app.route("/users/{id:int}/posts/{slug}")
def user_post(request: Request, id: int, slug: str):
return Template("post.html", post=get_post(id, slug))
# Extractable dataclasses — from query (GET), form (POST), or JSON body
@app.route("/search")
def search(form: SearchForm):
return Template("search.html", q=form.q, page=form.page)
# Dependency injection via app.provide()
@app.provide()
def get_store() -> DocumentStore:
return DocumentStore()
@app.route("/documents/{id}")
def document(id: str, store: DocumentStore):
return Template("doc.html", doc=store.get(id))
Argument resolution (first match wins):
- Request — Parameter named
requestor typed asRequest - Path parameters — From URL match, with type coercion
- Extractable dataclasses — Query string (GET), form body (POST), or JSON body. Dataclass fields are populated from request data.
- Service providers — Registered via
app.provide(). When a parameter's type matches a registered factory, Chirp injects the result.
Async Handlers
Handlers can be sync or async. Chirp handles both:
@app.route("/sync")
def sync_handler():
return "Sync"
@app.route("/async")
async def async_handler():
data = await fetch_data()
return Template("data.html", data=data)
Use async handlers when you need to awaitI/O (database queries, HTTP calls, file reads).
Error Handlers
Register error handlers by status code or exception type:
@app.error(404)
def not_found(request: Request):
return Template("errors/404.html", path=request.path)
@app.error(500)
def server_error(request: Request, error: Exception):
return Template("errors/500.html", error=str(error))
@app.error(ValidationError)
def validation_error(request: Request, error: ValidationError):
return Response(str(error)).with_status(422)
Error handlers use the same return-value system as route handlers.
Route Table Compilation
At freeze time, routes compile into a trie (prefix tree). Matching is O(path-segments), not O(total-routes). This means performance doesn't degrade as you add more routes.
The compiled route table is immutable. Under free-threading, all worker threads share it without synchronization.
Dynamic URLs in htmx Attributes
Whenchirp check <app> validates templates, it extracts hx-get, hx-post,
hx-put, hx-delete, hx-patch, action, and route-bearing macro arguments
such asconfirm_url, then verifies method + path against the route table.
Dynamic URLs (built with Kida's~ or {{ }}) are skipped; only literal URLs
are validated. Use~ or {{ var }}for path parameters; both work at render
time and are correctly treated as dynamic by the checker.
confirm_url defaults to POST unless a companion confirm_methodis present,
which lets dialog-style component APIs participate in the same route validation
as raw htmx attributes.
Legacy component-styleaction="update-thing"values are no longer treated as
route URLs. Chirp emits a warning instead of a false route error so you can
migrate older macros to literal URLs or explicit htmx attributes over time.
The checker also validates selector-bearing HTMX attributes (hx-target, hx-select, hx-include, etc.) for obvious syntax mistakes and unknown static #idtargets.
Next Steps
- Filesystem Routing -- Discover routes from a pages/ directory
- Request & Response -- The immutable request and chainable response
- Middleware -- Intercept requests before they reach handlers
- Fragments -- Return fragments from route handlers