FastAPI uses typed request models to parse and validate data at the HTTP boundary. A response model defines the outward shape and can filter fields from a returned object. These are two different contracts: an approval command may contain an operation ID and private reason, while its response may contain only case ID, public status, and revision. Validation stops malformed values before a service call, but it cannot decide whether reviewer 81 may approve case 62. A response model reduces accidental exposure, but it is safer for the service to produce a deliberate public projection as well. An error contract must distinguish bad input, missing private resources, stale revisions, and unexpected failures.
FastAPI Input, Output, and Error Contracts
Working case
A permit approval route accepts an arbitrary JSON object and forwards it to a service. A client adds an internal field named approved_by and the service accidentally trusts it. The route then returns a full ORM object containing internal reviewer notes. A follow-up adds an input model that rejects unknown fields and bounds the operation ID and reason, plus a separate output model with only approved fields. The service still derives reviewer identity from the verified request dependency and checks the case under current permissions. A revision mismatch becomes a controlled conflict. An unexpected database exception remains an internal error without echoing the submitted reason.
Implementation boundary
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field
from .services import ApprovalService, Reviewer, approval_service, current_reviewer
router = APIRouter()
class ApprovalCommand(BaseModel):
model_config = ConfigDict(extra="forbid")
operation_id: str = Field(min_length=16, max_length=81)
reason: str = Field(min_length=3, max_length=470)
expected_revision: int = Field(ge=1)
class PermitPublic(BaseModel):
id: int
status: Literal["pending", "approved"]
revision: int
@router.post("/permits/{case_id}/approval", response_model=PermitPublic)
def approve_permit(
case_id: int,
command: ApprovalCommand,
reviewer: Annotated[Reviewer, Depends(current_reviewer)],
service: Annotated[ApprovalService, Depends(approval_service)],
):
result = service.approve_once(reviewer.id, case_id, command)
return {"id": result.case_id, "status": result.status, "revision": result.revision}Define one input model per command and a separate public response model. Set extra-field behavior deliberately; when the contract should reject unknown input, forbid it rather than silently accepting future-looking fields. Bound strings and integer ranges near the HTTP edge, then apply domain checks in the service. Do not accept reviewer_id, approval state, or tenant scope as trusted client attributes. Use response_model on the route and return a small dictionary or DTO that contains only intended fields. Map expected domain failures to stable status codes and short machine-readable messages. Keep validation details useful without reflecting secrets from request bodies or private database objects.
Cost and boundaries
Parsing and response validation consume CPU roughly proportional to the accepted payload and response size. A response model cannot make an unbounded database query cheap; limit rows before building a list of models. Rejecting extra fields makes contract drift visible to callers, but it can require a coordinated rollout when clients are already sending undeclared data. Rich error bodies can help debugging, yet echoing raw request fragments increases data exposure and log volume. Measure rejected input rate by field and client version before changing a contract. Use explicit response projections to reduce object serialization work and avoid lazy ORM loads after the database session closes.
Failure trace
Submit a missing operation ID, a short ID, an overlong reason, an unknown approved_by field, and a wrong primitive type. None should reach the approval service. Submit a valid shape as reviewer 81 for case 62; validation must pass but authorization must fail. Return an internal note from a deliberately flawed service response and confirm the public response model does not expose it, then remove the note from the service projection too. Race a stale revision and require a controlled conflict rather than a generic 500. Trigger a database exception and verify the response omits raw SQL and the submitted private reason.
Verification
- Unknown command fields and invalid ranges stop before mutation.
- Reviewer identity is never accepted from the body.
- The outward response contains only public fields.
Practice drill
Write approval request and public response models for a permit workflow. Seed reviewer 47, reviewer 81, and case 62. Enumerate each client-controlled field and its allowed range, then write negative HTTP tests before implementing the route. Add a service result that carries an internal audit note and ensure the outward projection excludes it. Add an optimistic revision check and map its conflict outcome. Use a list endpoint with a fixed page size to verify output validation stays bounded. Inspect generated API schema and one real JSON response for agreement on field names and nullability.
Decision note
Validate input shape at the transport edge, keep domain permission in the service, and construct a narrow public response.
Common Mistakes
- Using one model for private input, database state, and public output.
- Treating schema validity as object permission.
- Returning an ORM object whose lazy fields may load after session cleanup.
Related lessons
FastAPI Contract and Resource Boundaries; FastAPI Dependencies and Object Authorization; FastAPI Async Sessions and Transaction Ownership; FastAPI Lifespan, Background Work, and Delivery State; API Contract Evolution and Client Migration; API Mutation and Failure Contracts.
Apply and check
Build Project: FastAPI permit service boundaries and review Web Development: FastAPI contract and resource quiz.
