Podstawy aplikacji webowych

API TaskBoard z walidacją


Agenda

  1. Przygotowanie
  2. Projekt API przed kodem
  3. Dane startowe: użytkownicy, projekty, zadania
  4. Modele zadania i endpointy zadań
  5. PATCH
  6. Projekty i użytkownicy
  7. Jednolity format błędów
  8. Frontend i kontrakt
  9. OpenAPI jako kontrakt
  10. Zadania samodzielne i eksperymenty
  11. Checkpoint

1. Cel

Po laboratorium 3 API TaskBoard działa, ale powstało przez dopisywanie endpointów: zna tylko zadania, akceptuje pusty tytuł, przyjmuje dowolny status, a błędy mają różne kształty.

Dziś API dostaje projekt i kontrakt. Najpierw zapiszesz, jakie zasoby, metody i statusy ma udostępniać, a dopiero potem napiszesz kod. Każdy endpoint dostanie jawny model danych wejściowych i wyjściowych, a każdy błąd ten sam format.

Każdy krok zaczyna się od krótkiej motywacji z wykładu 4 i od razu stosuje poprawny wzorzec. Złe warianty i ich skutki były na wykładzie. Jeżeli chcesz je zobaczyć u siebie, na końcu instrukcji znajdziesz sekcję eksperymentów.


2. Co będzie gotowe na końcu

API z trzema zasobami:

MetodaŚcieżkaSukcesBłędy
GET/projects200
POST/projects201409, 422
GET/projects/{project_id}200404
PATCH/projects/{project_id}200404, 409, 422
DELETE/projects/{project_id}204404, 409
GET/users200
GET/users/{user_id}200404
GET/tasks200422
POST/tasks201400, 409, 422
GET/tasks/{task_id}200404
PATCH/tasks/{task_id}200400, 404, 409, 422
DELETE/tasks/{task_id}204404

PATCH i DELETE dla projektów to zadanie samodzielne.

Oprócz tego:

  • zadanie zawsze należy do istniejącego projektu,
  • modele TaskCreate, TaskUpdate, TaskOut rozdzielają to, co klient wysyła, od tego, co dostaje,
  • nieznane pola w ciele żądania są odrzucane,
  • tytuł złożony z samych spacji jest odrzucany,
  • PATCH zmienia tylko przesłane pola,
  • każdy błąd ma format Problem Details z Content-Type: application/problem+json,
  • frontend działa z nowym kontraktem.

Struktura projektu się nie zmienia. Cały kod backendu nadal jest w app/main.py. Plik urośnie do około 300 linii i to jest zamierzone: na zajęciach 5 podzielimy go na moduły.

Czego dziś świadomie nie ma

Czego nie maKiedy
podziału kodu na pliki i warstwyzajęcia 5
bazy danychzajęcia 6
sortowania i paginacjizajęcia 6
wyboru projektu i edycji zadań w interfejsiezajęcia 7
tworzenia użytkownikówzajęcia 8, rejestracja

3. Wymagania

  • projekt TaskBoard po laboratorium 3 albo kopia labs/04-api-contract/starter/,
  • działający uv, curl i przeglądarka z DevTools,
  • wykład 4: zasób i reprezentacja, projekt URI, model wejścia i wyjścia, PATCH, Problem Details.

4. Krok 0 — przygotowanie

Cel

Zacząć od działającego, zapisanego stanu.

Działanie

Terminal 1, serwer API:

cd ~/projects/taskboard
uv run fastapi dev app/main.py

Terminal 2, serwer plików frontendu:

uv run python -m http.server 5500 --bind 127.0.0.1 --directory frontend

Terminal 3 zostaje na polecenia curl i git.

Otwórz http://127.0.0.1:5500 i sprawdź, że lista zadań się wyświetla. Następnie zapisz stan:

git status
git add -A
git commit -m "Stan po laboratorium 3"

Jeżeli git status pokazuje czyste drzewo, commit nie jest potrzebny.

Wyjaśnienie

Dzisiejsze zmiany dotykają prawie całego app/main.py. Commit na starcie daje punkt odniesienia: git diff pokaże, co się zmieniło, a sekcja eksperymentów będzie z niego korzystać.


5. Krok 1 — projekt API przed kodem

Cel

Podjąć decyzje projektowe, zanim powstanie kod. Tabela zasobów jest pierwszą wersją kontraktu.

Działanie

Otwórz README.md i zastąp sekcję „Endpointy" tabelą z sekcji 2 tej instrukcji. Dopisz pod nią decyzje:

### Decyzje projektowe

- Zadanie należy do projektu. Relację wyraża filtr `GET /tasks?project_id=1`, a nie zagnieżdżenie `/projects/1/tasks`.
- Zadanie zmieniamy przez PATCH. PUT zostaje usunięty.
- Użytkownicy są tylko do odczytu. Tworzenie kont wymaga rejestracji (zajęcia 8).
- Tytuł zadania jest unikalny w obrębie projektu.
- Projektu z zadaniami nie można usunąć (409).
- Odwołanie w ciele żądania do nieistniejącego projektu lub użytkownika to 422, a nie 404.
- Wszystkie błędy mają format Problem Details (RFC 9457).

Wyjaśnienie

Każda z tych decyzji mogła wypaść inaczej. Projekt z zadaniami można by usuwać kaskadowo, PUT można by zostawić obok PATCH. Ważne, żeby decyzja była podjęta świadomie i zapisana, zanim powstanie kod, który ją realizuje. Ta tabela odpowiada na pytanie z wykładu: co musi istnieć w poniedziałek, żeby frontend i backend mogły pracować równolegle.

Zastanów się nad pytaniem kontrolnym: co zwróci GET /tasks?project_id=999, gdy projekt 999 nie istnieje? Odpowiedź sprawdzisz w kroku 3.

Checkpoint 1

README.md zawiera tabelę zasobów i listę decyzji.


6. Krok 2 — dane startowe

Cel

Dodać do magazynu w pamięci użytkowników i projekty oraz powiązać zadania z projektami.

Działanie

W app/main.py zastąp słownik TASKS i licznik NEXT_ID poniższym fragmentem. Dane nie niosą żadnego mechanizmu, więc możesz je skopiować:

USERS: dict[int, dict] = {
    1: {"id": 1, "name": "Anna Nowak", "email": "anna.nowak@example.com"},
    2: {"id": 2, "name": "Jan Kowalski", "email": "jan.kowalski@example.com"},
    3: {"id": 3, "name": "Ewa Wiśniewska", "email": "ewa.wisniewska@example.com"},
}

PROJECTS: dict[int, dict] = {
    1: {"id": 1, "name": "TaskBoard", "description": "Aplikacja rozwijana na laboratoriach"},
    2: {"id": 2, "name": "Praca dyplomowa", "description": None},
}

TASKS: dict[int, dict] = {
    1: {"id": 1, "project_id": 1, "title": "Przygotować środowisko", "status": "done", "priority": "high", "assignee_id": 1},
    2: {"id": 2, "project_id": 1, "title": "Przeczytać RFC 9110", "status": "in_progress", "priority": "medium", "assignee_id": 2},
    3: {"id": 3, "project_id": 1, "title": "Zaprojektować API", "status": "open", "priority": "low", "assignee_id": None},
    4: {"id": 4, "project_id": 2, "title": "Wybrać temat", "status": "open", "priority": "high", "assignee_id": 3},
}

next_project_id = count(3)
next_task_id = count(5)

Na początku pliku dodaj import:

from itertools import count

Wyjaśnienie

count(5) tworzy licznik zwracający kolejno 5, 6, 7… przy każdym wywołaniu next(next_task_id). Zastępuje zmienną globalną NEXT_ID i instrukcję global. Identyfikatory nie są używane ponownie po usunięciu zasobu: URI /tasks/5 nigdy nie wskaże innego zadania niż to, które raz pod nim istniało.

Rekord użytkownika zawiera adres e-mail. W kroku 5 zdecydujesz, że nie jest on częścią reprezentacji użytkownika w API.

Po zapisaniu pliku serwer się przeładuje, ale endpointy zadań jeszcze korzystają z NEXT_ID. Naprawi to krok 3, więc nie sprawdzaj teraz POST /tasks.


7. Krok 3 — modele zadania i endpointy zadań

Cel

Opisać typami, co klient może wysłać przy tworzeniu zadania i co dostaje w odpowiedzi.

Typy wspólne

Pod danymi startowymi dodaj typy, z których zbudujesz modele:

def not_blank(value: str) -> str:
    value = value.strip()
    if not value:
        raise ValueError("Wartość nie może składać się z samych białych znaków")
    return value


Title = Annotated[str, Field(min_length=1, max_length=200), AfterValidator(not_blank)]
Name = Annotated[str, Field(min_length=1, max_length=100), AfterValidator(not_blank)]
Description = Annotated[str, Field(max_length=500)]
Status = Literal["open", "in_progress", "done"]
Priority = Literal["low", "medium", "high"]

Zaktualizuj importy na początku pliku:

from itertools import count
from typing import Annotated, Literal

from fastapi import FastAPI, HTTPException, Query, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from pydantic import AfterValidator, BaseModel, ConfigDict, Field

Wyjaśnienie typów

TypReguła
Literal["open", "in_progress", "done"]tylko jedna z trzech wartości
Field(min_length=1, max_length=200)długość tekstu
AfterValidator(not_blank)własna reguła: po usunięciu spacji z brzegów coś musi zostać

" " ma długość 3, więc przechodzi min_length=1. Dlatego potrzebna jest własna reguła. Funkcja not_blank jednocześnie waliduje i normalizuje: zwraca tytuł bez spacji na brzegach, więc " Napisać testy " zostanie zapisane jako "Napisać testy".

Pydantic najpierw sprawdza typ i Field, potem wywołuje not_blank. ValueError zgłoszony w funkcji staje się błędem walidacji i odpowiedzią 422.

Annotated pozwala nazwać typ razem z regułami i użyć go w wielu modelach. Name i Description przydadzą się w kroku 5.

Modele

Usuń klasę TaskIn i w jej miejsce dodaj:

class TaskCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    project_id: int
    title: Title
    status: Status = "open"
    priority: Priority = "medium"
    assignee_id: int | None = None


class TaskOut(BaseModel):
    id: int
    project_id: int
    title: str
    status: Status
    priority: Priority
    assignee_id: int | None


class TaskPage(BaseModel):
    items: list[TaskOut]
    total: int
ModelRola
TaskCreateciało POST /tasks: tylko pola, które klient może ustawić
TaskOutreprezentacja zadania zwracana klientowi
TaskPageodpowiedź GET /tasks: lista i liczba wszystkich pasujących

TaskCreate nie ma pola id. Identyfikator nadaje serwer. extra="forbid" sprawia, że ciało z polem spoza modelu, np. "id": 1, zostanie odrzucone z kodem 422. Tak zabezpieczasz się przed mass assignment z wykładu.

Funkcje pomocnicze

Pod modelami:

def invalid_reference(field: str, message: str) -> RequestValidationError:
    return RequestValidationError([{"type": "invalid_reference", "loc": ("body", field), "msg": message}])


def get_task_or_404(task_id: int) -> dict:
    task = TASKS.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail=f"Zadanie {task_id} nie istnieje")
    return task


def check_unique_title(project_id: int, title: str, task_id: int | None = None) -> None:
    for task in TASKS.values():
        if task["project_id"] == project_id and task["title"] == title and task["id"] != task_id:
            raise HTTPException(status_code=409, detail="Zadanie o tym tytule już istnieje w projekcie")

invalid_reference tworzy ten sam wyjątek, którego FastAPI używa dla błędów walidacji. Dzięki temu odwołanie do nieistniejącego projektu da 422 o tym samym kształcie co pusty tytuł. Parametr task_id w check_unique_title przyda się w PATCH: zadanie nie konfliktuje samo ze sobą.

Usuń funkcję find_by_title. Zastępuje ją check_unique_title.

Endpointy zadań

Zastąp wszystkie endpointy /tasks poniższymi. Endpoint PUT /tasks/{task_id} usuń.

@app.get("/tasks", response_model=TaskPage)
def list_tasks(
    response: Response,
    status: Status | None = None,
    project_id: int | None = None,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
    items = list(TASKS.values())
    if status is not None:
        items = [task for task in items if task["status"] == status]
    if project_id is not None:
        items = [task for task in items if task["project_id"] == project_id]
    response.headers["X-Total-Count"] = str(len(items))
    return {"items": items[:limit], "total": len(items)}


@app.post("/tasks", status_code=201, response_model=TaskOut)
def create_task(payload: TaskCreate, response: Response):
    if payload.project_id not in PROJECTS:
        raise invalid_reference("project_id", f"Projekt {payload.project_id} nie istnieje")
    check_unique_title(payload.project_id, payload.title)
    task_id = next(next_task_id)
    task = {"id": task_id, **payload.model_dump()}
    TASKS[task_id] = task
    response.headers["Location"] = f"/tasks/{task_id}"
    return task


@app.get("/tasks/{task_id}", response_model=TaskOut)
def get_task(task_id: int):
    return get_task_or_404(task_id)


@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    get_task_or_404(task_id)
    del TASKS[task_id]

Wyjaśnienie

  • response_model mówi FastAPI, przez jaki model przepuścić wartość zwróconą z funkcji. Funkcja zwraca słownik z magazynu, a do klienta trafiają tylko pola TaskOut. Ten sam model opisuje odpowiedź w /docs.
  • status: Status | None w parametrach query: ?status=closed zostanie odrzucone z 422, zanim kod funkcji się wykona.
  • Query(ge=1, le=100) ogranicza limit. Klient nie poprosi o milion rekordów.
  • {"id": task_id, **payload.model_dump()} buduje rekord z identyfikatora nadanego przez serwer i z pól, które przeszły walidację. Klient nie ma jak przemycić innych pól.
  • 422 dla nieistniejącego projektu: URI /tasks istnieje, problemem są dane w ciele. 404 oznaczałoby, że nie ma endpointu.

Sprawdzenie

curl -i -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "  Napisać testy  "}'

Oczekiwane: 201 Created, nagłówek location: /tasks/5, w ciele "title":"Napisać testy" bez spacji na brzegach i wartości domyślne status oraz priority.

Reguły kontraktu sprawdź jednym poleceniem każdą:

curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "   "}'
curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "Nowe", "id": 1}'
curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 999, "title": "Nowe"}'
curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "Zaprojektować API"}'

Oczekiwane kolejno: 422 z komunikatem o białych znakach, 422 extra_forbidden dla pola id, 422 dla project_id, 409. W tej chwili błędy mają jeszcze domyślny format FastAPI. Ujednolicisz go w kroku 6.

Odpowiedź na pytanie z kroku 1:

curl -s "http://127.0.0.1:8000/tasks?project_id=999"

Oczekiwane: 200 i pusta lista. Kolekcja /tasks istnieje, filtr niczego nie znalazł.

curl -s "http://127.0.0.1:8000/tasks?project_id=1&status=open"

Checkpoint 2

POST /tasks z tytułem " " oraz z polem "id" zwraca 422. /docs pokazuje osobne schematy TaskCreate i TaskOut.


8. Krok 4 — PATCH

Cel

Umożliwić zmianę wybranych pól zadania bez przesyłania całego zadania.

Model TaskUpdate

Pod TaskCreate:

class TaskUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    title: Title | None = None
    status: Status | None = None
    priority: Priority | None = None
    assignee_id: int | None = None

    @field_validator("title", "status", "priority")
    @classmethod
    def reject_null(cls, value: str | None) -> str:
        if value is None:
            raise ValueError("Pole nie może mieć wartości null")
        return value

Do importu z pydantic dopisz field_validator.

Wyjaśnienie modelu

Wszystkie pola są opcjonalne, bo PATCH przesyła tylko to, co się zmienia. Model nie ma project_id: przenoszenie zadań między projektami nie jest częścią kontraktu.

Pole w ciele PATCH może być w jednym z trzech stanów:

{"status": "done"}        -> assignee_id pominięte   -> nie zmieniaj przypisania
{"assignee_id": null}     -> assignee_id równe null  -> usuń przypisanie
{"assignee_id": 2}        -> assignee_id z wartością -> przypisz użytkownika 2

assignee_id może być null, bo zadanie może nie mieć przypisanej osoby. title, status i priority nie mogą. Walidator reject_null to pilnuje. Pydantic nie uruchamia walidatorów dla wartości domyślnych, więc reject_null wykona się tylko wtedy, gdy klient przesłał pole. Pole pominięte przechodzi, jawny null daje 422.

Endpoint

Pod get_task:

@app.patch("/tasks/{task_id}", response_model=TaskOut)
def update_task(task_id: int, payload: TaskUpdate):
    task = get_task_or_404(task_id)
    changes = payload.model_dump(exclude_unset=True)
    if "title" in changes:
        check_unique_title(task["project_id"], changes["title"], task_id)
    task.update(changes)
    return task

Wyjaśnienie endpointu

Pydantic pamięta, które pola klient faktycznie przesłał. model_dump(exclude_unset=True) zwraca tylko je:

Ciałochanges
{"status": "done"}{"status": "done"}
{"assignee_id": null}{"assignee_id": None}
{}{}

Dlatego task.update(changes) zmienia dokładnie to, o co prosił klient. Taka semantyka odpowiada standardowi JSON Merge Patch (RFC 7396).

Sprawdzenie

curl -s -X PATCH http://127.0.0.1:8000/tasks/2 -H "Content-Type: application/json" \
  -d '{"status": "done"}'

Oczekiwane: status równy done, assignee_id nadal 2, pozostałe pola bez zmian.

curl -s -X PATCH http://127.0.0.1:8000/tasks/2 -H "Content-Type: application/json" \
  -d '{"assignee_id": null}'
curl -s -X PATCH http://127.0.0.1:8000/tasks/2 -H "Content-Type: application/json" \
  -d '{"title": null}'
curl -s -X PATCH http://127.0.0.1:8000/tasks/2 -H "Content-Type: application/json" \
  -d '{"title": "Zaprojektować API"}'
curl -s -X PATCH http://127.0.0.1:8000/tasks/3 -H "Content-Type: application/json" \
  -d '{"title": "Zaprojektować API"}'

Oczekiwane kolejno: 200 z "assignee_id": null, 422, 409 (taki tytuł ma zadanie 3), 200 (zadanie 3 nie konfliktuje samo ze sobą).

Checkpoint 3

PATCH /tasks/1 z ciałem {"status": "done"} zmienia wyłącznie status. PATCH z {"assignee_id": null} usuwa przypisanie.


9. Krok 5 — projekty i użytkownicy

Cel

Udostępnić pozostałe zasoby domeny. Zobaczyć, że reprezentacja zasobu nie musi być kopią rekordu.

Modele

Pod TaskPage:

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


class ProjectCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: Name
    description: Description | None = None


class ProjectOut(BaseModel):
    id: int
    name: str
    description: str | None
    task_count: int

Wyjaśnienie modeli

  • UserOut nie zawiera adresu e-mail. Do przypisania zadania wystarczy identyfikator i imię. Model wyjścia działa jak filtr: pole, którego w nim nie ma, nie trafi do odpowiedzi, nawet gdy jest w rekordzie. Gdy w przyszłości do rekordu dojdzie hash hasła, nie wycieknie przez API bez świadomej decyzji.
  • ProjectOut zawiera task_count, którego nie ma w rekordzie projektu. Reprezentacja może zawierać pola wyliczane.

Funkcje pomocnicze

Pod get_task_or_404:

def get_project_or_404(project_id: int) -> dict:
    project = PROJECTS.get(project_id)
    if project is None:
        raise HTTPException(status_code=404, detail=f"Projekt {project_id} nie istnieje")
    return project


def project_out(project: dict) -> dict:
    task_count = sum(1 for task in TASKS.values() if task["project_id"] == project["id"])
    return {**project, "task_count": task_count}

Endpointy

Pod endpointem /health:

@app.get("/users", response_model=list[UserOut])
def list_users():
    return list(USERS.values())


@app.get("/users/{user_id}", response_model=UserOut)
def get_user(user_id: int):
    user = USERS.get(user_id)
    if user is None:
        raise HTTPException(status_code=404, detail=f"Użytkownik {user_id} nie istnieje")
    return user


@app.get("/projects", response_model=list[ProjectOut])
def list_projects():
    return [project_out(project) for project in PROJECTS.values()]


@app.post("/projects", status_code=201, response_model=ProjectOut)
def create_project(payload: ProjectCreate, response: Response):
    if any(project["name"] == payload.name for project in PROJECTS.values()):
        raise HTTPException(status_code=409, detail="Projekt o tej nazwie już istnieje")
    project_id = next(next_project_id)
    project = {"id": project_id, **payload.model_dump()}
    PROJECTS[project_id] = project
    response.headers["Location"] = f"/projects/{project_id}"
    return project_out(project)


@app.get("/projects/{project_id}", response_model=ProjectOut)
def get_project(project_id: int):
    return project_out(get_project_or_404(project_id))

response_model=list[UserOut] stosuje model do każdego elementu listy.

Sprawdzenie

curl -s http://127.0.0.1:8000/users
curl -s http://127.0.0.1:8000/projects
curl -i -X POST http://127.0.0.1:8000/projects -H "Content-Type: application/json" \
  -d '{"name": "Laboratoria", "description": "Zadania z laboratoriów"}'
curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 3, "title": "Laboratorium 4"}'
curl -s http://127.0.0.1:8000/projects/3

Oczekiwane: użytkownicy bez adresów e-mail, projekty z polem task_count, nowy projekt z kodem 201 i nagłówkiem location: /projects/3, zadanie w nowym projekcie, a projekt 3 z "task_count": 1.

Jeżeli serwer przeładował się od kroku 3, identyfikator nowego projektu też będzie 3, bo dane wracają do stanu startowego.

Checkpoint 4

GET /users nie zwraca adresów e-mail. POST /tasks z "project_id": 999 zwraca 422. GET /tasks?project_id=1 zwraca wyłącznie zadania projektu 1.


10. Krok 6 — jednolity format błędów

Cel

Sprawić, żeby każdy błąd API miał ten sam kształt, zgodny ze standardem Problem Details (RFC 9457), a uszkodzony JSON dawał 400.

Model błędu

Błąd też jest reprezentacją, więc dostaje model. Dodaj pod ProjectOut:

class FieldError(BaseModel):
    location: str
    field: str
    message: str


class Problem(BaseModel):
    type: str = "about:blank"
    title: str
    status: int
    detail: str
    errors: list[FieldError] | None = None

Funkcja budująca odpowiedź

Przenieś app = FastAPI(...) oraz app.add_middleware(...) tak, aby znalazły się pod modelami, a nad tą funkcją:

def problem(status: int, detail: str, errors: list[dict] | None = None, headers: dict | None = None) -> JSONResponse:
    body = Problem(title=HTTPStatus(status).phrase, status=status, detail=detail, errors=errors)
    return JSONResponse(
        body.model_dump(exclude_none=True),
        status_code=status,
        headers=headers,
        media_type="application/problem+json",
    )

HTTPStatus(404).phrase zwraca "Not Found". Dla "type": "about:blank" standard wymaga, żeby title był nazwą statusu HTTP. exclude_none=True pomija pole errors, gdy go nie ma.

Przeniesienie app pod modele nie jest jeszcze potrzebne, ale ułatwi zadanie samodzielne 5.

Handlery wyjątków

Pod funkcją problem:

@app.exception_handler(StarletteHTTPException)
def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
    return problem(exc.status_code, str(exc.detail), headers=exc.headers)


@app.exception_handler(RequestValidationError)
def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
    if any(error["type"] == "json_invalid" for error in exc.errors()):
        return problem(400, "Ciało żądania nie jest poprawnym dokumentem JSON")
    errors = [
        {
            "location": error["loc"][0],
            "field": ".".join(str(part) for part in error["loc"][1:]),
            "message": error["msg"],
        }
        for error in exc.errors()
    ]
    return problem(422, "Dane wejściowe nie spełniają kontraktu API", errors)

Uzupełnij importy:

from http import HTTPStatus
from itertools import count
from typing import Annotated, Literal

from fastapi import FastAPI, HTTPException, Query, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, field_validator
from starlette.exceptions import HTTPException as StarletteHTTPException

Wyjaśnienie

Exception handler to funkcja, którą FastAPI wywołuje, gdy podczas obsługi żądania zostanie zgłoszony wyjątek danego typu. Funkcja zwraca odpowiedź, która trafi do klienta. Endpointy dalej zgłaszają HTTPException i RequestValidationError, ale o kształcie odpowiedzi decyduje jedno miejsce.

HandlerObsługuje
StarletteHTTPExceptionHTTPException z endpointów (404, 409), a także 404 dla nieznanej ścieżki i 405 dla niedozwolonej metody, zgłaszane przez sam framework
RequestValidationErrorbłędy Pydantic w ciele, ścieżce i query oraz błędy z invalid_reference

Handler rejestrujemy dla klasy ze Starlette, a nie z FastAPI, bo fastapi.HTTPException dziedziczy po niej, a framework zgłasza błędy 404 i 405 jako instancje klasy bazowej. headers=exc.headers przenosi nagłówki z wyjątku, np. Allow przy 405.

FastAPI zgłasza uszkodzony JSON jako błąd walidacji z typem json_invalid. Handler rozpoznaje ten typ i zwraca 400, zgodnie z rozróżnieniem z wykładu: deserializacja kontra walidacja.

error["loc"] to krotka, np. ("body", "title") albo ("query", "limit"). Pierwszy element mówi, gdzie był błąd, reszta wskazuje pole.

Komunikaty z własnych walidatorów Pydantic poprzedza prefiksem Value error,. Komunikaty wbudowanych reguł są po angielsku, np. Field required. Jest to zachowanie biblioteki, nie błąd.

Sprawdzenie

curl -i -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" -d '{"title": '

Oczekiwane:

HTTP/1.1 400 Bad Request
content-type: application/problem+json

{"type":"about:blank","title":"Bad Request","status":400,"detail":"Ciało żądania nie jest poprawnym dokumentem JSON"}
curl -i -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "   ", "priority": "urgent"}'
curl -i http://127.0.0.1:8000/tasks/999
curl -i http://127.0.0.1:8000/nie-ma-takiej-sciezki
curl -i -X POST http://127.0.0.1:8000/projects -H "Content-Type: application/json" -d '{"name": "TaskBoard"}'

Oczekiwane: 422 z dwoma elementami w errors (title i priority), 404 z "detail":"Zadanie 999 nie istnieje", 404 dla nieznanej ścieżki w tym samym formacie, 409. Każda odpowiedź ma content-type: application/problem+json.

Checkpoint 5

Odpowiedzi 400, 404, 409 i 422 mają Content-Type: application/problem+json i pola type, title, status, detail. Uszkodzony JSON zwraca 400.


11. Krok 7 — frontend i kontrakt

Cel

Dostosować klienta do nowego kontraktu. Kontrakt obowiązuje obie strony, więc zmiany w backendzie i frontendzie trafiają do tego samego commita.

Zmiany kontraktu, które dotyczą frontendu

ZmianaCo trzeba zrobić w kliencie
project_id wymagane w POST /taskswysyłać project_id
zadania należą do projektówpobierać listę zadań jednego projektu
Problem Details, errors dla 422przekazać listę błędów pól do interfejsu
PUT zastąpiony przez PATCHzmienić listę metod w CORS
nowy status in_progressdodać opcję do filtra

Pole detail w Problem Details jest tekstem, tak jak dotychczas dla 404 i 409. Odczyt komunikatu w api.js działa więc bez zmian. Ta część zmiany formatu jest zgodna wstecz.

CORS w app/main.py

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://127.0.0.1:5500"],
    allow_methods=["GET", "POST", "PATCH", "DELETE"],
    expose_headers=["X-Total-Count"],
)

PATCH będzie potrzebny przy edycji zadań z poziomu interfejsu. Dopisujemy go teraz, razem z endpointem.

frontend/js/api.js

Zmień klasę błędu, funkcję request i fetchTasks:

export class ApiError extends Error {
  constructor(status, message, errors = []) {
    super(message);
    this.status = status;
    this.errors = errors;
  }
}

async function request(path, options = {}) {
  const response = await fetch(`${API_URL}${path}`, options);
  if (!response.ok) {
    const problem = await response.json().catch(() => null);
    throw new ApiError(
      response.status,
      problem?.detail ?? response.statusText,
      problem?.errors ?? [],
    );
  }
  return response;
}

export async function fetchTasks({ projectId, status }) {
  const params = new URLSearchParams({ project_id: projectId });
  if (status) {
    params.set("status", status);
  }
  const response = await request(`/tasks?${params}`);
  const data = await response.json();
  return {
    items: data.items,
    totalHeader: response.headers.get("X-Total-Count"),
  };
}

Warunek typeof body?.detail === "string" z laboratorium 3 nie jest już potrzebny: kontrakt gwarantuje, że detail jest zawsze tekstem. ?? zwraca prawą stronę tylko wtedy, gdy lewa jest null lub undefined, np. gdy serwer pośredniczący zwrócił stronę HTML zamiast JSON.

fetchTasks przyjmuje teraz obiekt z nazwanymi parametrami. Przy dwóch parametrach tego samego typu wywołanie fetchTasks({ projectId: 1, status: "open" }) jest czytelniejsze niż fetchTasks(1, "open").

frontend/js/main.js

Pod stałymi z elementami DOM dodaj:

const PROJECT_ID = 1;

W loadTasks:

    const { items, totalHeader } = await fetchTasks({
      projectId: PROJECT_ID,
      status: statusFilter.value,
    });

W obsłudze formularza:

    await createTask({
      project_id: PROJECT_ID,
      title: data.get("title"),
      priority: data.get("priority"),
    });

Stała PROJECT_ID jest rozwiązaniem tymczasowym. Wybór projektu w interfejsie pojawi się na zajęciach 7, razem z modelem stanu frontendu.

frontend/index.html

W filtrze statusu dodaj opcję między open a done:

            <option value="in_progress">in_progress</option>

Sprawdzenie

Odśwież http://127.0.0.1:5500 z otwartą zakładką Network.

  1. Lista pokazuje trzy zadania projektu TaskBoard, a w Network widać GET /tasks?project_id=1.
  2. Dodanie zadania wysyła POST /tasks z project_id w ciele (zakładka Payload) i odświeża listę.
  3. Ponowne dodanie tego samego tytułu pokazuje komunikat 409.
  4. Filtr in_progress pokazuje zadanie „Przeczytać RFC 9110".

Zapisz stan:

git add -A
git commit -m "Kontrakt API: projekty, uzytkownicy, modele, PATCH, Problem Details"

Checkpoint 6

Frontend dodaje i usuwa zadania z nowym kontraktem. Próba dodania duplikatu wyświetla komunikat 409.


12. Krok 8 — OpenAPI jako kontrakt

Cel

Odczytać z wygenerowanej dokumentacji decyzje zapisane w kodzie i ocenić, czy dokument opisuje API zgodnie z rzeczywistością.

Działanie

Otwórz http://127.0.0.1:8000/docs.

  1. Rozwiń POST /tasks. Sekcja Request body pokazuje schemat TaskCreate, sekcja Responses dla 201 pokazuje TaskOut. Porównaj oba schematy: które pola są tylko w jednym z nich?
  2. Na dole strony, w sekcji Schemas, rozwiń TaskCreate. Znajdź: pola wymagane, ograniczenia długości title, listę wartości status.
  3. Rozwiń GET /tasks. Odczytaj ograniczenia parametru limit i dopuszczalne wartości status.
  4. Rozwiń GET /users. Czy z dokumentacji wynika, że API nie zwraca adresów e-mail?

Następnie w terminalu:

curl -s http://127.0.0.1:8000/openapi.json | python3 -m json.tool | grep -A 25 '"TaskCreate"'

Szukaj w wyniku "additionalProperties": false. To zapis extra="forbid" w JSON Schema.

Pytanie kontrolne

Rozwiń odpowiedź 422 dla POST /tasks. Jaki schemat pokazuje dokumentacja? Czy zgadza się z odpowiedzią, którą zwraca serwer po kroku 6? Czy odpowiedzi 404 i 409 są opisane?

Dokumentacja pokazuje domyślny schemat HTTPValidationError, a 404 i 409 nie są opisane. Dokument powstaje z deklaracji: typów parametrów, modeli, response_model. Handler błędów jest kodem wykonywanym w trakcie obsługi żądania, więc generator o nim nie wie. Za zgodność dokumentacji z rzeczywistością odpowiada zespół. Zadanie samodzielne 5 to poprawia.


13. Zadania samodzielne

Zadanie 1 — zmiana i usuwanie projektu

Dodaj PATCH /projects/{project_id} i DELETE /projects/{project_id} zgodnie z tabelą zasobów.

Wymagania:

  • ProjectUpdate z opcjonalnymi polami name i description, extra="forbid",
  • name nie może być null, description może (usunięcie opisu),
  • zmiana nazwy na nazwę innego istniejącego projektu daje 409,
  • usunięcie projektu, który ma zadania, daje 409 z czytelnym detail,
  • usunięcie pustego projektu daje 204.

Podpowiedź: wzorem jest TaskUpdate i update_task. Liczbę zadań projektu zwraca już project_out.

Sprawdzenie:

curl -i -X PATCH http://127.0.0.1:8000/projects/2 -H "Content-Type: application/json" -d '{"description": null}'
curl -i -X DELETE http://127.0.0.1:8000/projects/1

Oczekiwane: 200 z "description": null, 409.

Zadanie 2 — przypisanie użytkownika

  1. W POST /tasks i PATCH /tasks/{task_id} odrzucaj assignee_id wskazujące nieistniejącego użytkownika (422, pole assignee_id). null jest dozwolony.
  2. Dodaj do GET /tasks filtr assignee_id.

Podpowiedź: funkcja pomocnicza check_assignee(assignee_id: int | None) -> None wywoływana w obu endpointach. W PATCH tylko wtedy, gdy "assignee_id" in changes.

Sprawdzenie:

curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "Recenzja", "assignee_id": 999}'
curl -s "http://127.0.0.1:8000/tasks?assignee_id=2"

Zadanie 3 — błędy pól we frontendzie

Wpisz w formularzu tytuł złożony z samych spacji. Atrybut required w HTML go przepuści, bo pole nie jest puste, a serwer zwróci 422. Frontend pokazuje teraz tylko ogólne „Dane wejściowe nie spełniają kontraktu API".

Zmień funkcję describeError w main.js tak, aby pod komunikatem głównym wypisała każdy element error.errors w postaci pole: komunikat.

Podpowiedź: error.errors.map(...), łączenie znakiem nowej linii oraz reguła CSS white-space: pre-line dla klasy .message.

Zadanie 4 — skrypt kontroli kontraktu

Napisz skrypt check-api.sh, który dla każdego wiersza tabeli zasobów wysyła żądanie wywołujące dany status i porównuje otrzymany kod z oczekiwanym.

check() {
  local expected="$1"
  local method="$2"
  local path="$3"
  local body="${4:-}"
  ...
}

check 200 GET /users
check 404 GET /users/999
check 422 POST /tasks '{"title": "Bez projektu"}'

Wymagania:

  • skrypt wypisuje OK albo BLAD dla każdego sprawdzenia i podsumowanie,
  • kończy się kodem wyjścia 0 tylko wtedy, gdy wszystkie sprawdzenia przeszły,
  • można go uruchomić wielokrotnie bez restartu serwera: tworzy własny projekt o unikalnej nazwie i korzysta z jego identyfikatora.

Podpowiedź: curl -s -o /dev/null -w "%{http_code}" wypisuje tylko status. $$ to numer procesu powłoki, wystarczający do unikalnej nazwy. Identyfikator utworzonego zasobu jest w nagłówku Location.

Skrypt przyda się na zajęciach 5: po refaktoryzacji kodu wszystkie sprawdzenia muszą nadal przechodzić. Na zajęciach 11 zastąpią go testy w pytest.

Zadanie 5 — błędy w dokumentacji OpenAPI

Spraw, żeby /docs opisywało odpowiedzi 400, 404, 409 i 422 modelem Problem.

Podpowiedź: konstruktor FastAPI przyjmuje parametr responses, słownik postaci {404: {"model": Problem}}. Dlatego w kroku 6 app zostało przeniesione pod modele.

Sprawdzenie: w /docs odpowiedź 422 dla POST /tasks pokazuje schemat Problem, a w sekcji Schemas nie ma już HTTPValidationError.

Dokumentacja poda dla tych odpowiedzi typ application/json, choć serwer wysyła application/problem+json. Opisanie innego typu mediów wymaga ręcznej edycji schematu OpenAPI i wykracza poza zajęcia.


14. Eksperymenty: co się stanie, jeżeli…

Eksperymenty pokazują u ciebie skutki złych wariantów omówionych na wykładzie. Każdy zaczynasz od zacommitowanego, działającego stanu i kończysz powrotem do niego:

git restore app/main.py frontend/js/api.js

Eksperyment 1 — PATCH bez exclude_unset

W update_task zamień payload.model_dump(exclude_unset=True) na payload.model_dump() i wyślij:

curl -s -X PATCH http://127.0.0.1:8000/tasks/1 -H "Content-Type: application/json" -d '{"status": "open"}'

Co się stało z polami title, priority i assignee_id w magazynie danych? Dlaczego klient dostał 500, a nie zadanie z pustymi polami? Podpowiedź: co robi response_model, gdy title jest None? Sprawdź log serwera. Czy dane zostały zniszczone mimo błędu w odpowiedzi?

Eksperyment 2 — model bez extra="forbid"

Usuń model_config = ConfigDict(extra="forbid") z TaskCreate i wyślij:

curl -s -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" \
  -d '{"project_id": 1, "title": "Przemyt", "id": 1, "status": "done"}'
curl -s http://127.0.0.1:8000/tasks/1

Czy zadanie 1 się zmieniło? Co stało się z polem id z żądania? Porównaj z endpointem, który przyjmowałby payload: dict (demonstracja z wykładu). Która ochrona zadziałała, mimo braku extra="forbid"?

Eksperyment 3 — bez handlera walidacji

Zakomentuj dekorator @app.exception_handler(RequestValidationError) i wyślij uszkodzony JSON oraz pusty tytuł. Porównaj status, Content-Type i kształt ciała z wersją z handlerem. Którą wersję łatwiej obsłużyć we frontendzie?

Eksperyment 4 — handler dla klasy z FastAPI

Zmień @app.exception_handler(StarletteHTTPException) na @app.exception_handler(HTTPException) (klasa z fastapi). Wywołaj:

curl -i http://127.0.0.1:8000/tasks/999
curl -i http://127.0.0.1:8000/nie-ma-takiej-sciezki
curl -i -X PUT http://127.0.0.1:8000/tasks/1

Które odpowiedzi zachowały format Problem Details, a które wróciły do {"detail": ...}? Dlaczego?

Eksperyment 5 — stary klient, nowy kontrakt

Przywróć api.js i main.js z laboratorium 3:

git show HEAD~1:frontend/js/api.js > frontend/js/api.js
git show HEAD~1:frontend/js/main.js > frontend/js/main.js

HEAD~1 to commit sprzed kroku 7. Jeżeli robiłeś więcej commitów, użyj git log --oneline i podaj właściwy identyfikator.

Odśwież stronę i dodaj zadanie. Co widzi użytkownik? Które zadania pokazuje lista? Które ze zmian kontraktu z kroku 7 były zgodne wstecz, a które nie?

Powrót:

git restore frontend/js/api.js frontend/js/main.js

15. Typowe problemy

Problem 1 — NameError: name 'Request' is not defined lub podobny przy starcie serwera

Przyczyna: brakujący import. Kod z kolejnych kroków korzysta z nowych nazw.

Rozwiązanie: porównaj importy z pełną listą w kroku 6. Serwer w trybie dev wypisuje w terminalu 1 nazwę i linię, której dotyczy błąd.

Problem 2 — NameError: name 'app' is not defined przy dekoratorze

Przyczyna: po przeniesieniu app = FastAPI(...) w kroku 6 któryś dekorator @app... znalazł się nad definicją app.

Rozwiązanie: kolejność w pliku: importy, dane, typy, modele, app i middleware, funkcje problem i handlery, funkcje pomocnicze, endpointy.

Problem 3 — 404 dla nieznanej ścieżki ma format {"detail": "Not Found"}

Przyczyna: handler zarejestrowany dla fastapi.HTTPException zamiast starlette.exceptions.HTTPException.

Rozwiązanie: import from starlette.exceptions import HTTPException as StarletteHTTPException i dekorator @app.exception_handler(StarletteHTTPException). W endpointach nadal używaj HTTPException z fastapi.

Problem 4 — PATCH z {"title": null} zwraca 200 i czyści tytuł

Przyczyna: brak walidatora reject_null albo brak "title" na liście jego pól.

Rozwiązanie: @field_validator("title", "status", "priority") w TaskUpdate. Dekorator @classmethod musi być pod @field_validator, nie nad nim.

Problem 5 — 500 Internal Server Error, w terminalu ResponseValidationError

Przyczyna: funkcja zwróciła słownik, który nie pasuje do response_model. Najczęściej brakuje pola, np. task_count przy zwróceniu project zamiast project_out(project), albo rekord w magazynie ma None w polu wymaganym.

Rozwiązanie: log w terminalu 1 wymienia brakujące pole. Jeżeli dane w pamięci zostały uszkodzone, zapisz plik ponownie: serwer się przeładuje, a dane wrócą do stanu startowego.

Problem 6 — lista we frontendzie jest pusta, w Network GET /tasks?project_id=undefined i 422

Przyczyna: wywołanie fetchTasks(statusFilter.value) w starym stylu albo literówka w nazwie projectId.

Rozwiązanie: fetchTasks({ projectId: PROJECT_ID, status: statusFilter.value }). Nazwy kluczy muszą odpowiadać nazwom w definicji funkcji.

Problem 7 — błąd CORS przy usuwaniu zadania z interfejsu

Przyczyna: brak "DELETE" w allow_methods. Dotyczy osób, które nie wykonały zadania samodzielnego 1 na laboratorium 3.

Rozwiązanie: allow_methods=["GET", "POST", "PATCH", "DELETE"].

Problem 8 — identyfikatory w odpowiedziach różnią się od tych w instrukcji

Przyczyna: serwer w trybie dev przeładowuje się po każdym zapisie pliku, a dane w pamięci wracają do stanu startowego. Liczniki zaczynają od nowa.

Rozwiązanie: to oczekiwane zachowanie magazynu w pamięci. Sprawdzaj identyfikator w nagłówku Location zamiast zakładać konkretną wartość. Trwałość danych pojawi się na zajęciach 6.


16. Checklista końcowa

  • README.md zawiera tabelę zasobów i decyzje projektowe
  • zadania mają modele TaskCreate, TaskUpdate, TaskOut
  • POST /tasks z polem "id" zwraca 422
  • POST /tasks z tytułem " " zwraca 422, a tytuł " X " jest zapisywany jako "X"
  • POST /tasks z nieistniejącym project_id zwraca 422, a nie 404
  • GET /tasks?project_id=999 zwraca 200 i pustą listę, i wiesz dlaczego
  • PATCH /tasks/{id} zmienia tylko przesłane pola
  • PATCH z {"assignee_id": null} usuwa przypisanie, a {"title": null} zwraca 422
  • GET /users nie zwraca adresów e-mail
  • GET /projects zwraca task_count
  • wszystkie błędy mają Content-Type: application/problem+json
  • uszkodzony JSON zwraca 400, i potrafisz wyjaśnić, czym różni się od 422
  • 404 dla nieznanej ścieżki ma format Problem Details
  • frontend dodaje zadania z project_id i pokazuje komunikat 409
  • potrafisz wskazać w /docs różnicę między TaskCreate a TaskOut
  • zmiany są zacommitowane

17. Podsumowanie

Wzorce zastosowane dzisiaj i problemy, które rozwiązują:

tabela zasobów przed kodem          -> decyzje projektowe zapisane jako kontrakt
TaskCreate z extra="forbid"         -> klient nie ustawi pól serwera
TaskOut, UserOut, ProjectOut        -> odpowiedź zawiera tylko to, co ma zawierać
AfterValidator(not_blank)           -> własna reguła i normalizacja
model_dump(exclude_unset=True)      -> PATCH zmienia tylko przesłane pola
422 dla błędnego odwołania w ciele  -> 404 zarezerwowane dla URI
Problem Details i dwa handlery      -> jeden kształt błędu, 400 kontra 422
zmiana backendu i frontendu razem   -> kontrakt obowiązuje obie strony

Stan projektu

ElementStanKiedy się zmieni
app/main.pyokoło 300 linii: dane, typy, modele, handlery, endpointyzajęcia 5, podział na moduły
danesłowniki w pamięci, znikają po restarciezajęcia 6, PostgreSQL
limit bez offsetbrak pełnej paginacjizajęcia 6
PROJECT_ID = 1 we frontendziewybór projektu wpisany w kodzajęcia 7
/users tylko do odczytubrak rejestracjizajęcia 8

Na następne zajęcia

Wykład 5 — architektura backendu: ASGI, routing, middleware, dependency injection, cykl życia aplikacji, def kontra async def.

Laboratorium 5 — refaktoryzacja app/main.py na routers, schemas, repositories, services, dependencies i configuration. Zachowanie API nie może się zmienić, a skrypt check-api.sh z zadania 4 to sprawdzi.

 

Imię Nazwisko

OPIS