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: str

StudentIn описывает, что клиент отправляет в 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).
DELETEdb.delete_student возвращает False → 404. Успех → нет return → 204 без тела.
if not db.delete_student(...)Вызов и проверка в одной строке. Удаление происходит в любом случае, а not превращает False в «не удалось».

Итого: файл примерно из 60 строк, в котором полный API. Каждый маршрут короткий, потому что вся тяжёлая работа (SQL, ограничения) — в db.py, а все преобразования (JSON, типы) — в FastAPI.

Ещё один пример

Все настоящие ответы сервера — последовательность запросов на пустой базе (проверено с кодом выше):

#ЗапросТелоКодОтвет
1POST /students{"name": "Dana", "age": 24, "email": "dana@mail.com"}201{"id": 1, "name": "Dana", "age": 24, "email": "dana@mail.com"}
2POST /students{"name": "Avi", "age": 31, "email": "avi@mail.com"}201{"id": 2, ...}
3POST /students{"name": "Dana2", "age": 20, "email": "dana@mail.com"}409{"detail": "a student with email dana@mail.com already exists"}
4POST /students{"name": "Kid", "age": 7, "email": "kid@mail.com"}400{"detail": "age must be between 16 and 120"}
5POST /students{"name": "Noa"}422{"detail": [{"type": "missing", "loc": ["body", "age"], ...}, {... "email" ...}]}
6POST /students{"name": "Noa", "age": "abc", "email": "n@m.com"}422{"detail": [{"type": "int_parsing", "loc": ["body", "age"], ...}]}
7GET /students—200[{"id": 1, ...Dana}, {"id": 2, ...Avi}]
8GET /students/99—404{"detail": "student not found"}
9GET /students/abc—422{"detail": [{"type": "int_parsing", "loc": ["path", "student_id"], ...}]}
10PUT /students/2{"name": "Avi Levi", "age": 32, "email": "avi@mail.com"}200{"id": 2, "name": "Avi Levi", "age": 32, "email": "avi@mail.com"}
11PUT /students/99{"name": "Ghost", "age": 30, "email": "g@mail.com"}404{"detail": "student not found"}
12DELETE /students/1—204(пусто)
13DELETE /students/1—404{"detail": "student not found"}
14GET /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)
Показать решение
ЗапросКодКто
1422Pydantic — возраст не число
2400validate_student — бизнес-правило
3409Перевод ValueError из db — дубликат в базе
4404db.update_student вернула None
5204Успех без тела

Итоги

  • 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, потому что каждый слой делает своё дело.

Официальная документация