Routing¶
@app.route(...) registers a handler under a method + path combination.
This page covers the route registration surface: HTTP routes, path
parameters (string and regex), typed routes with converters, route
groups, and a brief pointer to WebSocket routes.
For handler signatures (full ASGI triplet vs. simplified form) see Your First App.
HTTP routes¶
from http import HTTPMethod
from blackbull import BlackBull, Response
app = BlackBull()
@app.route(methods=[HTTPMethod.GET], path='/tasks')
async def list_tasks(scope, receive, send):
await send(Response(b'[]'))
methods defaults to [HTTPMethod.GET]. Pass a list to accept
multiple methods on the same handler:
@app.route(methods=[HTTPMethod.GET, HTTPMethod.HEAD], path='/healthz')
async def healthz(scope, receive, send):
await send(Response(b'ok'))
Path parameters¶
Use {name} segments in the path string. Captured values are
available in scope['path_params'] (and, in the simplified form,
injected as named arguments):
@app.route(path='/tasks/{task_id}')
async def get_task(scope, receive, send):
task_id = scope['path_params']['task_id'] # str (default converter)
await send(Response(task_id.encode()))
{name} (no converter) matches [^/]+ and injects a str. Append
:converter to control both the regex and the injected Python type —
see Typed routes below.
Regex patterns¶
For fully custom patterns supply a compiled regex with named groups;
the captured values are injected into scope['path_params'] — the
same place as {name} parameters:
import re
@app.route(path=re.compile(r'^/items/(?P<id>\d+)$'))
async def get_item(scope, receive, send):
item_id = scope['path_params']['id']
await send(Response(item_id.encode()))
Typed routes¶
Append :converter to a path parameter to control both the URL
pattern and the Python type injected into the handler:
| Syntax | Regex matched | Python type |
|---|---|---|
{name} or {name:str} |
[^/]+ |
str |
{id:int} |
-?[0-9]+ |
int |
{uid:uuid} |
UUID hex pattern | uuid.UUID |
{rest:path} |
.+ (matches /) |
str |
import uuid
from http import HTTPMethod
@app.route(path='/items/{id:int}', methods=HTTPMethod.GET)
async def get_item(id: int):
return {'id': id} # id is already int, not a string
@app.route(path='/users/{uid:uuid}', methods=HTTPMethod.GET)
async def get_user(uid: uuid.UUID):
return {'uid': str(uid)}
@app.route(path='/files/{rest:path}', methods=HTTPMethod.GET)
async def get_file(rest: str): # rest may contain slashes
return {'path': rest}
Using {id:int} means a request to /items/abc returns 404 (the
router doesn't match) rather than being routed in and then failing
to convert.
URL reverse lookup¶
Register a route with a name= keyword, then build its path from
parameters:
@app.route(path='/items/{id:int}', methods=HTTPMethod.GET, name='item-detail')
async def get_item(id: int):
return {'id': id}
app.url_path_for('item-detail', id=42) # → '/items/42'
url_path_for raises KeyError for unknown names and ValueError
when required parameters are missing.
Startup validation¶
app.run() and the ASGI lifespan startup event both call
Router.validate() before accepting connections. Validation
checks:
- Every
{param:converter}uses a known converter name. - Every path parameter appears in the handler's signature.
- The converter's output type matches the handler's annotation
(e.g.
{id:int}withid: stris flagged as an error).
On failure, a ConfigurationError is raised (or sent as
lifespan.startup.failed) listing every violated route. On
success, the router is frozen — further route registration
raises RuntimeError.
A passing example:
import uuid
from http import HTTPMethod
from blackbull import BlackBull
app = BlackBull()
@app.route(path='/greet/{name:str}', methods=HTTPMethod.GET, name='greet')
async def greet(name: str):
return f'Hello, {name}!'
@app.route(path='/double/{n:int}', methods=HTTPMethod.GET, name='double')
async def double(n: int):
return {'input': n, 'result': n * 2}
if __name__ == '__main__':
print(app.url_path_for('greet', name='Alice')) # /greet/Alice
app.run(port=8000)
A failing example:
@app.route(path='/double/{n:int}', methods=HTTPMethod.GET)
async def double(n: str): # converter is int, annotation is str
return f'double of {n}'
Output before the server binds:
ConfigurationError: Route '/double/{n:int}' param 'n': converter 'int' yields 'int'
but annotation is <class 'str'>: int is not an instance of str
Route groups¶
app.group(middlewares=[...]) returns a RouteGroup whose
.route() method prepends the group's middlewares to every route
registered through it.
public = app.group(middlewares=[error_mw, logging_mw])
protected = app.group(middlewares=[error_mw, logging_mw, auth_mw])
@public.route(methods=[HTTPMethod.GET], path='/')
async def index(scope, receive, send):
await send(Response(b'<h1>Login</h1>'))
@protected.route(methods=[HTTPMethod.GET], path='/tasks')
async def get_tasks(scope, receive, send):
return [] # → JSONResponse
Per-route middlewares=[...] are appended after the group
middlewares:
# Effective chain: error_mw → logging_mw → auth_mw → json_body_mw → create_task
@protected.route(methods=[HTTPMethod.POST], path='/tasks',
middlewares=[json_body_mw])
async def create_task(scope, receive, send):
...
For middleware ordering semantics see Middleware.
WebSocket routes¶
from blackbull.utils import Scheme
@app.route(path='/ws', scheme=Scheme.websocket)
async def ws_handler(scope, receive, send):
...
WebSocket handlers always receive the full
(scope, receive, send) triplet — the simplified form does not
apply. See WebSockets for the handshake,
subprotocol negotiation, fragmented messages, permessage-deflate,
and the RFC 8441 (HTTP/2) transport.
Next¶
- Middleware — attaching per-route middleware, built-in middleware, common recipes.
- Error handling — 404 / 405 / 500 defaults,
@app.on_errorfor custom handlers. - Requests and responses — reading the request body, streaming responses, detecting client disconnection.
- WebSockets — handshake, subprotocols, fragmented messages, RFC 8441.