On this page
The Problem With Ten Endpoints That All Look the Same
You have ten API endpoints. Every one of them needs to return a request_id, a success flag, an inference timestamp, a disclaimer, and a data source list — in addition to the actual prediction payload. The naive move is to copy-paste that boilerplate into every response schema. Three months later, a field name changes and you’re hunting down ten files.
The more experienced instinct is to create a BaseResponse class and inherit from it — but inheritance breaks down when the prediction field changes type for every route. You end up reaching for Any, which throws away all type safety and kills the precision of your auto-generated OpenAPI documentation.
The real answer is a Generic response envelope: one schema definition that wraps any payload type while preserving strict typing end-to-end. VitalCheck uses exactly this pattern, defined in app/schemas/common.py. It’s the structural backbone of all fourteen API responses.
What You Need Before You Start: Python Generics and Pydantic Fundamentals
Knowledge Base:
- Familiarity with Python classes and type annotations
- Basic understanding of what an API response body is
- You’ve seen Pydantic’s
BaseModelbefore, even if only briefly - Optional but helpful: awareness of Python’s
typingmodule (TypeVar,Generic)
Environment (from pyproject.toml):
Python >= 3.11, < 3.13
pydantic >= 2.10.0
fastapi >= 0.115.0
Pydantic v2 is a complete rewrite of v1 with a Rust-powered core. The
Generic[T]support in v2 is first-class and directly influences OpenAPI schema generation — the two systems are aware of each other. This tutorial requires v2 specifically.
Python’s Generic[T] isn’t magic. When you subclass Generic[T], Python’s type system registers T as a placeholder that gets resolved at the point of use — for example, VitalCheckResponse[DiabetesPrediction]. Pydantic v2 reads this resolved type at model construction time (via __class_getitem__) and correctly generates the prediction field’s JSON schema from the concrete type, not from the abstract T.
A Russian Nesting Doll: How the Envelope Architecture Works
The response structure is layered like a matryoshka doll. The outermost shell — VitalCheckResponse — is identical on every endpoint. It contains operational metadata: request tracking, timing, disclaimer. Inside sits the prediction field, which holds a domain-specific payload that differs per endpoint. Inside that payload, shared sub-components like RiskScore may appear again.
This nesting gives any client a single parsing contract: always unwrap the outer shell first, then deserialize the inner prediction based on the endpoint you called.
Think of VitalCheckResponse[T] as a standardized shipping box. The box always has the same label format, tracking number, and sender information on the outside. What’s inside the box — the cargo — changes every time. Generics let us describe both the box and its contents in a single specification without losing information about what’s inside.
Walking Through common.py: Four Blocks, One Coherent Pattern
Block 1 — The Risk Level Taxonomy (Lines 12–26)
Before any model can return a prediction, we need a controlled vocabulary for risk. An uncontrolled string like "kinda risky" is useless to a client. An enum with four members is a contract.
The str mixin on the enum is subtle but important: it makes RiskLevel.HIGH == "high" evaluate to True. Without it, you get False, which breaks JSON comparison in client code and causes silent bugs in tests.
class RiskLevel(str, Enum):
LOW = "low" # probability < 0.30
MODERATE = "moderate" # probability 0.30–0.60
HIGH = "high" # probability 0.60–0.80
CRITICAL = "critical" # probability >= 0.80
The conversion function that maps a raw float to this enum lives directly below. I made it a pure function with no side effects — it takes a float, returns an enum value, touches no external state. That makes it trivially testable and reusable across all five risk endpoints without any coupling.
def probability_to_risk_level(prob: float) -> RiskLevel:
if prob >= 0.80:
return RiskLevel.CRITICAL
elif prob >= 0.60:
return RiskLevel.HIGH
elif prob >= 0.30:
return RiskLevel.MODERATE
return RiskLevel.LOW
Block 2 — The Shared Risk Score Sub-Model (Lines 29–32)
RiskScore is the most-reused sub-component in the codebase. Every classification endpoint embeds it. Defining it once in common.py means that when the OpenAPI spec is generated, Pydantic registers it as a single named schema component — $ref: '#/components/schemas/RiskScore' — rather than inlining it redundantly into every endpoint response body.
class RiskScore(BaseModel):
# ge=0.0, le=1.0 are Pydantic field-level validators.
# If a model returns 1.0001 due to floating point error, Pydantic raises
# a ValidationError before the response ever leaves the server.
probability: float = Field(ge=0.0, le=1.0, description="Predicted probability (0–1)")
risk_level: RiskLevel = Field(description="Categorical risk tier")
# Not all endpoints have a reference population to compute percentiles.
# Using 'float | None' with a default of None is idiomatic Python 3.10+ syntax.
percentile: float | None = Field(default=None, description="Percentile vs. reference population")
Block 3 — The Operational Metadata Carrier (Lines 35–44)
ResponseMetadata separates domain data from operational data. The prediction tells you what the model thinks. The metadata tells you how long it took, where the data came from, and what legal disclaimer applies. Keeping these concerns in separate fields makes it easy for monitoring infrastructure to extract inference_ms without knowing anything about the domain.
class ResponseMetadata(BaseModel):
inference_ms: float = Field(description="Wall-clock inference time in milliseconds")
# The disclaimer has a default value — it ships on every response automatically.
# An endpoint can override it, but they all opt-in to the correct default.
disclaimer: str = Field(
default=(
"This API provides estimates for informational and research purposes only. "
"It is not a medical device and must not be used for clinical diagnosis or "
"treatment decisions. Always consult a qualified healthcare professional."
)
)
# list[str] with a factory default — each endpoint populates this with
# the specific datasets its model was trained on.
data_sources: list[str] = Field(default_factory=list)
Block 4 — The Generic Envelope (Lines 47–52)
This is where it comes together. Two imports enable the pattern: Generic and TypeVar from Python’s standard typing module.
from typing import Generic, TypeVar
# T is an unconstrained type variable. It will be substituted at point-of-use.
T = TypeVar("T")
# The key is the multiple inheritance: BaseModel gives us Pydantic behavior,
# Generic[T] registers T as a substitutable parameter.
class VitalCheckResponse(BaseModel, Generic[T]):
success: bool = True
# uuid.uuid4() generates a new UUID per instance, never per class.
# The lambda wrapper is critical: without it, Field would call uuid4()
# once at class definition time and every response would share the same ID.
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
model_version: str = "1.0.0"
# T is resolved when you write VitalCheckResponse[DiabetesPrediction].
# At that point, Pydantic knows prediction must be a DiabetesPrediction.
prediction: T
metadata: ResponseMetadata
Here’s how a route handler uses it — the type parameter makes prediction concrete:
# In app/routers/risk.py — the [DiabetesPrediction] resolves T.
# FastAPI reads this annotation to generate the OpenAPI response schema.
@router.post("/diabetes", response_model=VitalCheckResponse[DiabetesPrediction])
async def predict_diabetes(req: DiabetesRequest, ...) -> VitalCheckResponse[DiabetesPrediction]:
...
return VitalCheckResponse(
prediction=DiabetesPrediction(risk=risk, ...),
metadata=ResponseMetadata(inference_ms=elapsed, data_sources=DIABETES_SOURCES),
)
Notice that success, request_id, and model_version are never set in the route handler. They have defaults. The handler only provides what’s unique per request.
Two Things That Look Fine But Will Burn You
The default_factory Lambda
This one trips up almost everyone migrating from Pydantic v1:
# WRONG — uuid4() is called once at class definition time.
# Every single response shares the same request_id forever.
request_id: str = Field(default=str(uuid.uuid4()))
# CORRECT — the lambda is called once per model instantiation.
# Each response gets a fresh UUID.
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
The default_factory parameter exists precisely because mutable or side-effecting defaults must be produced fresh on each call.
Generic Type Information Is Erased at Runtime
Generic[T] type information doesn’t survive to runtime in Python. That means isinstance(response, VitalCheckResponse[DiabetesPrediction]) raises a TypeError. You can’t use parameterized generics in isinstance checks. At runtime, the object is simply an instance of VitalCheckResponse. Reserve type parameter checks for static analysis tools (mypy, pyright) and IDE tooling only.
What the OpenAPI spec actually looks like
When FastAPI builds the OpenAPI specification at startup, it inspects the response_model annotation on each route. For VitalCheckResponse[DiabetesPrediction], Pydantic v2 creates a parameterized model — essentially a dynamically generated subclass with prediction bound to DiabetesPrediction. The generated schema for /diabetes shows the full nested structure of a diabetes prediction with all its fields and types.
If you used Any instead of T, the OpenAPI schema for prediction would be {} — a schema that matches any JSON value. Every endpoint’s documentation would say “prediction can be anything.” That’s useless for API consumers and can’t be used to auto-generate client SDKs.
The generic envelope pattern is the correct abstraction whenever you have a stable “container” shape and a variable “contents” shape. You’ll encounter this in pagination wrappers, event envelopes, API gateways, and anywhere responses share structure but differ in payload. The mechanics are always the same: TypeVar, Generic[T], concrete parameterization at the point of use.