· Pydantic· FastAPI· Python· API· validering· schemas· TypeScript· OpenAPI
Pydantic v2 API-validering i FastAPI — schemas, validators og strict mode
Pydantic v2 med FastAPI: Create/Out-schemas, field validators, discriminated unions, strict mode, custom types og performance forbedringer vs. v1.
Af M-Bus Gateway
M-Bus Gateway platformen bruger Pydantic v2 til all request/response-validering i FastAPI. Her er mønstrene der giver type-sikker, selvdokumenterende API.
Pydantic v2: Hvad ændrede sig fra v1?
Pydantic v2 (rust-baseret kerne — 5-50× hurtigere):
→ model_validator → @model_validator(mode="before"/"after")
→ @validator → @field_validator med ny signatur
→ .dict() → .model_dump()
→ .json() → .model_dump_json()
→ orm_mode = True → model_config = ConfigDict(from_attributes=True)
→ __fields__ → model_fields
→ schema() → model_json_schema()
Breaking changes fra v1:
→ @validator: "cls, v, values" → "cls, v, info" (FieldValidationInfo)
→ Strict mode slår None-coercion fra (str → int fejler)
→ UUID er nu native UUID type (ikke str)
→ datetime auto-konverteret til UTC med tzinfo
Create/Out schema-mønster
# server/src/properties/schemas.py
from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime
from uuid import UUID
class PropertyIn(BaseModel):
"""Request body til oprettelse af ejendom."""
name: str = Field(min_length=1, max_length=200)
address: str = Field(min_length=5)
zip_code: str = Field(pattern=r"^\d{4}$")
city: str
country: str = Field(default="DK", pattern=r"^[A-Z]{2}$")
heated_area_m2: float = Field(gt=0, le=50_000)
tenant_id: UUID | None = None # Kun super_admin kan sætte dette
@field_validator("zip_code")
@classmethod
def validate_zip(cls, v: str) -> str:
"""Validér dansk postnummer 1000–9999."""
if not (1000 <= int(v) <= 9999):
raise ValueError("Postnummer skal være 1000-9999")
return v
class PropertyOut(BaseModel):
"""Response schema — kun felter lejer/udlejer må se."""
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
address: str
zip_code: str
city: str
country: str
heated_area_m2: float
tenant_id: UUID
created_at: datetime
# Bemærk: deleted_at og deleted_by eksponeres IKKE
@field_validator("created_at", mode="before")
@classmethod
def ensure_utc(cls, v: datetime) -> datetime:
"""Sikr at datoer altid returneres med UTC tzinfo."""
if v.tzinfo is None:
return v.replace(tzinfo=UTC)
return v
# FastAPI endpoint:
@router.post("", response_model=PropertyOut, status_code=201)
async def create_property(body: PropertyIn, ...):
...
Discriminated unions: Multi-type responses
# Diskrimineret union til aflæsninger af forskellig type:
from typing import Literal, Annotated
from pydantic import Discriminator, Tag
class HCAReading(BaseModel):
type: Literal["hca"] = "hca"
unit: Literal["units"] = "units"
value: float
timestamp: datetime
class HeatMeterReading(BaseModel):
type: Literal["heat"] = "heat"
unit: Literal["kWh"] = "kWh"
value: float
flow_temp: float | None = None
return_temp: float | None = None
timestamp: datetime
class WaterMeterReading(BaseModel):
type: Literal["water"] = "water"
unit: Literal["m3"] = "m3"
value: float
timestamp: datetime
# Union med diskriminator på "type"-feltet:
AnyReading = Annotated[
HCAReading | HeatMeterReading | WaterMeterReading,
Field(discriminator="type"),
]
class ReadingListOut(BaseModel):
readings: list[AnyReading]
total: int
# FastAPI returnerer korrekt JSON per type — ingen isinstance() checks
Nested schemas og computed fields
from pydantic import computed_field
class OccupancyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
unit_id: UUID
tenant_name: str
move_in_date: datetime
move_out_date: datetime | None = None
@computed_field
@property
def is_active(self) -> bool:
"""Beregnet felt — ikke gemt i DB."""
return self.move_out_date is None
@computed_field
@property
def duration_days(self) -> int | None:
"""Antal dage i perioden (None hvis stadig aktiv)."""
if not self.move_out_date:
return None
return (self.move_out_date - self.move_in_date).days
class UnitOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
property_id: UUID
name: str
floor: int | None = None
area_m2: float
# Nested schema fra relationship:
active_occupancy: OccupancyOut | None = None
Strict mode: Ingen stille koercion
from pydantic import ConfigDict
class StrictInput(BaseModel):
"""
Strict mode: "123" → int fejler (ikke 123).
Bruges ved interne API-kald der altid sender korrekte typer.
"""
model_config = ConfigDict(strict=True)
meter_id: UUID # "abc" → ValidationError (ikke silent cast)
value: float # "1.5" → ValidationError (ikke 1.5)
timestamp: datetime # int → ValidationError (ikke datetime)
# Selektiv strict pr. felt:
from pydantic import Field
from typing import Annotated
StrictFloat = Annotated[float, Field(strict=True)]
StrictInt = Annotated[int, Field(strict=True)]
class MixedSchema(BaseModel):
name: str # Tillader int → str koercion
value: StrictFloat # "1.5" → ValidationError
count: StrictInt # 1.0 → ValidationError
Custom validator: Energimærke validering
from pydantic import field_validator, model_validator
from typing import Self
class PropertyConfigIn(BaseModel):
distribution_method: Literal["30_70", "area", "equal"]
fixed_share_pct: float = Field(default=30.0, ge=0, le=100)
variable_share_pct: float = Field(default=70.0, ge=0, le=100)
floor_correction: bool = False
floor_correction_factors: dict[int, float] | None = None
@model_validator(mode="after")
def validate_shares_sum_to_100(self) -> Self:
total = self.fixed_share_pct + self.variable_share_pct
if abs(total - 100.0) > 0.01:
raise ValueError(
f"Fast + variabel andel skal summere til 100% (er {total}%)"
)
return self
@model_validator(mode="after")
def validate_floor_correction_data(self) -> Self:
if self.floor_correction and not self.floor_correction_factors:
raise ValueError(
"floor_correction_factors påkrævet når floor_correction=True"
)
return self
OpenAPI schema eksempel: Automatisk generering
# FastAPI genererer OpenAPI 3.0 schema automatisk fra Pydantic-modeller:
# GET /openapi.json
# Pydantic Field-metadata → OpenAPI properties:
class MeterIn(BaseModel):
serial_number: str = Field(
min_length=4,
max_length=20,
description="Fabrikantens serienummer fra etiketten",
examples=["66073591"],
)
manufacturer: str = Field(
description="3-bogstavers FAB ID (OMS standard)",
examples=["ELS", "KAM", "TCH"],
)
meter_type: Literal["hca", "heat", "water", "electricity", "gas"]
mid_expiry_date: datetime | None = Field(
default=None,
description="MID-certifikat udløbsdato. None = ikke MID-certificeret",
)
# → Genererer komplet OpenAPI schema med validering, eksempler og beskrivelser
# → TypeScript-klient kan auto-genereres fra OpenAPI schema
Konklusion
Pydantic v2 er 5-50× hurtigere end v1 og introducerer klarere mønstre: Create/Out-skema-adskillelse, discriminated unions til multi-type APIs, @model_validator til cross-felt validering og strict mode til intern API-sikkerhed. FastAPI integrerer automatisk med Pydantic til OpenAPI-generering — ingen separat dokumentationsvedligeholdelse.