Deferred routing and content negotiation for FastAPI
Project description
fastapi-resolve
Extends FastAPI with two new return types for route handlers: Deferred and Negotiated.
Deferred lets a handler resolve a URL prefix — like a tenant slug or project identifier — and delegate the remaining path to a dedicated set of sub-handlers. The returned instance carries the resolved context as properties and declares its own routes as decorated methods.
Negotiated adds HTTP content negotiation, serving different response formats based on the client's Accept header.
Installation
pip install fastapi-resolve
Quick Start
from fastapi import FastAPI
from fastapi_resolve import Router
app = FastAPI()
router = Router()
@router.get("/hello")
def hello():
return {"message": "hello world"}
Router.use(app, router)
Router.use adds a catch-all route, so register any direct FastAPI routes (health checks, OpenAPI, etc.) before calling it.
Route Patterns
Route patterns use /-separated sections. Each section is a literal, a typed parameter, or a wildcard.
Parameter Types
| Pattern | Matches | Example |
|---|---|---|
int:name |
Integer | /users/42 |
date:name |
Date (yyyy-MM-dd) |
/log/2025-03-15 |
uuid:name |
UUID | /items/550e8400-... |
slug:name |
Any string | /posts/hello-world |
Parameters are extracted and injected into the handler by name:
@router.get("/users/int:id")
def get_user(id: int):
return {"id": id}
Wildcards
A * at the end of a pattern matches any remaining path. This is mainly used with Deferred prefix handlers:
@router.get("/slug:tenant/*")
def resolve_tenant(tenant: str) -> TenantEndpoints | None:
...
Handler Parameters
Handlers can request any combination of these parameters by name:
@router.get("/items/int:id")
async def get_item(request: Request, response: Response, id: int):
...
request— the FastAPI/StarletteRequestobjectresponse— the FastAPI/StarletteResponseobjectself— theDeferredinstance, when the handler is a method on aDeferredsubclassdeferred— the context provided by the resolvedDeferredinstance (see Splitting Routes Across Files). By default this is theDeferredinstance itself, but subclasses can overridecontext()to return a domain object insteadpayload— the parsed JSON body, for POST/PUT/PATCH/DELETE requests. If the parameter has a Pydantic model annotation, the payload is validated and converted automatically- Route parameters — matched by name from the pattern (e.g.
idfromint:id)
All parameters are optional — declare only the ones you need.
HTTP Methods
@router.get("/path")
@router.post("/path")
@router.put("/path")
@router.patch("/path")
@router.delete("/path")
@router.head("/path")
@router.route("/path", methods=["GET", "POST"])
@router.get("/path") will also automatically register with a HEAD method. Any body will be stripped for HEAD requests. you can check request.method to skip expensive work for HEAD requests.
Deferred Routing
A handler can return a Deferred subclass to delegate routing to a nested scope. The subclass carries context as instance properties and declares its own routes via a class-level Router:
from fastapi_resolve import Deferred, Router
class TenantEndpoints(Deferred):
router = Router()
def __init__(self, tenant):
self.tenant = tenant
@router.get("")
def home(self, request):
return templates.TemplateResponse(
"home.html",
{"request": request, "tenant": self.tenant}
)
@router.get("dashboard")
def dashboard(self, request):
return templates.TemplateResponse(
"dashboard.html",
{"request": request, "tenant": self.tenant}
)
@router.get("users/int:id")
def get_user(self, id: int):
return {"tenant": self.tenant, "user_id": id}
The top-level route resolves the prefix and returns the instance:
router = Router()
@router.get("/slug:tenant/*")
def resolve_tenant(tenant: str) -> TenantEndpoints | None:
if tenant == "acme":
return TenantEndpoints("acme")
# Returning None falls through to try other routes
This handles:
| Request | Remaining path | Handler called |
|---|---|---|
GET /acme |
"" |
home |
GET /acme/dashboard |
dashboard |
dashboard |
GET /acme/users/42 |
users/42 |
get_user |
GET /acme/unknown |
unknown |
404 |
GET /nonexistent |
— | Falls through |
Nested Deferred
A deferred handler can itself return another Deferred, creating nested scopes. Here a tenant branch resolves a project within its scope and returns a ProjectBranch whose context() exposes the Project domain object:
class ProjectBranch(Deferred):
router = Router()
def __init__(self, project: Project):
self.project = project
def context(self):
return self.project
@router.get("")
def overview(self, request):
return templates.TemplateResponse(
"project.html",
{"request": request, "project": self.project}
)
class TenantBranch(Deferred):
router = Router()
def __init__(self, tenant):
self.tenant = tenant
@router.get("")
def overview(self, request):
return templates.TemplateResponse(
"overview.html",
{"request": request, "tenant": self.tenant}
)
@router.get("slug:project/*")
def resolve_project(self, project: str) -> ProjectBranch | None:
project = Project.get_by_slug(self.tenant, project)
if project:
return ProjectBranch(project)
This matches paths like /acme/website/ — the first Deferred resolves the tenant, the second resolves the project within that tenant.
Fallthrough
When a handler returns None, the router continues trying other matching routes. Once a handler returns a Deferred instance (non-None), the router is committed to that branch — if no deferred handler matches the remaining path, the result is 404.
Splitting Routes Across Files
Routes don't have to be methods on the Deferred subclass. You can define routers in separate modules and attach them as class attributes — the Deferred base class picks up any Router instances on the class automatically.
External handlers receive the value returned by context() as the deferred parameter. By overriding context() to return a domain object, external routers can import that domain type directly — no coupling to the routing layer and no circular imports.
# articles.py
from objects.project import Project
from fastapi_resolve import Router
router = Router()
@router.get("articles")
def list_articles(deferred: Project, request):
return templates.TemplateResponse(
"articles.html",
{"request": request, "project": deferred}
)
@router.get("articles/int:id")
def get_article(deferred: Project, request, id: int):
article = Article.get(deferred, id)
return templates.TemplateResponse(
"article.html",
{"request": request, "project": deferred, "article": article}
)
# project_branch.py
from objects.project import Project
from fastapi_resolve import Deferred, Router
from articles import router as article_router
class ProjectBranch(Deferred):
router = Router()
articles = article_router
def __init__(self, project: Project):
self.project = project
def context(self):
return self.project
@router.get("")
def home(self, request):
return templates.TemplateResponse(
"project.html",
{"request": request, "project": self.project}
)
articles.py imports Project from the domain layer and gets full type safety on the deferred parameter. It has no dependency on ProjectBranch or any routing code.
A Deferred subclass can have any number of Router attributes — they're all collected and their routes are tried in order.
Content Negotiation
Return a Negotiated instance to serve different formats based on the Accept header:
from fastapi_resolve import Negotiated
@router.get("/article/int:id")
def get_article(request, id: int) -> Negotiated:
article = Article.get(id)
return Negotiated({
"text/html": lambda: templates.TemplateResponse(
"article.html",
{"request": request, "article": article}
),
"application/json": lambda: article.to_dict(),
})
The resolution follows standard HTTP content negotiation: quality factors are respected (text/html;q=0.9), wildcards work (*/*, text/*), and a 406 is returned if nothing matches. When no Accept header is present, */* is assumed.
Negotiated works both at the top level and inside deferred handlers.
Wiring It Up
from fastapi import FastAPI
from fastapi_resolve import Router
app = FastAPI()
router = Router()
# ... register routes on router ...
Router.use(app, router)
You can pass multiple routers to Router.use — they're tried in order:
Router.use(app, tenant_router, api_router, fallback_router)
Logging
fastapi-resolve uses Python's logging module under the fastapi_resolve namespace. Set the FASTAPI_RESOLVE_LOG_LEVEL environment variable to enable debug output:
export FASTAPI_RESOLVE_LOG_LEVEL=DEBUG
This traces the full resolution chain — route matching, Deferred branching, fallthrough, and content negotiation:
DEBUG:fastapi_resolve.routing: Resolving GET 'acme/dashboard' against 4 route(s)
DEBUG:fastapi_resolve.routing: Matched 'slug:project/*' → getProject with params {'project': 'acme'}
DEBUG:fastapi_resolve.routing: Deferred to Project with 'dashboard' as remaining path
DEBUG:fastapi_resolve.routing: Resolving GET 'dashboard' against 3 route(s)
DEBUG:fastapi_resolve.routing: Matched 'dashboard' → Project.getDashboard with params {}
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_resolve-1.0.1.tar.gz.
File metadata
- Download URL: fastapi_resolve-1.0.1.tar.gz
- Upload date:
- Size: 12.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a9348ce5dd15723e3c04d27f4eda27f68d1359a9fd4615d9f4d6d6def6794b9d
|
|
| MD5 |
b2fd855df92a5be23647aa83e984655a
|
|
| BLAKE2b-256 |
07b64b333065425bed6946cf48f5e6dee722b6a40294a30a4995da134696b255
|
File details
Details for the file fastapi_resolve-1.0.1-py3-none-any.whl.
File metadata
- Download URL: fastapi_resolve-1.0.1-py3-none-any.whl
- Upload date:
- Size: 11.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1906b3c2a5803a2496bda3a90c3531dfd24ec482632e737634f9393474bb9a3f
|
|
| MD5 |
b6f545df9536a45c5cef04178b3087b4
|
|
| BLAKE2b-256 |
f20daf40dfb60f72511ac41177f61495248cf3dccc2ef5b676325cc99b467efb
|