M-Bus Gateway
← Tilbage til blog
· 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.

Se FastAPI SQLModel guide eller TypeScript API-typer guide.