# API студентов — полный CRUD: POST, GET, PUT и DELETE над базой данных<no value>

## Что мы изучим?

Завершаем API по таблице соответствий:

- **`POST /students`** → INSERT → **201**.
- **`PUT /students/{id}`** → UPDATE → 200 / **404** / **409**.
- **`DELETE /students/{id}`** → DELETE → **204** / 404.
- **Модель ввода** (Pydantic) и общую **бизнес-проверку** (400).

## Зачем это нужно?

Это **короткий проект, который подводит итог: сервер + база данных + CRUD**, как определяет программа курса. В конце урока есть настоящий API: приложение, фронтенд или другой сервис могут создавать, читать, обновлять и удалять студентов, а данные сохраняются в базе.

## Объяснение с основ

### Модель ввода

```python
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):

```python
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`

```python
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).

## Синтаксис

```python
@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`:**

```python
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) показывает строку для каждого запроса, например:

```text
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.**

```python
@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` в маршруте.**

```python
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`).

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

### Упражнение 1: POST — создание

Добавьте в `main.py` модель `StudentIn` (`name`, `age`, `email`) и `POST /students`, который возвращает 201. Дублирующийся email (`ValueError` из `db.create_student`) → 409.

{{< solution >}}
**main.py**

```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**, ещё до выполнения функции.
{{< /solution >}}

### Упражнение 2: Правила проверки

Напишите `validate_student(student)`, которая выбрасывает `HTTPException` с 400, если: имя короче 2 символов, возраст вне диапазона от 16 до 120 или в email нет `@`. Вызывайте её в POST перед сохранением.

{{< solution >}}
**main.py** (дополнительно)

```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))
```

Наши проверки выполняются **до** обращения к базе: недопустимый ввод вообще не доходит до слоя данных.
{{< /solution >}}

### Упражнение 3: PUT — обновление

Добавьте `PUT /students/{student_id}`: проверка (400), несуществующий студент (404), email, занятый другим студентом (409), а иначе 200 с обновлённым студентом.

{{< solution >}}
**main.py** (дополнительно)

```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.
{{< /solution >}}

### Упражнение 4: DELETE — 204

Добавьте `DELETE /students/{student_id}`, который возвращает **204**, когда удаление удалось, и 404, когда такого студента нет. Почему с 204 не возвращают тело?

{{< solution >}}
**main.py** (дополнительно)

```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**.
{{< /solution >}}

### Упражнение 5: Кто решает, какой код?

Для каждого запроса — какой код вернётся и **кто** его возвращает: Pydantic, наш код (`validate_student`) или перевод из `db`?

```text
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)
```

{{< solution >}}
| Запрос | Код | Кто |
|---|---|---|
| 1 | **422** | Pydantic — возраст не число |
| 2 | **400** | `validate_student` — бизнес-правило |
| 3 | **409** | Перевод `ValueError` из `db` — дубликат в базе |
| 4 | **404** | `db.update_student` вернула `None` |
| 5 | **204** | Успех без тела |
{{< /solution >}}

<!-- exercises:end -->

## Итоги

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

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

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

- [FastAPI — Request Body](https://fastapi.tiangolo.com/tutorial/body/)
- [FastAPI — Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/)
- [FastAPI — Body - Updates](https://fastapi.tiangolo.com/tutorial/body-updates/)

<!-- docs:end -->
