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}
Note that the words request, response, self, deferred and payload are reserved and cannot be used for path parameters.
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
Metadata
Release files for fastapi-resolve 1.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| fastapi_resolve-1.2.0.tar.gz | 13.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastapi_resolve-1.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 25.2 kB
Release files / fastapi_resolve-1.2.0.tar.gz
| Download URL | fastapi_resolve-1.2.0.tar.gz |
|---|---|
| Size | 13.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3ac4cc1269eddb5eef8c8d521aa9614ff42b9f30e636756a2450d49dacb4c81d
|
|
BLAKE2b-256 checksum How to use checksums |
5afc868ce2482b421f7ff4df5d6774022474ae8bc0552438803096e3d3481fc4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.3
|
Release files / fastapi_resolve-1.2.0-py3-none-any.whl
| Download URL | fastapi_resolve-1.2.0-py3-none-any.whl |
|---|---|
| Size | 11.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8dc7726aeb6610cca6858d64a4a8a72cc5f022521cc2a69223de37d09a2dc7e8
|
|
BLAKE2b-256 checksum How to use checksums |
d5c671f7b62f2bb93812851b20504b7fe4a7500e45b6ec15acf188a9f3d6814d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.3
|
Security for fastapi-resolve 1.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.