API студентов — полный CRUD: POST, GET, PUT и DELETE над базой данных
- Добавить POST, PUT и DELETE, связанные с функциями в db.py
- Выполнять бизнес-проверку в общей функции и возвращать 400
- Переводить контракт db.py в коды — 201, 204, 404 и 409
- Запустить полный сервер и правильно читать каждый ответ
Что мы изучим?
Завершаем API по таблице соответствий:
POST /students→ INSERT → 201.PUT /students/{id}→ UPDATE → 200 / 404 / 409.DELETE /students/{id}→ DELETE → 204 / 404.- Модель ввода (Pydantic) и общую бизнес-проверку (400).
Зачем это нужно?
Это короткий проект, который подводит итог: сервер + база данных + CRUD, как определяет программа курса. В конце урока есть настоящий API: приложение, фронтенд или другой сервис могут создавать, читать, обновлять и удалять студентов, а данные сохраняются в базе.
Объяснение с основ
Модель ввода
class StudentIn(BaseModel):
name: str
age: int
email: strStudentIn описывает, что клиент отправляет в POST и в PUT. id в ней нет, потому что id даёт база (POST) или путь (PUT). Имя ...In — распространённое соглашение: «модель того, что входит».
FastAPI проверяет структуру и типы (отсутствующее поле, текст вместо числа → 422, автоматически).
Бизнес-проверка — 400
Правила (возраст 16–120, имя не короче 2 символов, email с @) — наши. Проверяйте их в одной функции, которую вызывают POST и PUT (DRY):
def validate_student(student):
if student.age < 16 or student.age > 120:
raise HTTPException(status_code=400, detail="age must be between 16 and 120")Три слоя проверок, у каждого своя роль:
| Слой | Проверяет | Код |
|---|---|---|
| Pydantic (FastAPI) | Структуру и типы | 422 |
validate_student (main.py) | Бизнес-правила | 400 |
| Ограничения базы (db.py) | Уникальность, обязательность | 409 (через ValueError) |
204 No Content
При успешном DELETE возвращать нечего: студент удалён. status_code=204 говорит «я справился, без тела». Функция просто ничего не возвращает (None), и FastAPI отправляет пустой ответ.
Перевод ValueError
try:
return db.create_student(...)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))str(error) = сообщение, которое написал db.py («a student with email … already exists»). Сообщение проходит через два слоя: из базы (IntegrityError) через db.py (ValueError) к клиенту (409 + detail).
Синтаксис
@app.post("/students", status_code=201)
def create_student(student: StudentIn): # body
...
@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn): # path + body together
...
@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
... # return nothing on successВ PUT есть и параметр пути, и тело. FastAPI различает их: student_id — в пути, а student (модель) — в теле.
Первый пример кода
Законченный main.py:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import db
app = FastAPI(title="Students API")
db.init_db()
class StudentIn(BaseModel):
name: str
age: int
email: str
def validate_student(student):
if len(student.name.strip()) < 2:
raise HTTPException(status_code=400, detail="name must have at least 2 characters")
if student.age < 16 or student.age > 120:
raise HTTPException(status_code=400, detail="age must be between 16 and 120")
if "@" not in student.email:
raise HTTPException(status_code=400, detail="invalid email")
@app.get("/students")
def list_students():
return db.get_all_students()
@app.get("/students/{student_id}")
def get_student(student_id: int):
student = db.get_student(student_id)
if student is None:
raise HTTPException(status_code=404, detail="student not found")
return student
@app.post("/students", status_code=201)
def create_student(student: StudentIn):
validate_student(student)
try:
return db.create_student(student.name, student.age, student.email)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))
@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):
validate_student(student)
try:
updated = db.update_student(student_id, student.name, student.age, student.email)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))
if updated is None:
raise HTTPException(status_code=404, detail="student not found")
return updated
@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
if not db.delete_student(student_id):
raise HTTPException(status_code=404, detail="student not found")Запуск: uvicorn main:app --reload из папки проекта с активным venv.
Разбор кода построчно
| Часть | Объяснение |
|---|---|
validate_student(student) | Одна проверка для двух Endpoint-ов. raise HTTPException(400) останавливает запрос до обращения к базе. |
| POST | Проверка → db.create_student → возвращает студента из базы (с id) и 201. ValueError (email уже есть) → 409. |
| PUT | Проверка → db.update_student. Два разных сбоя: ValueError → 409, а None → 404. |
updated = ... внутри try, проверка на None — снаружи | try охватывает только то, что может выбросить ValueError (маленький try). |
| DELETE | db.delete_student возвращает False → 404. Успех → нет return → 204 без тела. |
if not db.delete_student(...) | Вызов и проверка в одной строке. Удаление происходит в любом случае, а not превращает False в «не удалось». |
Итого: файл примерно из 60 строк, в котором полный API. Каждый маршрут короткий, потому что вся тяжёлая работа (SQL, ограничения) — в db.py, а все преобразования (JSON, типы) — в FastAPI.
Ещё один пример
Все настоящие ответы сервера — последовательность запросов на пустой базе (проверено с кодом выше):
| # | Запрос | Тело | Код | Ответ |
|---|---|---|---|---|
| 1 | POST /students | {"name": "Dana", "age": 24, "email": "dana@mail.com"} | 201 | {"id": 1, "name": "Dana", "age": 24, "email": "dana@mail.com"} |
| 2 | POST /students | {"name": "Avi", "age": 31, "email": "avi@mail.com"} | 201 | {"id": 2, ...} |
| 3 | POST /students | {"name": "Dana2", "age": 20, "email": "dana@mail.com"} | 409 | {"detail": "a student with email dana@mail.com already exists"} |
| 4 | POST /students | {"name": "Kid", "age": 7, "email": "kid@mail.com"} | 400 | {"detail": "age must be between 16 and 120"} |
| 5 | POST /students | {"name": "Noa"} | 422 | {"detail": [{"type": "missing", "loc": ["body", "age"], ...}, {... "email" ...}]} |
| 6 | POST /students | {"name": "Noa", "age": "abc", "email": "n@m.com"} | 422 | {"detail": [{"type": "int_parsing", "loc": ["body", "age"], ...}]} |
| 7 | GET /students | — | 200 | [{"id": 1, ...Dana}, {"id": 2, ...Avi}] |
| 8 | GET /students/99 | — | 404 | {"detail": "student not found"} |
| 9 | GET /students/abc | — | 422 | {"detail": [{"type": "int_parsing", "loc": ["path", "student_id"], ...}]} |
| 10 | PUT /students/2 | {"name": "Avi Levi", "age": 32, "email": "avi@mail.com"} | 200 | {"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"} |
| 11 | PUT /students/99 | {"name": "Ghost", "age": 30, "email": "g@mail.com"} | 404 | {"detail": "student not found"} |
| 12 | DELETE /students/1 | — | 204 | (пусто) |
| 13 | DELETE /students/1 | — | 404 | {"detail": "student not found"} |
| 14 | GET /students | — | 200 | [{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}] |
А лог сервера (в терминале uvicorn) показывает строку для каждого запроса, например:
INFO: 127.0.0.1:49197 - "POST /students HTTP/1.1" 201 Created
INFO: 127.0.0.1:49198 - "GET /students/999 HTTP/1.1" 404 Not FoundКак правильно читать таблицу: у каждой строки причина в своём слое:
- 422 (строки 5, 6, 9) — FastAPI и Pydantic. Наш код не выполнялся.
- 400 (строка 4) —
validate_student. - 409 (строка 3) — база (
UNIQUE) черезdb.py. - 404 (строки 8, 11, 13) —
db.pyвернулNone/False.
Типичная ошибка
Ошибка 1: забыть status_code=201 в POST или 204 в DELETE. Всё «работает», но с кодом 200. Клиент (и автоматические проверки) ожидают правильные коды.
Ошибка 2: вернуть содержимое из DELETE с 204.
@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
db.delete_student(student_id)
return {"message": "deleted"}По стандарту HTTP у 204 не должно быть тела. Разные версии FastAPI обрабатывают это по-разному (ошибка или тело, которое выбрасывается). Либо 204 без return, либо 200 с сообщением. Не оба сразу.
Ошибка 3: забыть проверку на None в PUT. return db.update_student(...) возвращает 200 с null для несуществующего студента.
Ошибка 4: except Exception в маршруте.
try:
return db.create_student(...)
except Exception:
raise HTTPException(status_code=409, detail="conflict")Настоящий баг (например, отсутствующая таблица) вернётся как «409 Conflict» и всех запутает. Ловите только ValueError, а всё остальное придёт как 500, чтобы мы его увидели и исправили.
Ошибка 5: дублирующаяся и разная проверка. Проверка возраста в POST «больше 16», а в PUT «больше 18». Одна функция (validate_student).
Практика
Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.
Упражнение 1: POST — создание
Добавьте в main.py модель StudentIn (name, age, email) и POST /students, который возвращает 201. Дублирующийся email (ValueError из db.create_student) → 409.
Показать решение
main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import db
app = FastAPI(title="Students API")
db.init_db()
class StudentIn(BaseModel):
name: str
age: int
email: str
@app.post("/students", status_code=201)
def create_student(student: StudentIn):
try:
return db.create_student(student.name, student.age, student.email)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))- Успешное создание → 201 со студентом (включая
id). - Тот же email снова → 409 с
"a student with email ... already exists". - Тело без
email→ 422, ещё до выполнения функции.
Упражнение 2: Правила проверки
Напишите validate_student(student), которая выбрасывает HTTPException с 400, если: имя короче 2 символов, возраст вне диапазона от 16 до 120 или в email нет @. Вызывайте её в POST перед сохранением.
Показать решение
main.py (дополнительно)
MIN_NAME_LENGTH = 2
MIN_AGE = 16
MAX_AGE = 120
def validate_student(student):
if len(student.name.strip()) < MIN_NAME_LENGTH:
raise HTTPException(status_code=400, detail="name too short")
if student.age < MIN_AGE or student.age > MAX_AGE:
raise HTTPException(status_code=400, detail="age must be 16-120")
if "@" not in student.email:
raise HTTPException(status_code=400, detail="invalid email")
@app.post("/students", status_code=201)
def create_student(student: StudentIn):
validate_student(student)
try:
return db.create_student(student.name, student.age, student.email)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))Наши проверки выполняются до обращения к базе: недопустимый ввод вообще не доходит до слоя данных.
Упражнение 3: PUT — обновление
Добавьте PUT /students/{student_id}: проверка (400), несуществующий студент (404), email, занятый другим студентом (409), а иначе 200 с обновлённым студентом.
Показать решение
main.py (дополнительно)
@app.put("/students/{student_id}")
def update_student(student_id: int, student: StudentIn):
validate_student(student)
try:
updated = db.update_student(student_id, student.name, student.age, student.email)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))
if updated is None:
raise HTTPException(status_code=404, detail="student not found")
return updatedКаждый результат db.update_student переводится в свой код: словарь → 200, None → 404, ValueError → 409.
Упражнение 4: DELETE — 204
Добавьте DELETE /students/{student_id}, который возвращает 204, когда удаление удалось, и 404, когда такого студента нет. Почему с 204 не возвращают тело?
Показать решение
main.py (дополнительно)
@app.delete("/students/{student_id}", status_code=204)
def delete_student(student_id: int):
if not db.delete_student(student_id):
raise HTTPException(status_code=404, detail="student not found")204 No Content говорит ровно «я справился, и возвращать нечего». Поэтому функция ничего не возвращает. Повторное удаление того же id → 404.
Упражнение 5: Кто решает, какой код?
Для каждого запроса — какой код вернётся и кто его возвращает: Pydantic, наш код (validate_student) или перевод из db?
1. POST /students {"name": "Noa", "age": "abc", "email": "noa@mail.com"}
2. POST /students {"name": "Noa", "age": 10, "email": "noa@mail.com"}
3. POST /students {"name": "Noa", "age": 22, "email": "dana@mail.com"} (taken)
4. PUT /students/99 {"name": "Noa", "age": 22, "email": "noa@mail.com"}
5. DELETE /students/1 (exists)Показать решение
| Запрос | Код | Кто |
|---|---|---|
| 1 | 422 | Pydantic — возраст не число |
| 2 | 400 | validate_student — бизнес-правило |
| 3 | 409 | Перевод ValueError из db — дубликат в базе |
| 4 | 404 | db.update_student вернула None |
| 5 | 204 | Успех без тела |
Итоги
- POST →
db.create_student→ 201 + студент.ValueError→ 409. - PUT (путь + тело) →
db.update_student→ 200.None→ 404.ValueError→ 409. - DELETE →
db.delete_student→ 204 без тела.False→ 404. - Три слоя проверок: 422 (Pydantic, структура), 400 (
validate_student, правила), 409 (база, уникальность). - Проверка — в одной функции для POST и PUT. Ловите только
ValueError, а всё остальное остаётся 500. main.pyпримерно из 60 строк = полный API, потому что каждый слой делает своё дело.