Routes

Route registration, methods, and path parameters

4 min read 756 words

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 namedrequest or typed as Request
  • 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 viaapp.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