M-Bus Gateway
← Tilbage til blog
· Pydantic· FastAPI· datavalidering· Python· API· IoT· SQLModel· type hints· schema

Pydantic datavalidering i FastAPI — mønstre for IoT og SaaS

Pydantic v2 til request/response validering i FastAPI: nested models, custom validators, discriminated unions for IoT-data, partial updates og strict vs. lax mode.

Af M-Bus Gateway

Pydantic v2 er fundamentet for FastAPI's automatiske validering og dokumentation. Her er mønstrene der giver robust API-design til IoT-platforme med heterogene data.


Request vs. Response models: Adskil dem altid

# server/src/properties/schemas.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from uuid import UUID
from datetime import datetime

# CREATE (hvad klienten sender):
class PropertyCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    address: str = Field(min_length=5, max_length=500)
    bbr_id: Optional[str] = Field(default=None, pattern=r"^\d{15}$")
    area_m2: float = Field(gt=0, le=100_000)
    unit_count: int = Field(gt=0, le=10_000)
    # tenant_id sættes aldrig af klient — kun fra JWT

# UPDATE (partial — alle felter valgfrie):
class PropertyUpdate(BaseModel):
    name: Optional[str] = Field(default=None, min_length=1, max_length=200)
    address: Optional[str] = Field(default=None, min_length=5, max_length=500)
    area_m2: Optional[float] = Field(default=None, gt=0)
    # BBR-ID kan ikke ændres efter oprettelse

# RESPONSE (hvad API'et returnerer):
class PropertyOut(BaseModel):
    id: UUID
    name: str
    address: str
    bbr_id: Optional[str]
    area_m2: float
    unit_count: int
    tenant_id: UUID
    created_at: datetime
    updated_at: datetime
    # deleted_at eksponeres ALDRIG i API

    model_config = {"from_attributes": True}  # SQLModel/ORM compat

Custom validators: BBR-ID og CVR

from pydantic import field_validator, model_validator

class PropertyCreate(BaseModel):
    name: str
    bbr_id: Optional[str] = None
    cvr_number: Optional[str] = None
    area_m2: float

    @field_validator("bbr_id")
    @classmethod
    def validate_bbr_id(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        if not v.isdigit() or len(v) != 15:
            raise ValueError("BBR-ID skal være 15 cifre")
        return v

    @field_validator("cvr_number")
    @classmethod
    def validate_cvr(cls, v: Optional[str]) -> Optional[str]:
        if v is None:
            return v
        # CVR: 8 cifre
        cleaned = v.replace("-", "").replace(" ", "")
        if not cleaned.isdigit() or len(cleaned) != 8:
            raise ValueError("CVR-nummer skal være 8 cifre")
        return cleaned

    @model_validator(mode="after")
    def validate_area_consistency(self) -> "PropertyCreate":
        # Forretningsregel: Areal skal passe til enhedsantal
        if self.area_m2 < self.unit_count * 20:
            raise ValueError(
                f"Areal {self.area_m2}m² virker lavt for {self.unit_count} enheder"
                f" (min. 20 m²/enhed)"
            )
        return self

Discriminated unions: IoT payload-parsing

wM-Bus data kan komme i mange format afhænging af målertype:

from pydantic import BaseModel, Discriminator, Tag
from typing import Annotated, Union, Literal

class HCAReading(BaseModel):
    meter_type: Literal["hca"]
    current_hca: int
    previous_hca: int
    set_date: str
    status: str

class HeatMeterReading(BaseModel):
    meter_type: Literal["heat"]
    total_energy_kwh: float
    current_power_kw: Optional[float]
    flow_temperature_c: Optional[float]
    return_temperature_c: Optional[float]
    status: str

class WaterMeterReading(BaseModel):
    meter_type: Literal["water"]
    total_water_m3: float
    alarm_bits: int = 0
    status: str

# Discriminated union — Pydantic vælger korrekt model baseret på meter_type:
AnyReading = Annotated[
    Union[
        Annotated[HCAReading, Tag("hca")],
        Annotated[HeatMeterReading, Tag("heat")],
        Annotated[WaterMeterReading, Tag("water")],
    ],
    Discriminator("meter_type"),
]

class MQTTPayload(BaseModel):
    gateway_id: str
    timestamp: datetime
    readings: list[AnyReading]

# Brug:
payload = MQTTPayload.model_validate(raw_json)
for reading in payload.readings:
    if isinstance(reading, HCAReading):
        process_hca(reading.current_hca)
    elif isinstance(reading, WaterMeterReading):
        check_leak(reading.alarm_bits)

Strict mode: Ingen stille type-konvertering

from pydantic import BaseModel, ConfigDict

class ReadingCreate(BaseModel):
    model_config = ConfigDict(strict=True)  # Ingen implicit casting

    value: float      # "342" → ValueError (i strict mode)
    timestamp: datetime  # Kun ISO 8601 strenge — ikke timestamps

# I lax mode (default):
ReadingLax(value="342", timestamp=1716508800)  # Virker
# I strict mode:
ReadingStrict(value="342", timestamp=1716508800)  # ValueError

Strict mode anbefales for interne API-kald. Lax mode er OK for bruger-input.


Response model excludes: Skjul interne felter

from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter()

class UserOut(BaseModel):
    id: UUID
    email: str
    full_name: str
    role: str
    created_at: datetime
    # Aldrig eksponér: password_hash, aes_key_encrypted, deleted_at

@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)):
    # FastAPI filtrerer automatisk til UserOut-felter:
    return user  # Returnerer User SQLModel — kun UserOut-felter eksponeres

Pagination schema

from pydantic import BaseModel, Field
from typing import Generic, TypeVar

T = TypeVar("T")

class Page(BaseModel, Generic[T]):
    data: list[T]
    total: int
    page: int
    per_page: int
    has_more: bool
    next_cursor: Optional[str] = None

class PageParams(BaseModel):
    page: int = Field(default=1, ge=1)
    per_page: int = Field(default=50, ge=1, le=1000)

# Brug:
@router.get("/properties", response_model=Page[PropertyOut])
async def list_properties(
    params: PageParams = Depends(),
    user: User = Depends(get_current_user),
) -> Page[PropertyOut]:
    ...

Konklusion

Pydantic v2 i FastAPI giver automatisk validering, OpenAPI-dokumentation og type safety i ét. Nøglemønstre for IoT-platforme: Separate Create/Update/Out-schemas, custom validators for domænespecifikke regler, discriminated unions for heterogene IoT-payloads og response_model for automatisk datamaskering. Strict mode anbefales for interne API-kald.

Se FastAPI multi-tenant guide eller API-katalog.