FastAPI: REST APIs mit automatischer Validierung aufbauen

1. Übersicht

Beim Schreiben von REST APIs liegt der Großteil des “defensiven” Codes in der Eingabeprüfung: Ist ein Feld erforderlich, kann die Zahl negativ sein, ist die E-Mail gültig? FastAPI delegiert dies an Pydantic. Du brauchst nur den Datentyp und die Einschränkungen zu deklarieren, das Framework validiert die Request automatisch und gibt eine strukturierte 422-Fehlermeldung zurück.

Dieser Artikel erstellt eine Order-Management-API (Order) von Grund auf. Nach dem Lesen wirst du wissen, wie du Body, Path und Query einschränkst. Du wirst auch benutzerdefinierte Validatoren schreiben können, Rückgabedaten filtern, das Fehlerformat ändern und alles mit pytest testen.

2. Installation

Du brauchst Python 3.10 oder höher. Erstelle ein virtualenv und installiere FastAPI mit der standard-Abhängigkeitsgruppe:

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest

Das Paket fastapi[standard] enthält bereits alles, was dieser Artikel braucht:

  • uvicorn zum Ausführen des Servers.
  • fastapi-cli stellt den fastapi dev-Befehl bereit.
  • httpx für TestClient.
  • email-validator für den EmailStr-Typ.

3. Erster Endpoint mit Pydantic Model

Erstelle die Datei main.py:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Order API")


class OrderCreate(BaseModel):
    customer_email: str
    product_sku: str
    quantity: int


@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
    return order

Führe den Server im Entwicklungsmodus mit Auto-Reload aus:

fastapi dev main.py

Hier gibt es noch keine einzige if-Zeile. Der Parameter order: OrderCreate sagt FastAPI drei Dinge:

  • JSON aus dem Request-Body lesen.
  • JSON in OrderCreate umwandeln.
  • 422 zurückgeben, wenn die Daten nicht dem Typ entsprechen.

Versuche, quantity als einen nicht numerischen String zu senden:

curl -X POST localhost:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}

Das Feld loc zeigt genau die Fehlerposition: liegt in body, im Feld quantity. Darüber hinaus wird die OpenAPI-Dokumentation unter /docs aus dem Model selbst generiert. Code und Dokumentation weichen daher nicht voneinander ab.

4. Datenbeschränkungen mit Field

Typprüfung reicht nicht aus. Zum Beispiel ist quantity = -5 immer noch ein gültiger int. Du fügst Einschränkungen mit Field hinzu, kombiniert mit Annotated:

from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, EmailStr, Field

Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]


class OrderCreate(BaseModel):
    customer_email: EmailStr
    product_sku: Sku
    quantity: Annotated[int, Field(gt=0, le=100)]
    unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
    discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
    note: Annotated[str | None, Field(max_length=200)] = None

Jede Zeile macht folgendes:

  • EmailStr prüft das E-Mail-Format wirklich, nicht nur, ob ein @-Zeichen vorhanden ist.
  • Sku ist ein wiederverwendbarer Typ. Es erfordert eine Länge von 3–20 Zeichen und darf nur Buchstaben, Ziffern und Bindestriche enthalten.
  • quantity muss größer als 0 und darf 100 nicht überschreiten.
  • unit_price verwendet Decimal statt float, um Rundungsfehler bei Geldberechnung zu vermeiden. Maximal 10 Ziffern, 2 Dezimalstellen.
  • discount und note haben Standardwerte, daher sind sie optionale Felder. Felder ohne Default sind erforderlich.

Wenn du quantity: 0 sendest, erhältst du:

{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}

Hinweis: Pydantic läuft standardmäßig im lax-Modus. Der String "5" wird ohne Fehler in die Zahl 5 konvertiert. Wenn du dies vollständig ablehnen möchtest, füge model_config = ConfigDict(strict=True) zum Model hinzu oder verwende Field(strict=True) für einzelne Felder.

5. Validierung von Path und Query Parametern

Nicht nur der Body, sondern auch Path und Query werden nach dem gleichen Mechanismus validiert:

from fastapi import HTTPException, Path, Query

orders_db: dict[int, dict] = {}


@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
    if order_id not in orders_db:
        raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
    return orders_db[order_id]


@app.get("/orders")
def list_orders(
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
    sku: Annotated[str | None, Query(min_length=3)] = None,
):
    items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
    start = (page - 1) * size
    return items[start : start + size]

Ergebnisse mit verschiedenen Anfragen:

  • GET /orders/0 gibt 422 zurück, da order_id größer als 0 sein muss.
  • GET /orders?size=500 gibt 422 zurück, da size maximal 50 ist.
  • GET /orders?page=2&size=5 läuft normal. Beide Werte page und size wurden zu int konvertiert, bevor sie in die Funktion eingeben.

Unterscheide zwischen 422 und 404. 422 bedeutet, dass die Anfrage falsch formatiert ist. 404 ist Geschäftslogik, daher wirfst du selbst HTTPException.

6. Benutzerdefinierte Validatoren

Es gibt Regeln, die mit Field nicht ausdrückbar sind. In diesem Fall verwendest du field_validator für ein einzelnes Feld oder model_validator für Regeln, die mehrere Felder betreffen.

from pydantic import field_validator, model_validator

FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}


class OrderCreate(BaseModel):
    # ... Felder wie in Abschnitt 4 ...

    @field_validator("product_sku")
    @classmethod
    def normalize_sku(cls, value: str) -> str:
        return value.upper()

    @field_validator("customer_email")
    @classmethod
    def reject_disposable_email(cls, value: str) -> str:
        domain = value.split("@")[-1].lower()
        if domain in FORBIDDEN_DOMAINS:
            raise ValueError("Disposable-E-Mail ist nicht zulässig")
        return value

    @model_validator(mode="after")
    def check_discount(self) -> "OrderCreate":
        total = self.unit_price * self.quantity
        if self.discount > total:
            raise ValueError("Rabatt darf die Gesamtsumme nicht übersteigen")
        return self

Erklärung jedes Validators:

  • normalize_sku läuft, nachdem Field das Pattern überprüft hat. Es normalisiert die SKU in Großbuchstaben, daher wird "tshirt-01" als "TSHIRT-01" gespeichert. Der Validator kann den Wert ändern, solange du den neuen Wert return.
  • reject_disposable_email wirft ValueError. Pydantic konvertiert dies in einen Validierungsfehler, und die Nachricht hat das Präfix "Value error, ".
  • check_discount läuft mit mode="after", das heißt, wenn alle Felder gültig sind und den richtigen Typ haben. Auf diese Weise kannst du Decimal sicher multiplizieren. Denk daran, return self zu verwenden.

Hinweis: Rufe nicht die Datenbank oder externe APIs in einem Validator auf. Zum Beispiel ist die Überprüfung, ob „eine SKU in einem Lager existiert”, Geschäftslogik. Platziere es in einem Service oder einer Dependency. Ein Validator sollte eine reine Funktion sein, schnell und leicht zu testen.

7. response_model: Kontrolle der ausgegebenen Daten

Du speicherst oft mehr Informationen, als du an den Client zurückgeben möchtest, z. B. interne Notizen. Trenne das Input- und Output-Model:

class OrderRead(BaseModel):
    id: int
    customer_email: EmailStr
    product_sku: str
    quantity: int
    total: Decimal


@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
    order_id = len(orders_db) + 1
    record = {
        "id": order_id,
        **order.model_dump(),
        "total": order.unit_price * order.quantity - order.discount,
        "internal_flag": "manual-review",
    }
    orders_db[order_id] = record
    return record

Die Funktion gibt ein dict zurück, das sowohl note als auch internal_flag enthält. Jedoch filtert response_model=OrderRead die Response, sodass nur die deklarierten Felder übrig bleiben:

{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}

Es gibt zwei Punkte zu beachten:

  • Decimal wird als String "39.98" serialisiert, um die Genauigkeit zu bewahren. Der Client muss das wissen.
  • Die Response wird auch validiert. Wenn du das Feld total vergisst, wirft FastAPI ResponseValidationError und gibt 500 zurück, nicht 422. Das ist ein Serverfehler, nicht ein Clientfehler.

8. Anpassung des 422-Fehlerformats

Das Standard-Format ist ziemlich detailliert. Viele Teams möchten eine kompakte und einheitliche Struktur für das Frontend. Du überschreibst den Handler für RequestValidationError:

from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "field": ".".join(str(part) for part in err["loc"][1:]) or "body",
            "message": err["msg"],
        }
        for err in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"code": "VALIDATION_FAILED", "errors": errors},
    )

Wie der Handler jeden Fehler behandelt:

  • err["loc"][1:] entfernt das erste Element (body, query, path) und verbindet den Rest mit einem Punkt.
  • Fehler von model_validator sind nicht an ein Feld gebunden. Dann enthält loc nur noch ("body",), daher weist der Handler "body" zu.

Wenn du eine Bestellung mit quantity: 0 und einer Einmal-E-Mail sendest, erhältst du:

{"code":"VALIDATION_FAILED","errors":[
  {"field":"customer_email","message":"Value error, Disposable-E-Mail ist nicht zulässig"},
  {"field":"quantity","message":"Input should be greater than 0"}]}

Pydantic sammelt alle Fehler der Felder auf einmal, statt beim ersten Fehler zu stoppen. Der Client kann daher alles in einer Runde korrigieren.

9. Tests mit pytest

TestClient ruft die App direkt auf, ohne einen Server zu starten. Erstelle die Datei test_main.py neben main.py:

import pytest
from fastapi.testclient import TestClient

from main import app, orders_db

client = TestClient(app)

VALID_ORDER = {
    "customer_email": "an@example.com",
    "product_sku": "tshirt-01",
    "quantity": 2,
    "unit_price": "19.99",
}


@pytest.fixture(autouse=True)
def clear_db():
    orders_db.clear()


def test_create_order_returns_normalized_sku_and_total():
    response = client.post("/orders", json=VALID_ORDER)

    assert response.status_code == 201
    body = response.json()
    assert body["product_sku"] == "TSHIRT-01"
    assert body["total"] == "39.98"
    assert "internal_flag" not in body


def test_invalid_quantity_and_email_are_reported_together():
    payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    fields = {e["field"] for e in response.json()["errors"]}
    assert fields == {"quantity", "customer_email"}


def test_discount_greater_than_total_is_rejected():
    payload = {**VALID_ORDER, "discount": "100.00"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    assert response.json()["errors"][0] == {
        "field": "body",
        "message": "Value error, Rabatt darf die Gesamtsumme nicht übersteigen",
    }


def test_page_size_over_limit_is_rejected():
    assert client.get("/orders?size=500").status_code == 422

Tests ausführen:

pytest -q
....                                                    [100%]
4 passed in 0.31s

Die Fixture clear_db setzt orders_db vor jedem Test zurück und hilft, Tests voneinander unabhängig zu machen. Darüber hinaus kannst du das Modell separat ohne HTTP testen, indem du OrderCreate.model_validate(...) kombiniert mit pytest.raises(ValidationError) verwendest. Dieser Ansatz ist geeignet, wenn das Modell viele komplexe Regeln hat.

10. Häufige Fehler

  • float für Geld verwenden. 0.1 + 0.2 ist nicht gleich 0.3. Verwende Decimal mit max_digits und decimal_places.
  • @classmethod oder Decorator-Reihenfolge vergessen. @field_validator muss vor @classmethod stehen. Andernfalls gibt Pydantic v2 sofort beim Import einen Fehler aus.
  • return im Validator vergessen. field_validator ohne return wird None dem Feld zuweisen. model_validator(mode="after") muss self zurückgeben.
  • Pydantic v1-Syntax verwenden. @validator, class Config, .dict() sind veraltet. Verwende @field_validator, model_config, .model_dump().
  • 422 mit 500 verwechseln. Falsche Eingabe gibt 422. Response passt nicht zu response_model gibt 500. Wenn du 500 siehst, überprüfe die Daten, die du zurückgibst.
  • Das gleiche Model für Input und Output verwenden. Dies kann interne Felder verfügbar machen und zwingt den Client, Felder wie id zu senden. Trenne OrderCreate und OrderRead.

Hinweis: Validierung auf der API-Ebene ersetzt keine Einschränkungen auf der Datenbankebene. Einschränkungen wie UNIQUE oder NOT NULL sind immer noch erforderlich. Anfragen können auf vielen verschiedenen Wegen in das System gelangen, z. B. durch Background-Jobs oder Migrationsskripte.

11. Fazit

FastAPI kombiniert mit Pydantic transformiert Type-Hints in Datenverträge und hat drei Hauptvorteile:

  • Falsche Anfragen werden an der Eingabe blockiert.
  • Fehler werden vollständig und strukturiert zurückgegeben.
  • Die OpenAPI-Dokumentation stimmt immer mit dem Code überein.

In diesem Artikel hast du Field für einfache Einschränkungen, Path und Query für URL-Parameter, field_validator und model_validator für benutzerdefinierte Regeln verwendet. Du hast auch response_model verwendet, um Output zu filtern, das 422-Fehlerformat zu ändern, und alles mit TestClient getestet.

Wichtiges Prinzip: Lass Validatoren die Form der Daten überprüfen, während die Geschäftslogik in die Service-Schicht gehört.

pythonfastapipydanticrest-apivalidation