FastAPI¶
The integration lives in depin.ext.fastapi and needs the extra:
uv add 'pydepin[fastapi]'
It is two pieces: middleware that opens a depin scope around every request, and an annotation that resolves a dependency into a route handler.
Wiring an app¶
import contextlib
from collections.abc import AsyncGenerator
from fastapi import FastAPI, Request
from depin import Container, FrozenContainer
from depin.ext.fastapi import Inject, install
from .wiring import infra, services
def create_app(container: FrozenContainer | None = None) -> FastAPI:
di = container if container is not None else Container(infra, services).scope_value(Request).freeze()
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
yield
await di.aclose()
app = FastAPI(lifespan=lifespan)
@app.get('/users/{uid}')
async def get_user(uid: int, svc: Inject[UserService]) -> User:
return await svc.get(uid)
install(app, di)
return app
Taking the container as an argument is what makes the app testable: a test
passes its own graph instead of patching module state. aclose() in the
lifespan drains the singletons that own resources. Call install after every
path operation and router has been registered, before startup. A repeated call
with the same container compiles routes added since the first call; routes
added after the final call remain correct on the compatibility resolver but do
not receive the compiled fast path.
install is tested against FastAPI 0.133 with Starlette 1.1 and the latest
allowed pair. If a future framework shape cannot be compiled, it raises
FastAPIIntegrationError at setup and tells you to upgrade or use the eager
compatibility path:
from depin.ext.fastapi import RequestScope
app.add_middleware(RequestScope, container=di)
RequestScope opens one eager depin frame for every HTTP request. install
hosts the container immediately but opens a frame only when resolution needs a
scoped value, a request seed, or a request-owned resource. Singleton-only and
ordinary transient routes therefore avoid an empty request frame. This changes
only activation cost: construction identity, teardown order, failures,
streaming, background work, and WebSocket lifetime are unchanged.
Inject[T]¶
Inject[T] is a type-level shortcut. To the type checker the parameter is
plain T; at runtime it carries an Annotated[object, Depends(...)] marker
whose resolver retains the depin key. FastAPI therefore resolves it through
its normal dependency plumbing without changing the handler's static service
type. There is no default-value marker at the call site, and no # noqa: B008
waiver.
Resolving it raises ContainerNotBoundError when no container is hosted in the
current context. A missing RequestScope is the usual cause, not the
definition: Inject[T] reads whichever container the current context carries,
so a container published elsewhere — by Host.activated() in a lifespan, for
instance — satisfies it just as well. Reached that way no scope is open, so a
scoped provider then fails with OutsideScopeError rather than
ContainerNotBoundError.
One scope per request¶
RequestScope is implemented directly against the ASGI protocol rather than
Starlette's BaseHTTPMiddleware, so streaming responses, server-sent events and
WebSockets pass through unbuffered. A connection scope that is neither http
nor websocket — the lifespan scope above all — is forwarded untouched: no
depin scope is opened and nothing is published. A websocket is scoped and
hosted exactly like an HTTP request, but it is not seeded, because it has no
request-body semantics and Request is HTTP-shaped.
Every scoped provider is therefore built once per request and torn down when the response finishes:
@services.scoped()
async def open_session(db: Database) -> AsyncGenerator[Session]:
session = await db.begin()
yield session
await session.close()
Reading the request¶
For HTTP requests, install records FastAPI's actual Request and places it
into the scope frame only if a provider reads the matching scope_value key. A
scoped provider reads it back because the container declares the key with
scope_value(Request), as the wiring above does; without that declaration the
graph fails at freeze():
@services.scoped()
def current_tenant(request: Request) -> Tenant:
return Tenant(request.headers['x-tenant'])
The Request in a provider is metadata only
It carries no receive channel: headers, URL, cookies and state are readable, but reading the body through it raises rather than consuming the stream the route handler needs. The body belongs to the route's own typed parameters, where FastAPI parses it once.
Testing an app¶
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.asyncio
async def test_get_user():
di = Container(fake_infra, services).freeze()
transport = ASGITransport(app=create_app(di))
async with AsyncClient(transport=transport, base_url='http://t') as client:
response = await client.get('/users/1')
assert response.status_code == 200
await di.aclose()
Because the transport drives the real ASGI app, the middleware runs and the per-request scope behaves exactly as in production.