-
Notifications
You must be signed in to change notification settings - Fork 0
FastAPI Programming Notes
In FastAPI endpoints you need a reference to a Session and/or an Engine.
These are usually injected using FastAPI's Depends, which requires you supply
a generator for database sessions. In my code, the generator is defined in the Database class.
In general the code is something like (this isn't how I did it):
DATABASE_URL = "sqlite+aiosqlite:///./example.sqlite3"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": True}) # False?
# a factory method for Sessions
SessionMaker = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_session() -> Session:
session = SessionMaker()
try:
yield session
finally:
session.close()Dependency injection using Depends:
router = APIRouter(tags=["users"])
@router.post("/users/", response_model=schemas.User)
def create_user(user: User, session: Session = Depends(get_session)) -> schemas.User:
session.add(**user)
session.commit()
session.refresh(user)FastAPI has a lot of (sometimes cludgy) optional parameters that you can access in route handlers (functions). These include the HTTP Request and Response objects, query parameters, and specific request headers.
To get the Request and Response objects in a route handler, add parameters with type hints:
from fastapi import APIRouter, Depends, Request, Response
@router.post("/users", ...)
async def create_user(user_data, request: Request, response: Response, ...):A POST request that creates a new resource (e.g. entity) should return the URL of the new resource
using the HTTP Location header. Location can be a relative URL, that is, not including the protocol or host name.
For example:
from fastapi import Depends, Request, Response
@router.post("/users", ...)
async def create_user(user_data: schemas.UserCreate,
request: Request, response: Response,
session = Depends(get_session)):
# Validate the request data and then...
result: models.User = await user_dao.create(session, user_data)
# Add Location of the new user. "url_for" performs reverse mapping
user_id = result.id
location = request.url_for("get_user", user_id=str(user_id))
response.headers["Location"] = str(location)
# result is serialized by FastAPI and used as response body
return resultIn a router definition, routes are typically written like this:
router = APIRouter(tags=["Data Source"])
@router.get("/sources", status_code=status.HTTP_200_OK)
async def get_data_sources(...)This handles GET /sources. If a client send GET /sources/, FastAPI returns 307 Temporary Redirect with a Location header that removes the trailing "/".
Conversely, if you write a route like this:
router = APIRouter(prefix="/sources/{source_id}/readings", tags=["Readings"])
@router.get("/", , status_code=status.HTTP_200_OK)
async def get_readings(...)this matches the path GET /sources/1/readings/ with trailing / (the get("/") is appended to the path prefix). If a client sends GET /sources/1/readings` then, again, FastAPI returns 307 with a Location header that includes the trailing slash.
Many of the routes require an Authorization header. The front-end web app includes in the request. But when a web browser handles a 307 Redirect, it typically omits the Authorization header in the second request, even if it was in the original request. So you get a 401 Unauthorized response.
I applied three elements to the solution.
-
Always write FastAPI router paths without trailing /, unless the entire path is "/".
-
Add an option to suppress redirects:
app = FastAPI(redirect_slashes=False, ...) -
Add middleware to remove trailing slash as needed. In
app/main.pyuse:@app.middleware("http") async def strip_trailing_slash(request: Request, call_next): """Remove trailing / from URLs for consistency.""" if request.url.path.endswith("/") and request.url.path != "/": request.scope["path"] = request.url.path.rstrip("/") return await call_next(request)
Note: I later removed this middleware. Based on documentation, I'm not sure its advisable to modify
scope["path"].
In FastAPI, the Request class is built on top of Starlette.
Request comes from starlette.routing.Request which inherits from HttpConnection of httpcore and its crummy documentation.
fastapi.Request exposes the underlying ASGI scope through request.scope. This attribute is useful for debugging, advanced middleware, routing logic, and instrumentation.
In any ASGI application, scope is a dictionary that contains all connection-level metadata about the request.
request.scope is the raw ASGI metadata available for every request.
- "method" - HTTP method
- URL path and query string
- Headers
- Client/server addresses
- ASGI version
- Type (
"http","websocket", etc.)
FastAPI does not alter the ASGI scope; it only adds some values before handing the request to your path handler.
You may inspect it:
@app.get("/items/{item_id}")
async def read_item(request: Request, item_id: int):
return request.scopescope["route"]: starlette.routing.Route is set by Starlette’s routing system once a matching route is found.
It is an instance of starlette.routing.Route**, representing the specific route object that matched the request.
-
scope["route"]is aRouteobject produced by Starlette after route resolution. - It identifies which FastAPI route matched the request.
The Route contains:
- The path pattern (e.g.,
"/items/{item_id}"):route.path - The endpoint function (your FastAPI path function):
route.endpoint.__name__ - The methods allowed
- Path parameter converters
- Routing metadata used internally
Example introspection:
@app.get("/items/{item_id}")
async def read_item(request: Request, item_id: int):
route = request.scope["route"]
return {
"path": route.path,
"name": route.name,
"endpoint": route.endpoint.__name__,
}You can record metrics keyed by the declared route, instead of the raw path.
Example: convert /items/123 → /items/{item_id} for Prometheus metrics.
route_pattern = request.scope["route"].path
metrics_counter.labels(route_pattern).inc()This lets you aggregate metrics for all item_id values.
FastAPI’s own APIRouter uses this to merge OpenAPI paths and to label handlers.
Custom middleware can behave differently depending on the matched route.
Example: attach rate limits, caching strategies, or security rules:
route = request.scope.get("route")
if route and route.name == "healthcheck":
skip_auth = TrueBecause route resolution happens before the request hits the endpoint, middleware has access to it.
Starlette uses the route’s internal converters to extract URL parameters. This is how FastAPI knows the correct parameter types and how to convert them.
Route objects support URL generation if they were configured with name=....
url = request.url_for(request.scope["route"].name)FastAPI uses route objects to:
- build OpenAPI documentation
- manage dependency injection trees
- determine operation-level metadata
*## 4. When scope["route"] May Be Missing
In some contexts, especially inside early lifecycle middleware, route matching has not occurred yet.
In such cases:
route = request.scope.get("route") # May be NoneThis is normal for:
- lifespan events
- server-level error handlers
- middleware running before routing
In the FastAPI @router decorator you can specify a response_model and on the function header you can add a type hint for the return value. For example:
@router.get("/users/{user_id}", response_model=schemas.User)
async def get_user(user_id: int, session=Depends(get_session)) -> schemas.User:
...Seems redundant.
The response_model and return type hint serve complimentary purposes:
- Primarily for editor support and type checking.
- FastAPI does not use it to generate OpenAPI documentation or enforce response serialization.
- The return type is the type of the response body, not
fastapi.Response. Unlike Django.
This FastAPI directive controls:
- Serialization: filters response fields according to the schema.
- Validation: ensures the returned data matches the Pydantic schema.
-
OpenAPI documentation: populates the response schema in the generated API docs (
/docsor/openapi.json).
If a validation error occurs it probably means a programming error or deployment error, i.e. database structure doesn't match code for schema.
Specify both.
In my router functions I return an ORM model object (models.User) not a Pydantic schema object. FastAPI converts this to a schema object itself. Then it serializes the return value into the body of the Response.
In app/main.py we add middleware for:
- CORS preventation (CORSMiddleware)
- Remove trailing slash from the request path (normalize request)
- Record metrics for Prometheus
- Log the request and response.
ChatGPT and Deepseek give different recommendations for order of adding middleware.
- CORSMiddleware registered first, runs last
- CORS headers should be the final modification before response
- Ensures CORS headers aren't overwritten by other middleware
- Path Normalization registered 2nd, runs 3rd
- Metrics should use normalized paths for consistent tracking
- metrics middleware can access
request.state.normalized_path(Deepseek's code)
- Metrics Middleware registered 3rd, runs 2nd
- Record normalized path for consistency
- Logging Middleware, registered 4th, runs 1st
- Logs complete request/response cycle
- Captures any errors from downstream middleware
- Log requests (added first, invoked last)
- Metrics collection
- Normalize path
- CORSMiddleware (added last, invoked first)
Justification for having CORSMiddleware invoked first are:
-
CORS must process browser preflight requests before anything else. A CORS preflight (
OPTIONS) request is not a normal application request. Browsers send it before sending the “real” request. Semantics are:- It should be cheap.
- It should not trigger logging, metrics, or your custom logic unnecessarily.
- It should return quickly with the correct CORS headers.
- Wrong middleware ordering can cause missing CORS headers, leading to client-side failures.
-
CORS controls HTTP semantics and response headers. CORS is an HTTP protocol-level concern:
- It must set the
Access-Control-Allow-*headers on responses. - It can short-circuit preflight requests.
- It determines which requests may proceed to the application.
To ensure consistency, CORS must run before any other middleware modifies the request or response. If another middleware intercepts or modifies the request first, CORS may operate on a partially modified or redirected request.
- It must set the
-
If the path is normalized before CORS, the preflight URL changes Example:
- Browser preflight sent to
/api//users/ - Your normalization converts it to
/api/usersNow the CORS check may evaluate rules for a different path than the browser intended. This can create: - Incorrect CORS rule matching
- Surprising inconsistencies
- Security policy holes
- Browser preflight sent to
-
Making CORS outermost matches how API gateways and reverse proxies handle CORS**
-
Gateways (Nginx, Cloudflare, AWS API Gateway) apply cross-origin logic at the perimeter layer, before:
- path rewriting
- logging modules
- metrics modules
- application routing
-
FastAPI/Starlette follows the same principle: CORS is fundamentally a “network edge” function.
-
-
This ordering aligns with Starlette’s own recommendations. Starlette’s CORS documentation states:
“CORS should typically be applied as a top-level middleware, so that it can handle preflight requests before they hit application logic.” (Starlette CORS documentation)