FastAPI + Pydantic: walidacja danych i automatyczna dokumentacja OpenAPI

0
26
1/5 - (1 vote)

Nawigacja:

Dlaczego FastAPI i Pydantic tak dobrze grają razem

Ręczna walidacja w Flask/Django kontra podejście deklaratywne

W klasycznym podejściu do budowy API, na przykład w Flasku czy czystym Django, walidacja danych jest zwykle rozproszona i ręczna. Programista:

  • parsuje request.data lub request.POST,
  • sprawdza, czy pola są obecne,
  • konwertuje typy (np. int(request.form["age"])),
  • zwraca błędy jako własnoręcznie zbudowany JSON.

Po kilku endpointach pojawia się chaos: te same zasady walidacji trzeba kopiować, a aktualizacja reguł w jednym miejscu wymaga przeszukania całego kodu. Dokumentacja OpenAPI, jeśli w ogóle istnieje, zazwyczaj jest pisana osobno w YAML/JSON albo generowana tylko częściowo. Typowy efekt? Backend i frontend zaczynają mieć różne wersje rozumienia tego samego API.

FastAPI i Pydantic odwracają tę logikę. Zamiast pisać walidację „po fakcie”, opisujesz dane deklaratywnie: co endpoint przyjmuje i co zwraca, a framework sam dba o spójność. Reguły walidacji, konwersja typów i dokumentacja wynikają z jednego źródła prawdy – modeli i typów.

Adnotacje typów Pythona jako kontrakt API

Od Pythona 3.x adnotacje typów stały się pierwszoplanowym narzędziem. FastAPI wykorzystuje je bardzo dosłownie: sygnatura funkcji endpointu jest kontraktem API. Jeśli napiszesz:

from fastapi import FastAPI

app = FastAPI()

@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
    return {"item_id": item_id, "q": q}

to:

  • item_id: int staje się parametrem ścieżki typu integer w OpenAPI,
  • q: str | None staje się opcjonalnym query paramem typu string,
  • FastAPI automatycznie wymusi konwersję item_id do int, a przy błędzie zwróci logiczną odpowiedź HTTP.

Ten sam mechanizm działa z modelami Pydantic: kiedy w sygnaturze użyjesz klasy dziedziczącej po BaseModel, FastAPI wykorzysta ją do:

  • parsowania JSON z request body,
  • weryfikacji typów,
  • generowania schematu JSON Schema i włączenia go do OpenAPI.

Podział ról: co robi FastAPI, a co robi Pydantic

Łatwo wrzucić wszystko do jednego worka i powiedzieć „FastAPI działa z Pydantic, robi magię”. Lepiej spojrzeć na role obu bibliotek bardziej technicznie:

  • Pydantic:
    • definiowanie modeli danych jako klas Pythona,
    • wbudowana walidacja typów (proste i złożone),
    • konwersja danych wejściowych do typów zadeklarowanych w modelu,
    • generowanie JSON Schema dla modeli.
  • FastAPI:
    • analiza sygnatur funkcji endpointów (typy argumentów, typy zwrotu),
    • mapowanie request body/query/path/header/cookie na parametry funkcji,
    • sklejanie schematów z Pydantic w kompletny dokument OpenAPI,
    • udostępnianie Swagger UI i ReDoc,
    • opakowanie błędów walidacji w odpowiedź HTTP (np. status 422).

Taki podział pozwala korzystać z Pydantic także poza FastAPI (np. w skryptach, workerach, testach), a jednocześnie utrzymać spójność kontraktów danych w całym projekcie.

Ścieżka: od typu w sygnaturze do dokumentacji OpenAPI

Dobrze jest raz zobaczyć cały „łańcuch” przepływu informacji:

  1. Definiujesz model Pydantic:
    from pydantic import BaseModel
    
    class Item(BaseModel):
        name: str
        price: float
        is_offer: bool | None = None
    
  2. Używasz go jako typu w endpointzie:
    @app.post("/items")
    def create_item(item: Item) -> Item:
        return item
    
  3. FastAPI:
    • przyjmuje JSON w body,
    • przekazuje go do Pydantic, który waliduje i konwertuje dane na Item,
    • na podstawie Item generuje schemat JSON Schema,
    • umieszcza schemat w dokumencie OpenAPI (w sekcji components.schemas),
    • dodaje informację o tym, że endpoint /items przyjmuje i zwraca ten schemat.

Efekt końcowy możesz od razu obejrzeć w /docs (Swagger UI) i /redoc – bez pisania osobnej specyfikacji.

Szybki fundament: model danych Pydantic od zera

Model jako klasa dziedzicząca po BaseModel

Punktem startu jest zawsze klasa dziedzicząca po BaseModel. Konstrukcja jest intuicyjna: przypomina zwykłą klasę Pythona, ale z silnym typowaniem i logiką walidacji „pod spodem”:

from pydantic import BaseModel

class User(BaseModel):
    username: str
    age: int
    is_active: bool = True

Instancjonowanie takiego modelu wygląda jak praca z normalnymi obiektami:

u = User(username="jan", age=30)
print(u.username)  # "jan"
print(u.is_active)  # True (domyślna wartość)

Różnica pojawia się, gdy spróbujesz przekazać niepoprawne typy:

User(username="jan", age="trzydzieści")
# Pydantic podniesie ValidationError

Ten model możesz potem wykorzystać w FastAPI zarówno jako wzorzec danych wejściowych, jak i wyjściowych, reużywając logikę walidacji.

Pola wymagane, domyślne i opcjonalne

Pydantic wyciąga wnioski z typów i domyślnych wartości:

  • pole bez wartości domyślnej jest wymagane,
  • pole z wartością domyślną jest opcjonalne – jeśli nie zostanie podane, użyta zostanie wartość domyślna,
  • pole typu Optional lub | None może przyjmować None.
from typing import Optional

class Profile(BaseModel):
    full_name: str                 # wymagane
    bio: Optional[str] = None      # opcjonalne, może być None
    newsletter: bool = False       # opcjonalne, domyślnie False

Jeśli spróbujesz utworzyć Profile bez full_name, Pydantic zasygnalizuje brak wymaganego pola. To zachowanie przełoży się bezpośrednio na OpenAPI: w schemacie JSON pola wymagane pojawią się w sekcji required.

Walidacja typów prostych w praktyce

Typy podstawowe – str, int, float, bool, datetime – obsługiwane są „z pudełka”. Przykład:

from datetime import datetime
from pydantic import BaseModel

class Event(BaseModel):
    name: str
    start_at: datetime
    duration_minutes: int
    public: bool

Pydantic potrafi przyjąć kilka formatów dat (np. ISO 8601), przekonwertować je na datetime, a przy niepowodzeniu zwrócić czytelny błąd. Przy bool przeprowadzi konwersję m.in. z "true", "false", 1, 0 – szczególnie przydatne przy danych z formularzy.

Automatyczna konwersja typów i kiedy pojawia się błąd

Pydantic jest dość „przyjacielski” – próbuje konwertować dane, jeśli to możliwe. Kilka typowych scenariuszy:

  • "123" -> int – konwersja OK,
  • "12.5" -> float – konwersja OK,
  • 1 -> bool – konwersja OK,
  • "abc" -> int – walidacja nie przejdzie,
  • "2023-05-01T10:00:00" -> datetime – konwersja OK.

To „miękkie” podejście zwykle pomaga, ale warto wiedzieć, że możesz zaostrzyć reguły, stosując typy takie jak conint, confloat czy własne walidatory – o tym później. Dla FastAPI ma to ten plus, że większość „dziwnych” danych od klienta odpadnie jeszcze zanim dotrze do logiki biznesowej.

Wstrzykiwanie modeli Pydantic do endpointów FastAPI

Request body jako model Pydantic

Gdy endpoint ma przyjmować JSON w ciele żądania, po prostu używasz modelu Pydantic jako typu parametru funkcji. FastAPI rozpoznaje, że to request body:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    tax: float | None = None

@app.post("/items")
def create_item(item: Item):
    # item jest już zwalidowanym obiektem Pythona
    return {"message": "Created", "item": item}

Nie ma tu ręcznego request.json(). FastAPI:

  • czyta ciało żądania,
  • próbuje zdekodować JSON,
  • tworzy obiekt Item (używając Pydantic),
  • przy błędzie walidacji zwraca status 422 z opisem błędu.

Łączenie body, query params i path params

W praktyce endpoint często łączy różne źródła danych: parametry ścieżki do identyfikacji zasobu, query params jako filtry/paginacja i body jako właściwe dane do zapisu. Przykład:

from fastapi import Path, Query

class UpdateItem(BaseModel):
    name: str | None = None
    price: float | None = None

@app.put("/shops/{shop_id}/items/{item_id}")
def update_item(
    shop_id: int = Path(..., ge=1),
    item_id: int = Path(..., ge=1),
    item: UpdateItem | None = None,
    expand: bool = Query(False)
):
    return {
        "shop_id": shop_id,
        "item_id": item_id,
        "expand": expand,
        "item": item,
    }

W tym przykładzie w jednym strzale:

  • shop_id i item_id są parametrami ścieżki z dodatkowymi ograniczeniami (min 1),
  • expand to query param typu bool z domyślną wartością,
  • item to body reprezentujące częściową aktualizację zasobu.

Dokumentacja OpenAPI pokaże wszystkie te elementy w odpowiednich miejscach: path params, query params, request body ze schematem z modelu UpdateItem.

Parametry proste vs modele – różnica w interpretacji

FastAPI przyjmuje prostą zasadę: parametry prostych typów traktuje jako query/path/header/cookie, a modele Pydantic jako body (o ile nie wskażesz inaczej). Stąd różnica:

@app.get("/search")
def search(q: str | None = None, limit: int = 10):
    ...  # q i limit to query params
@app.post("/users")
def create_user(user: UserCreate):
    ...  # user to request body

Jeśli chcesz jednak, by proste pole znalazło się w body (np. body jako zwykły string), możesz użyć Body z FastAPI, ale w prawdziwych projektach zwykle wygrywa jawny model Pydantic – jest czytelniejszy i lepiej się dokumentuje.

Wpływ typowania na dokumentację OpenAPI

Każdy element sygnatury funkcji trafia potem do OpenAPI:

  • parametry typu int, str, bool stają się parametrami w sekcji parameters,
  • modele Pydantic stają się schematami w components.schemas,
  • typ zwrotny funkcji (-> ItemOut) staje się schematem odpowiedzi,
  • domyślne wartości, zakresy i opisy (dodane np. przez Query, Path, Field) są odwzorowywane w specyfikacji.

Dlatego konsekwentne typowanie endpointów to nie tylko „ładny kod” – to fundament aktualnej, generowanej automatycznie dokumentacji dla twojego zespołu i zewnętrznych integratorów.

Response_model, filtrowanie danych i kontrola odpowiedzi

Walidacja odpowiedzi za pomocą response_model

Kontrola schematu odpowiedzi i ukrywanie pól

response_model to nie tylko walidacja, ale też filtr. Z serwera możesz zwrócić bogaty obiekt, a do klienta wysłać tylko to, co jawnie zadeklarujesz w modelu odpowiedzi:

from pydantic import BaseModel
from fastapi import FastAPI

app = FastAPI()

class UserInDB(BaseModel):
    id: int
    email: str
    password_hash: str

class UserPublic(BaseModel):
    id: int
    email: str

fake_db = {
    1: UserInDB(id=1, email="user@example.com", password_hash="...")
}

@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
    user = fake_db[user_id]
    # zwracamy "za dużo", ale FastAPI & Pydantic odfiltrują pola spoza UserPublic
    return user

W odpowiedzi pola password_hash nie będzie, bo nie należy do schematu UserPublic. Po stronie kodu możesz więc pracować z pełnym modelem, a na zewnątrz publikować bezpieczną „maskę” danych.

response_model_include, response_model_exclude i widoki danych

Dla prostych scenariuszy wygodniej czasem sterować polami na poziomie widoku endpointu niż definiować osobny model. FastAPI eksponuje do tego parametry response_model_include i response_model_exclude:

class Product(BaseModel):
    id: int
    name: str
    description: str | None = None
    price: float
    internal_code: str

@app.get(
    "/products/{product_id}",
    response_model=Product,
    response_model_exclude={"internal_code"},
)
def get_product(product_id: int):
    product = Product(
        id=product_id,
        name="Keyboard",
        description="Mechanical keyboard",
        price=100.0,
        internal_code="SKU-123",
    )
    return product

Do klienta trafi wszystko poza internal_code. Gdy potrzebna jest jeszcze inna wersja widoku (np. bardzo skrócona lista), można skorzystać z response_model_include:

@app.get(
    "/products/{product_id}/summary",
    response_model=Product,
    response_model_include={"id", "name", "price"},
)
def get_product_summary(product_id: int):
    # ta sama logika, inny widok danych
    ...

W specyfikacji OpenAPI nadal wyląduje pełny model Product, ale klient dostanie faktycznie zawężony JSON – to filtr po stronie serwera, nie zmiana kontraktu schematu.

Ścisła walidacja odpowiedzi i debug niezgodności

Domyślnie FastAPI także konwertuje odpowiedzi do zadanego modelu, co bywa pomocne, ale potrafi też zamaskować błąd po twojej stronie. Jeśli chcesz wymusić ostrzejszą kontrolę, możesz włączyć walidację zwrotną:

app = FastAPI()

@app.get("/numbers", response_model=list[int])
def numbers():
    # przypadkowo zwrócony string
    return ["1", "2", "3"]

Pydantic spróbuje przekonwertować stringi na liczby i prawdopodobnie mu się to uda. Jeśli jednak ma to być twardy błąd, w Pydantic v2 możesz użyć konfiguracji model_config z strict=True lub typów ścisłych (StrictInt, StrictStr). Wtedy rozbieżność między modelem a realną odpowiedzią wyjdzie na jaw natychmiast, zamiast po paru tygodniach w produkcji.

Modele odpowiedzi a kody statusu i wiele wariantów

Zdarza się, że jeden endpoint może zwrócić różne formy danych w zależności od sytuacji. FastAPI pozwala przypisać model odpowiedzi dla głównego scenariusza, a w dokumentacji pokazać bardziej szczegółowy obraz przy użyciu responses:

from fastapi import HTTPException

class ErrorDetail(BaseModel):
    detail: str

class Order(BaseModel):
    id: int
    total: float

@app.get(
    "/orders/{order_id}",
    response_model=Order,
    responses={404: {"model": ErrorDetail}},
)
def get_order(order_id: int):
    order = None  # znalezienie w bazie pominięte
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    return order

W OpenAPI główna odpowiedź 200 używa schematu Order, a odpowiedź 404 – schematu ErrorDetail. Klient od razu widzi, jakiej struktury spodziewać się w typowych i błędnych przypadkach.

Specjalista analizuje wykresy danych na laptopie przy biurku w biurze
Źródło: Pexels | Autor: Kampus Production

Zaawansowane typy Pydantic w kontraktach FastAPI

Listy, zagnieżdżone modele i struktury słownikowe

Gdy aplikacja rośnie, same typy proste przestają wystarczać. Pydantic wspiera kolekcje i zagnieżdżone modele, a FastAPI przekłada je na odpowiednio złożone schematy OpenAPI.

from typing import List, Dict

class Tag(BaseModel):
    name: str

class Article(BaseModel):
    title: str
    tags: List[Tag] = []
    metadata: Dict[str, str] = {}

W OpenAPI pole tags stanie się tablicą obiektów typu Tag, a metadata – obiektem słownikowym o wartościach typu string. Klient ma więc jasność, że tags nie jest np. jednym stringiem rozdzielanym przecinkami, tylko prawdziwą listą obiektów.

Enumy – ograniczony zbiór wartości

Dla pól, które mogą przyjmować tylko kilka konkretnych wartości (status, rola użytkownika, typ zamówienia), bardzo wygodnym typem jest Enum. Połączenie Pydantic + Enum daje czytelny kod i przejrzystą dokumentację.

from enum import Enum

class OrderStatus(str, Enum):
    pending = "pending"
    paid = "paid"
    cancelled = "cancelled"

class Order(BaseModel):
    id: int
    status: OrderStatus

W specyfikacji pojawi się pole status z typem string i wyliczeniem ["pending", "paid", "cancelled"]. Frontend od razu wie, jakie wartości może wysłać, a Pydantic na backendzie nie przepuści nic spoza tej listy.

Ograniczenia numeryczne i tekstowe (conint, constr, confloat)

Kolejny krok to ograniczenie zakresów i długości. Pydantic udostępnia „fabryki typów” do tworzenia takich pól:

from pydantic import BaseModel, conint, constr, confloat

class ProductCreate(BaseModel):
    name: constr(min_length=3, max_length=100)
    quantity: conint(ge=1, le=1000)
    price: confloat(gt=0, le=10000)

Te ograniczenia pojawią się zarówno w walidacji, jak i w OpenAPI jako:

  • minLength i maxLength dla name,
  • minimum/maximum oraz exclusiveMinimum/exclusiveMaximum dla liczb.

Dzięki temu front może np. zbudować walidację formularza wyłącznie z kontraktu, bez dodatkowych ustaleń w wiadomościach na komunikatorze.

Dane złożone: Union, Annotated i typy specjalne

Czasem pole może przyjmować kilka różnych kształtów. Prosty przykład to dane kontaktowe: email albo numer telefonu. Do tego służy Union (w Pythonie 3.10+ zapis |):

from typing import Union
from pydantic import EmailStr

class Contact(BaseModel):
    value: Union[EmailStr, constr(regex=r"^+?[0-9]{7,15}$")]

Taki model jest walidowany sekwencyjnie: Pydantic próbuje dopasować wartość do pierwszego typu, potem do drugiego itd. W dokumentacji będzie to pole o typie string, ale z dodatkowymi opisami i przykładowymi formatami. Jeśli chcesz doprecyzować opis, w Pydantic v2 możesz użyć Annotated:

from typing import Annotated
from pydantic import Field

Price = Annotated[
    confloat(gt=0),
    Field(description="Cena w walucie bazowej, wyższa niż 0"),
]

class Item(BaseModel):
    price: Price

Walidatory, Field i precyzyjne komunikaty błędów

Field: domyślne wartości, opisy i dodatkowe ograniczenia

Field to odpowiednik Query czy Path, ale dla modeli Pydantic. Pozwala dodać metadane i ograniczenia bez tworzenia customowych typów:

from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    email: str = Field(..., description="Adres e-mail użytkownika")
    password: str = Field(
        ...,
        min_length=8,
        max_length=128,
        description="Hasło, co najmniej 8 znaków",
        example="S3cureP@ss",
    )

Opis i przykład pojawią się w OpenAPI, a min_length/max_length trafią do schematu JSON. W panelu Swagger UI użytkownik od razu zobaczy, jakich danych się oczekuje, a walidacja po stronie serwera i tak dopilnuje reszty.

Walidatory pól – logika biznesowa bliżej danych

Niektóre reguły trudno wyrazić samymi typami. Wtedy z pomocą przychodzą walidatory. W Pydantic v1 używa się dekoratora @validator, w v2 – @field_validator. Przykład na Pydantic v2:

from pydantic import BaseModel, field_validator, EmailStr

class Registration(BaseModel):
    email: EmailStr
    password: str
    password_repeat: str

    @field_validator("password_repeat")
    @classmethod
    def passwords_match(cls, v, values):
        if "password" in values.data and v != values.data["password"]:
            raise ValueError("Hasła muszą być identyczne")
        return v

Informacja o błędzie trafi do odpowiedzi 422 z dokładną ścieżką pola (password_repeat) i komunikatem. Klient nie musi zgadywać, co poszło nie tak – ma wszystko w jednym, spójnym formacie.

Walidacja całego modelu i zależności między polami

Są też reguły dotyczące kombinacji pól: albo jedno, albo drugie, ale nie oba naraz. Albo przynajmniej jedno z nich. Takie przypadki obsłuży walidator modelu (@model_validator w Pydantic v2):

from pydantic import BaseModel, model_validator

class SearchQuery(BaseModel):
    text: str | None = None
    tags: list[str] | None = None

    @model_validator(mode="after")
    def at_least_one_criterion(self):
        if not self.text and not self.tags:
            raise ValueError("Podaj co najmniej tekst wyszukiwania lub tagi")
        return self

Taka reguła nie da się łatwo odwzorować w „czystym” JSON Schema, ale wciąż pojawi się w odpowiedziach błędów walidacji jako komunikat przy całym modelu. Dla klienta to jasny sygnał, jak budować poprawne zapytania.

Konfiguracja modeli i wpływ na OpenAPI

Aliasowanie pól i konwencje nazewnicze

Backend często używa innych nazw niż frontend. Pydantic pozwala rozdzielić nazwę atrybutu w Pythonie od nazwy pola w JSON. W Pydantic v2 robi się to przez Field(alias=...) i odpowiednią konfigurację modelu:

from pydantic import BaseModel, Field
from pydantic import ConfigDict  # v2

class User(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    user_id: int = Field(alias="id")
    full_name: str = Field(alias="fullName")

W Pythonie pracujesz z user.user_id i user.full_name, ale JSON wejściowy i wyjściowy używa id i fullName. OpenAPI pokaże aliasy, bo to one są „prawdziwym” kontraktem dla świata zewnętrznego.

Model_config, tryby strict i serializacja

model_config pozwala sterować wieloma aspektami walidacji i serializacji. Kilka typowych opcji:

  • strict=True – wyłącza „miękką” konwersję typów,
  • extra="forbid" – zakazuje nadmiarowych pól w wejściu,
  • use_enum_values=True – serializuje enumy jako wartości, nie nazwy.
class StrictModel(BaseModel):
    model_config = ConfigDict(
        strict=True,
        extra="forbid",
    )

    value: int

Przy takim ustawieniu JSON z polem "value": "1" zostanie odrzucony, mimo że normalnie Pydantic skonwertowałby string na int. Dla systemów z bardzo ostrym kontraktem typów to spora zaleta.

OpenAPI, przykłady i rozbudowane opisy schematów

Przykłady na poziomie pól i całych modeli

Kontrakt API staje się dużo czytelniejszy, gdy pola i modele mają przykładowe dane. Pydantic pozwala dodać example do poszczególnych pól oraz json_schema_extra dla całego modelu.

class Address(BaseModel):
    street: str = Field(..., example="ul. Kowalska 1")
    city: str = Field(..., example="Warszawa")
    postal_code: str = Field(..., example="00-001")

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "street": "ul. Kwiatowa 5",
                "city": "Kraków",
                "postal_code": "30-001",
            }
        }
    )

Swagger UI pokaże zarówno przykłady przy polach, jak i jeden spójny przykład całego obiektu. Dzięki temu ktoś konsumujący API nie musi zgadywać, jak wygląda poprawny adres – wystarczy, że kliknie w rozwijane „Example value”.

Integracja modeli Pydantic z FastAPI w praktyce

Same modele to dopiero połowa układanki. Cała magia dzieje się wtedy, gdy podłączysz je do endpointów FastAPI – wtedy walidacja, serializacja i dokumentacja OpenAPI zaczynają współpracować z routerami, statusami HTTP i autoryzacją.

Request body – wejście zawsze w tym samym kształcie

Pierwszy, najbardziej oczywisty przypadek to użycie modeli jako „korpusu” żądania. FastAPI rozpoznaje, że skoro parametr jest typem BaseModel, to trzeba go sparsować z JSON-a i zwalidować:

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserOut(BaseModel):
    id: int
    email: EmailStr

@app.post("/users", response_model=UserOut, status_code=201)
def create_user(payload: UserCreate):
    # tutaj normalna logika: zapis do bazy itd.
    return UserOut(id=1, email=payload.email)

payload jest już gotowym obiektem Pythona z poprawnymi typami. Jeśli przyjdzie niepoprawny JSON, odpowiedź 422 zostanie wygenerowana automatycznie, z dokładnym opisem błędów. Jednocześnie OpenAPI dostanie pełny opis modelu wejściowego i wyjściowego.

response_model – kontrakt na wyjściu

Dobrą praktyką jest podawanie osobnego modelu na wyjściu, nawet jeśli struktury są podobne. Można wtedy spokojnie ukryć pola techniczne (np. password_hash) i pilnować, żeby klient nigdy ich nie zobaczył.

class UserInternal(BaseModel):
    id: int
    email: EmailStr
    password_hash: str

class UserPublic(BaseModel):
    id: int
    email: EmailStr

@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
    user = UserInternal(
        id=user_id,
        email="alice@example.com",
        password_hash="...",
    )
    return user

FastAPI weźmie zwrócony UserInternal, przepuści go przez UserPublic i dopiero taki obiekt trafi do klienta. OpenAPI zna tylko UserPublic, więc w dokumentacji nie ma śladu po polach, które chcesz zachować w backendzie.

Tryb odczytu, tryb zapisu i rozdzielenie modeli

W większych projektach szybko pojawia się zestaw trzech modeli na tę samą encję: model wejściowy (create/update), model „pełny” wewnątrz aplikacji oraz model publiczny. Schemat może wyglądać tak:

class ArticleBase(BaseModel):
    title: str
    content: str

class ArticleCreate(ArticleBase):
    pass

class ArticleRead(ArticleBase):
    id: int
    author: str
    created_at: datetime

Endpointy POST/PUT używają ArticleCreate jako body, a GET-y zwracają ArticleRead. Dla klienta API to jasny sygnał: co mogę podać, a co dostanę w odpowiedzi.

Laptop z wykresami finansowymi i analizą danych obok dokumentów
Źródło: Pexels | Autor: Tiger Lily

Parametry zapytań, ścieżki i nagłówki z typami Pydantic

FastAPI potrafi wykorzystać te same mechanizmy walidacji także poza body – w query params, path params i nagłówkach. Wystarczy dodać odpowiednie funkcje pomocnicze.

Query i Path – walidacja bez dodatkowego kodu

Zamiast ręcznie sprawdzać, czy limit jest dodatni, a sort ma dozwoloną wartość, można oprzeć się wyłącznie na typach i ograniczeniach Pydantic:

from fastapi import Query, Path
from pydantic import conint
from enum import Enum

class SortOrder(str, Enum):
    asc = "asc"
    desc = "desc"

@app.get("/products")
def list_products(
    limit: conint(gt=0, le=100) = Query(10, description="Liczba rekordów na stronę"),
    offset: conint(ge=0) = Query(0),
    sort: SortOrder = Query(SortOrder.asc),
):
    return {"limit": limit, "offset": offset, "sort": sort}

OpenAPI pokaże od razu: limit z zakresem, offset jako liczba całkowita ≥ 0 oraz sort jako enum z dwoma możliwymi wartościami. Formularz w Swagger UI ustawi odpowiednie pola, a niepoprawne dane zostaną odrzucone z komunikatem 422.

Modele w parametrach zapytań – wspólne filtry

Jeśli wiele endpointów ma podobne filtry, dobrze jest je ująć w modelu Pydantic i wstrzykiwać jako zależność. Tak powstaje jeden wspólny schemat OpenAPI dla filtrów, używany w różnych miejscach.

from fastapi import Depends
from pydantic import BaseModel

class ProductFilters(BaseModel):
    min_price: float | None = Field(None, ge=0)
    max_price: float | None = Field(None, ge=0)
    in_stock: bool | None = None

def get_filters(
    min_price: float | None = Query(None, ge=0),
    max_price: float | None = Query(None, ge=0),
    in_stock: bool | None = Query(None),
) -> ProductFilters:
    return ProductFilters(
        min_price=min_price,
        max_price=max_price,
        in_stock=in_stock,
    )

@app.get("/products/search")
def search_products(filters: ProductFilters = Depends(get_filters)):
    return {"filters": filters}

W OpenAPI widać wszystkie parametry zapytania, a dodatkowo w kodzie aplikacji masz jeden, spójny obiekt filters, który łatwo przekazać np. do warstwy bazy danych.

Walidacja nagłówków i cookies

Podobnie działają nagłówki czy ciasteczka. Można nadać im typy Pydantic, a FastAPI zajmie się resztą:

from fastapi import Header, HTTPException, status

@app.get("/secure-endpoint")
def secure_endpoint(x_api_key: str = Header(...)):
    if x_api_key != "sekretny-klucz":
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    return {"message": "OK"}

Typ, wymaganie oraz opis nagłówka trafią do OpenAPI. Klient od razu widzi, że musi wysłać X-API-Key, w jakim formacie i czy jest obowiązkowy.

Typy odpowiedzi, błędy i zagnieżdżone schematy

Listy, słowniki i odpowiedzi stronicowane

W realnych projektach rzadko zwraca się pojedynczy obiekt. Częściej jest to lista obiektów z dodatkowymi metadanymi stronicowania. Pydantic elegancko opisuje takie przypadki:

from typing import List

class Product(BaseModel):
    id: int
    name: str

class PageMeta(BaseModel):
    total: int
    limit: int
    offset: int

class ProductPage(BaseModel):
    meta: PageMeta
    items: List[Product]

@app.get("/products", response_model=ProductPage)
def list_products_route():
    return ProductPage(
        meta=PageMeta(total=100, limit=10, offset=0),
        items=[Product(id=1, name="Monitor")],
    )

OpenAPI wygeneruje pełne, zagnieżdżone schematy, które Swagger UI potrafi ładnie zwizualizować. Po jednej wizycie w panelu klient rozumie strukturę odpowiedzi, nawet jeśli jest wielopoziomowa.

Union w odpowiedziach – różne kształty dla różnych przypadków

Bywa, że odpowiedź przyjmuje różne formy, np. zależnie od typu zasobu. Da się to odzwierciedlić Unionem, choć trzeba pamiętać, że część narzędzi klienckich różnie interpretuje takie schematy.

class Cat(BaseModel):
    type: Literal["cat"]
    meows_per_minute: int

class Dog(BaseModel):
    type: Literal["dog"]
    barks_per_minute: int

Pet = Cat | Dog

@app.get("/pet/{pet_id}", response_model=Pet)
def get_pet(pet_id: int):
    return Cat(type="cat", meows_per_minute=20)

W OpenAPI pojawi się tu mechanizm oneOf z referencjami do Cat i Dog. Generator klienta może na tej podstawie tworzyć typy z rozróżnieniem na type="cat" lub type="dog", co bardzo ułatwia pracę np. w TypeScripcie.

Standardowe formaty błędów i własne modele błędów

FastAPI ma wbudowany model błędu dla 422, ale inne kody możesz opisać własnymi strukturami. Dzięki temu klient ma pewność, że np. 404 czy 409 będzie miało zawsze ten sam kształt.

from fastapi import HTTPException
from fastapi.responses import JSONResponse

class ErrorResponse(BaseModel):
    code: str
    message: str

@app.get(
    "/orders/{order_id}",
    responses={
        404: {
            "description": "Zamówienie nie istnieje",
            "model": ErrorResponse,
        }
    },
)
def get_order(order_id: int):
    if order_id != 1:
        return JSONResponse(
            status_code=404,
            content=ErrorResponse(
                code="ORDER_NOT_FOUND",
                message="Zamówienie nie zostało znalezione",
            ).model_dump(),
        )
    return {"id": 1}

Opis dla 404 wraz z modelem ErrorResponse trafi do specyfikacji OpenAPI. Generator klienta może wtedy mieć np. osobny typ błędu na 404, z polami code i message.

Bezpieczeństwo, tokeny i modele autoryzacji

Token JWT i odseparowanie części publicznej od prywatnej

Tokeny często zawierają zarówno część techniczną (np. data ważności), jak i dane użytkownika. Wygodnie jest mieć na nie osobne modele – inny dla tego, co przechodzi przez API, inny dla „wnętrza” tokena.

from datetime import datetime

class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"

class TokenPayload(BaseModel):
    sub: str
    exp: datetime

Endpoint logowania zwraca Token jako response_model, specyfikacja OpenAPI dokładnie opisuje kształt odpowiedzi, a logika związana z dekodowaniem JWT używa TokenPayload w zupełnie innym miejscu aplikacji.

Zależności bezpieczeństwa z modelami Pydantic

Zależności Depends często zwracają obiekty opisane modelami Pydantic. Dzięki temu reszta kodu używa silnie typowanego użytkownika, a nie słownika bez struktury.

from fastapi import Depends, HTTPException, status

class CurrentUser(BaseModel):
    id: int
    email: EmailStr
    is_admin: bool = False

def get_current_user() -> CurrentUser:
    # zwykle: odczyt tokena, sprawdzenie w bazie itd.
    return CurrentUser(id=1, email="alice@example.com", is_admin=True)

@app.get("/me", response_model=CurrentUser)
def read_me(user: CurrentUser = Depends(get_current_user)):
    return user

Zarówno wewnątrz aplikacji, jak i w OpenAPI ten sam model opisuje dane aktualnego użytkownika. Kod zależności jest luźno połączony z routerami, a jednocześnie masz pełny kontrakt na poziomie specyfikacji.

Migrations w API: wersjonowanie modeli i zachowanie kompatybilności

Dodawanie pól opcjonalnych bez psucia klientów

W pewnym momencie dojdzie nowe pole do istniejącego modelu. Klasyczny przykład: do użytkownika dochodzi phone_number. Jeśli zrobisz je opcjonalne i ustawisz sensowną wartość domyślną, dotychczasowi klienci się nie „wyłożą”.

class UserV1(BaseModel):
    id: int
    email: EmailStr

class UserV2(UserV1):
    phone_number: str | None = Field(
        None,
        description="Numer telefonu w formacie międzynarodowym",
        example="+48123456789",
    )

OpenAPI zacznie prezentować nową wersję z dodatkowym polem. Klient, który je zignoruje, nadal zadziała poprawnie, a nowi klienci mogą zacząć je uzupełniać.

Endpointy wersjonowane i równoległe schematy

Kiedy zmiana jest niekompatybilna (np. zmiana typu pola lub usunięcie kluczowego atrybutu), rozsądniej jest współistnienie kilku wersji modeli i endpointów, przynajmniej przez jakiś czas.

class ProfileV1(BaseModel):
    username: str
    full_name: str

class ProfileV2(BaseModel):
    handle: str
    first_name: str
    last_name: str

@app.get("/v1/profile", response_model=ProfileV1)
def profile_v1():
    return ProfileV1(username="alice", full_name="Alice Doe")

@app.get("/v2/profile", response_model=ProfileV2)
def profile_v2():
    return ProfileV2(handle="alice", first_name="Alice", last_name="Doe")

Specyfikacja OpenAPI pokaże obie ścieżki, każdy z innym kształtem odpowiedzi. Klienci mogą przechodzić na nową wersję stopniowo, mając jasny wgląd w to, co się zmieniło.

Modelowanie zagnieżdżonych struktur i relacji

Relacje jeden-do-wielu w odpowiedziach

Zagnieżdżone modele są w Pydantic naturalne. Można nimi opisać relacje w stylu „użytkownik + jego zamówienia” bez dodatkowego wysiłku:

class Order(BaseModel):
    id: int
    total: float

class UserWithOrders(BaseModel):
    id: int
    email: EmailStr
    orders: list[Order] = []

@app.get("/users/{user_id}/details", response_model=UserWithOrders)
def user_details(user_id: int):
    return UserWithOrders(
        id=user_id,
        email="alice@example.com",
        orders=[Order(id=1, total=99.99)],
    )

Swagger UI rozwinie taką strukturę drzewiasto, a generatory klienta stworzą odpowiednie klasy/typy zagnieżdżone. Po stronie frontendu wystarczy trzymać się kontraktu – reszta jest oczywista.

Unikanie cykli i referencji zwrotnych

Przy relacjach dwukierunkowych łatwo o pętlę: użytkownik ma listę postów, post ma pełnego użytkownika itd. Z punktu widzenia API lepiej zwykle zwrócić pełny obiekt tylko z jednej strony relacji, a z drugiej jedynie identyfikator.

Najczęściej zadawane pytania (FAQ)

Po co używać FastAPI z Pydantic, skoro mam już Django albo Flask?

FastAPI z Pydantic odciąża cię od ręcznej walidacji i ręcznego klejenia odpowiedzi z błędami. Zamiast pisać w każdym widoku/parze endpointów: „sprawdź, czy pole istnieje, skonwertuj typ, zwróć błąd”, opisujesz dane raz – typami i modelami – a resztą zajmuje się framework. Jedno źródło prawdy zamiast walidacji rozsianej po całym projekcie.

Dodatkowo zyskujesz automatyczną dokumentację OpenAPI, Swagger UI i ReDoc. Frontend widzi od razu aktualny kontrakt API – bez osobnego pliku YAML/JSON utrzymywanego ręcznie. Przy większym projekcie różnica w utrzymaniu jest kolosalna.

Jak działa walidacja danych w FastAPI z użyciem Pydantic krok po kroku?

Gdy wywołujesz endpoint, FastAPI sprawdza sygnaturę funkcji i widzi, jakie typy przyjmują parametry: proste (int, str, bool) albo modele Pydantic. Na tej podstawie wie, czy dane mają być w ścieżce, query, nagłówkach czy w body. Następnie zdekodowane dane trafiają do Pydantic, który próbuje je skonwertować do zadanych typów i sprawdza wymagane pola.

Jeśli coś się nie zgadza (np. „abc” zamiast liczby), Pydantic rzuca ValidationError, a FastAPI zamienia to automatycznie na odpowiedź HTTP 422 z czytelnym JSON-em opisującym, które pola są błędne i dlaczego. Twoja logika biznesowa dostaje już tylko poprawne, silnie typowane obiekty.

Czym różni się rola FastAPI od roli Pydantic w walidacji?

Pydantic odpowiada za modele danych: definiujesz klasy dziedziczące po BaseModel, typujesz pola, ustawiasz wartości domyślne. On zajmuje się konwersją wejścia (np. stringów z JSON-a) na konkretne typy Pythona, pilnuje wymaganych pól i generuje JSON Schema na potrzeby dokumentacji.

FastAPI natomiast „orchestru­je” cały proces: analizuje typy w sygnaturze endpointu, przypisuje parametry do odpowiednich części żądania (body, path, query), woła Pydantic do walidacji i z gotowych schematów składa kompletny dokument OpenAPI. Dodatkowo podaje ci to w Swagger UI i ReDoc oraz pakuje błędy walidacji w poprawne odpowiedzi HTTP.

Jak zdefiniować model Pydantic i użyć go jako request body w FastAPI?

Bazą jest klasa dziedzicząca po BaseModel. Wskazujesz pola z typami i ewentualnymi wartościami domyślnymi. Przykład:

class Item(BaseModel):
    name: str
    price: float
    is_offer: bool | None = None

Następnie używasz tego modelu jako typu parametru w endpointzie, np. def create_item(item: Item). FastAPI zinterpretuje ten parametr jako JSON w body, przekaże go do Pydantic, a ty dostaniesz gotowy obiekt Item. Nie musisz ręcznie robić request.json() ani pisać if-ów sprawdzających pola.

Jak FastAPI generuje dokumentację OpenAPI na podstawie Pydantic?

Najpierw Pydantic tworzy JSON Schema dla każdego modelu, który wykorzystujesz w endpointach. Ten schemat opisuje typy pól, wymagane atrybuty, wartości domyślne itp. FastAPI zbiera te schematy i umieszcza je w sekcji components.schemas specyfikacji OpenAPI.

Następnie łączy je z definicjami poszczególnych ścieżek: przy endpointzie POST/GET/PUT itd. dopisuje, że body wejściowe lub odpowiedź bazuje na konkretnym schemacie. Dzięki temu /docs (Swagger UI) i /redoc pokazują dokładnie to, co wynika z twoich modeli i typów, bez dodatkowej ręcznej konfiguracji.

Jak działa automatyczna konwersja typów w Pydantic i kiedy dostanę błąd?

Pydantic jest dość elastyczny: spróbuje zamienić dane na zadany typ, jeśli konwersja ma sens. Przykładowo string "123" stanie się int, "12.5" zamieni się na float, a wartości typu "true", "false", 1, 0 potrafi przemapować na bool. Dzięki temu dane z formularzy lub z luźniejszych API nadal mogą przejść poprawnie.

Błąd pojawia się wtedy, gdy konwersja jest niemożliwa lub łamie reguły, które ustalisz (np. "abc" dla int, dziwny format daty, brak wymaganego pola). W FastAPI taki błąd zostanie przechwycony i zobaczysz odpowiedź 422 z listą problematycznych pól i komunikatami. To pozwala szybko wychwycić błędne requesty po stronie klienta.

Jak w FastAPI łączyć modele Pydantic z parametrami ścieżki i query?

Najprostszy sposób to użycie modelu Pydantic dla body oraz zwykłych typów dla parametrów ścieżki i query. Przykładowo: def update_item(item_id: int, q: str | None = None, item: Item). Wtedy item_id stanie się parametrem ścieżki, q – opcjonalnym query paramem, a item – JSON-em w body walidowanym przez Pydantic.

FastAPI sam rozpozna źródło danych na podstawie tego, czy dany parametr ma domyślną wartość, gdzie jest użyty w ścieżce, i czy jest modelem Pydantic. Ty widzisz po prostu funkcję z jasno opisanymi argumentami, a resztą „hydrauliki” zajmuje się framework.

Najważniejsze wnioski

  • FastAPI i Pydantic zastępują ręczną, rozproszoną walidację jednym deklaratywnym źródłem prawdy – opisujesz strukturę danych w modelach i adnotacjach typów, a framework sam pilnuje spójności.
  • Sygnatura funkcji endpointu staje się kontraktem API: typy parametrów (path, query, body) oraz typ zwracany są automatycznie wymuszane, konwertowane i odzwierciedlane w specyfikacji OpenAPI.
  • Pydantic odpowiada za definicję modeli, walidację i konwersję typów oraz generowanie JSON Schema, podczas gdy FastAPI „skleja” to w pełny dokument OpenAPI i obsługuje odpowiedzi HTTP, w tym błędy 422.
  • Modele Pydantic można używać wszędzie (API, skrypty, workery, testy), dzięki czemu kontrakt danych jest wspólny dla całego projektu, a zmiana typu czy pola w jednym miejscu propaguje się do reszty systemu.
  • Na podstawie modeli Pydantic FastAPI automatycznie tworzy dokumentację interaktywną (Swagger UI, ReDoc), bez pisania osobnych plików YAML/JSON – zmieniasz kod, dokumentacja aktualizuje się sama.
  • Sposób definiowania pól w modelu (typ + wartość domyślna lub jej brak) jednoznacznie określa, co jest wymagane, co opcjonalne i co może być None, a ta logika od razu trafia do schematów OpenAPI.
  • Proste typy (str, int, float, bool, datetime) są walidowane „z pudełka”, więc już podstawowy model Pydantic daje solidny fundament pod bezpieczne API, w którym złe dane są wychwytywane zanim dotrą do logiki biznesowej.