Routing¶
Registering a route¶
One decorator exists per HTTP method: get, post, put, patch, delete,
head, options. Each returns the handler unchanged, so decorators stack:
@app.get("/admin")
@app.get("/administrator") # same handler, two paths
@use_guards(AdminGuard)
def admin(res):
res.text("ok")
For several methods at once:
Path parameters¶
Two syntaxes are accepted and are exactly equivalent — they compile to the same route. Use whichever your team already reads fluently.
Converters¶
Add :converter to type and constrain a parameter before matching:
@app.get("/users/{id:int}")
@app.get("/posts/{slug:slug}")
@app.get("/orders/{ref:uuid}")
@app.get("/assets/{path:path}")
@app.get("/codes/{code:re:[A-Z]{3}-[0-9]{4}}")
| Converter | Matches | Arrives as |
|---|---|---|
str (default) |
anything except / |
str |
int |
[0-9]+ |
int |
float |
1 or 1.5 |
float |
uuid |
a canonical UUID | uuid.UUID |
slug |
lower-case-with-dashes |
str |
path |
anything, including / |
str |
re:<pattern> |
your pattern | str |
Converters run at match time, which changes the failure mode in a way worth internalising:
GET /users/abc is a 404, because no route matched. Without the converter
it would be a 422 from validation. Neither is wrong; the converter version is
cheaper and reads better in logs.
A path converter is greedy and must be the last segment:
Partial segments¶
A parameter does not have to fill a whole segment:
How matching works¶
Paths are compiled once, at registration, into a trie of segments. Matching costs time proportional to the number of segments in the request, not the number of routes in the application — a thousand routes match as fast as ten.
Within one segment the order is: literal, then parameterised, then a greedy
path catch-all.
@app.get("/users/me") # wins for /users/me
@app.get("/users/{id:int}") # wins for /users/42
@app.get("/users/{name}") # wins for /users/ada
Backtracking is real: if {id:int} matches a segment but nothing deeper
matches, the router falls back and tries {name}.
Methods that are handled for you¶
HEADfalls back to theGETroute and the body is dropped, per RFC 9110.405responses carry a correctAllowheader listing what is allowed.OPTIONSis included inAlloweven if you did not register it.
Routers¶
Group routes and merge them under a prefix:
# users/routes.py
from slowfw import Router
users = Router(prefix="/users", tags=["users"])
@users.get("/{id:int}", name="user_detail")
def get_user(id: int) -> dict:
return {"id": id}
@users.post("", status_code=201)
def create_user(payload: CreateUser) -> dict: ...
# main.py
from users.routes import users
app.include_router(users, prefix="/api/v1") # -> /api/v1/users/{id}
A router carries its own prefix, tags, and middleware, and merging applies all
three. Routers nest, so a v1 router can include a users router.
In practice, most applications that need this reach for controllers instead, which give the same grouping plus dependency injection.
Reverse URLs¶
Never build a path with an f-string; ask the router:
@app.get("/users/{id:int}", name="user_detail")
def get_user(id: int) -> dict: ...
app.url_for("user_detail", id=7) # "/users/7"
Rename the path and every url_for follows. Forget a parameter and you get a
ConfigurationError naming it, at the call site, rather than a broken link in
production. Route names default to the handler's function name.
Static files¶
Mounts a {path:path} route that serves public/, with ETags, Last-Modified,
conditional 304 responses, and byte-range support. Every candidate path is
resolved and checked against the root, so .. traversal and symlinks pointing
outside the directory are refused. See
Templates and static files.
Common errors, and what they mean¶
| Message | Cause |
|---|---|
Route path must start with '/' |
You wrote "users/{id}" |
Unknown path converter 'x' |
Typo, or you wanted re: |
A 'path' converter must be the last segment |
{p:path} is not final |
Duplicate route: GET /x is already handled by ... |
Two handlers, one method and path |
Duplicate path parameter(s) ['id'] |
/{id}/y/{id} |
Route name 'x' is already used by ... |
Two routes, same name= |
All of these raise at import time, not on the first request.