Add subdomain routing to aiohttp¶
aiohttp doesn't natively support routing handlers by hostname. The canonical workaround — recommended by aiohttp's maintainers in issue #1860 — is to split your application into sub-apps:
app.add_subapp("/", api, host="api.example.com")
app.add_subapp("/", ui, host="example.com")
app.add_subapp("/admin", admin, host="example.com")
This forces three separate Application instances just to vary by
host, and there's no wildcard form: per-tenant *.example.com routing
requires hand-coded middleware that strips the host and dispatches.
The awkward way (middleware)¶
@web.middleware
async def tenant_middleware(request, handler):
host = request.url.host or ""
if not host.endswith(".example.com"):
return web.HTTPNotFound()
tenant = host[: -len(".example.com")]
if "." in tenant or not tenant:
return web.HTTPNotFound()
request["tenant"] = tenant
return await handler(request)
Hostname-shape validation lives separately from the routes themselves;
every handler reaches into request["tenant"] blindly.
With URLPattern¶
URLPattern matches a URL, not "a request" — so the natural fit is
to use it inside the handler (or a middleware) on the request.url
yarl.URL object. yarlpattern accepts a yarl.URL directly, so there's
no str() round-trip:
from yarlpattern import URLPattern
TENANT = URLPattern({
"hostname": ":tenant.example.com",
"pathname": "/*",
})
@web.middleware
async def tenant_middleware(request, handler):
result = TENANT.exec(request.url) # yarl.URL accepted directly
if result is None:
return web.HTTPNotFound()
request["tenant"] = result.hostname["groups"]["tenant"]
return await handler(request)
For per-route URL-shape constraints, attach a URLPattern to each handler:
USERS = URLPattern({"hostname": ":tenant.example.com",
"pathname": "/api/v:version/users/:id(\\d+)"})
async def get_user(request):
result = USERS.exec(request.url)
if result is None:
return web.HTTPNotFound()
groups = {**result.hostname["groups"], **result.pathname["groups"]}
# groups = {'tenant': 'acme', 'version': '2', 'id': '42'}
return web.json_response(load_user(**groups))
app.router.add_get("/api/{tail:.*}", get_user) # broad aiohttp route
aiohttp's own router still dispatches the broad path; URLPattern narrows on the full URL (hostname + version + id) inside the handler.
What you get for free¶
- Hostname pattern matching aiohttp doesn't have. No sub-app
acrobatics, no
add_domain()config gymnastics. yarl.URLas input — fast path.request.urlis already ayarl.URL; yarlpattern reads components directly from it without re-parsing. The typicalpat.test(request.url)call is the fast path, not the slow one.- Wildcard subdomains work properly.
:tenant.example.commatchesacme.example.combut notfoo.bar.example.com(two labels) oracme-example.com(no boundary) — exactly what multi-tenant security requires. Hand-rolledendswith()checks routinely get this wrong. - One pattern, all components. Hostname constraint and path
constraint and per-segment regex (
:id(\\d+)) in one declaration. The aiohttp router only constrains the path.