API TaskBoard z walidacją
Agenda
- Przygotowanie
- Projekt API przed kodem
- Dane startowe: użytkownicy, projekty, zadania
- Modele zadania i endpointy zadań
- PATCH
- Projekty i użytkownicy
- Jednolity format błędów
- Frontend i kontrakt
- OpenAPI jako kontrakt
- Zadania samodzielne i eksperymenty
- 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żka | Sukces | Błędy |
|---|---|---|---|
| GET | /projects | 200 | |
| POST | /projects | 201 | 409, 422 |
| GET | /projects/{project_id} | 200 | 404 |
| PATCH | /projects/{project_id} | 200 | 404, 409, 422 |
| DELETE | /projects/{project_id} | 204 | 404, 409 |
| GET | /users | 200 | |
| GET | /users/{user_id} | 200 | 404 |
| GET | /tasks | 200 | 422 |
| POST | /tasks | 201 | 400, 409, 422 |
| GET | /tasks/{task_id} | 200 | 404 |
| PATCH | /tasks/{task_id} | 200 | 400, 404, 409, 422 |
| DELETE | /tasks/{task_id} | 204 | 404 |
PATCH i DELETE dla projektów to zadanie samodzielne.
Oprócz tego:
- zadanie zawsze należy do istniejącego projektu,
- modele
TaskCreate,TaskUpdate,TaskOutrozdzielają 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 ma | Kiedy |
|---|---|
| podziału kodu na pliki i warstwy | zajęcia 5 |
| bazy danych | zajęcia 6 |
| sortowania i paginacji | zajęcia 6 |
| wyboru projektu i edycji zadań w interfejsie | zajęcia 7 |
| tworzenia użytkowników | zajęcia 8, rejestracja |
3. Wymagania
- projekt TaskBoard po laboratorium 3 albo kopia
labs/04-api-contract/starter/, - działający
uv,curli 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
| Typ | Reguł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
| Model | Rola |
|---|---|
TaskCreate | ciało POST /tasks: tylko pola, które klient może ustawić |
TaskOut | reprezentacja zadania zwracana klientowi |
TaskPage | odpowiedź 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_modelmówi FastAPI, przez jaki model przepuścić wartość zwróconą z funkcji. Funkcja zwraca słownik z magazynu, a do klienta trafiają tylko polaTaskOut. Ten sam model opisuje odpowiedź w/docs.status: Status | Nonew parametrach query:?status=closedzostanie odrzucone z 422, zanim kod funkcji się wykona.Query(ge=1, le=100)ograniczalimit. 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
/tasksistnieje, 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ło | changes |
|---|---|
{"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
UserOutnie 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.ProjectOutzawieratask_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.
| Handler | Obsługuje |
|---|---|
StarletteHTTPException | HTTPException z endpointów (404, 409), a także 404 dla nieznanej ścieżki i 405 dla niedozwolonej metody, zgłaszane przez sam framework |
RequestValidationError | błę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
| Zmiana | Co trzeba zrobić w kliencie |
|---|---|
project_id wymagane w POST /tasks | wysyłać project_id |
| zadania należą do projektów | pobierać listę zadań jednego projektu |
Problem Details, errors dla 422 | przekazać listę błędów pól do interfejsu |
| PUT zastąpiony przez PATCH | zmienić listę metod w CORS |
nowy status in_progress | dodać 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.
- Lista pokazuje trzy zadania projektu TaskBoard, a w Network widać
GET /tasks?project_id=1. - Dodanie zadania wysyła
POST /taskszproject_idw ciele (zakładka Payload) i odświeża listę. - Ponowne dodanie tego samego tytułu pokazuje komunikat 409.
- Filtr
in_progresspokazuje 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.
- Rozwiń
POST /tasks. Sekcja Request body pokazuje schematTaskCreate, sekcja Responses dla 201 pokazujeTaskOut. Porównaj oba schematy: które pola są tylko w jednym z nich? - Na dole strony, w sekcji Schemas, rozwiń
TaskCreate. Znajdź: pola wymagane, ograniczenia długoścititle, listę wartościstatus. - Rozwiń
GET /tasks. Odczytaj ograniczenia parametrulimiti dopuszczalne wartościstatus. - 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:
ProjectUpdatez opcjonalnymi polaminameidescription,extra="forbid",namenie może byćnull,descriptionmoż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
- W
POST /tasksiPATCH /tasks/{task_id}odrzucajassignee_idwskazujące nieistniejącego użytkownika (422, poleassignee_id).nulljest dozwolony. - Dodaj do
GET /tasksfiltrassignee_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
OKalboBLADdla 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.mdzawiera tabelę zasobów i decyzje projektowe - zadania mają modele
TaskCreate,TaskUpdate,TaskOut -
POST /tasksz polem"id"zwraca 422 -
POST /tasksz tytułem" "zwraca 422, a tytuł" X "jest zapisywany jako"X" -
POST /tasksz nieistniejącymproject_idzwraca 422, a nie 404 -
GET /tasks?project_id=999zwraca 200 i pustą listę, i wiesz dlaczego -
PATCH /tasks/{id}zmienia tylko przesłane pola -
PATCHz{"assignee_id": null}usuwa przypisanie, a{"title": null}zwraca 422 -
GET /usersnie zwraca adresów e-mail -
GET /projectszwracatask_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_idi pokazuje komunikat 409 - potrafisz wskazać w
/docsróżnicę międzyTaskCreateaTaskOut - 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
| Element | Stan | Kiedy się zmieni |
|---|---|---|
app/main.py | około 300 linii: dane, typy, modele, handlery, endpointy | zajęcia 5, podział na moduły |
| dane | słowniki w pamięci, znikają po restarcie | zajęcia 6, PostgreSQL |
limit bez offset | brak pełnej paginacji | zajęcia 6 |
PROJECT_ID = 1 we frontendzie | wybór projektu wpisany w kod | zajęcia 7 |
/users tylko do odczytu | brak rejestracji | zaję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.