# FastAPI: параметры пути, строка запроса, тело и HTTPException<no value>

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

В предыдущем уроке функции вообще не получали ввода. Теперь они будут получать три вида, которые мы изучили:

| Ввод | Как FastAPI узнаёт |
|---|---|
| **Параметр пути** | Имя в фигурных скобках в пути: `"/students/{student_id}"` |
| **Параметр запроса** | Параметр функции, которого нет в пути |
| **Тело** | Параметр, тип которого — **модель Pydantic** |

И дополнительно: **подсказки типов** (Type Hints), **`status_code`**, **`HTTPException`** и автоматическая ошибка **422**.

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

Без ввода сервер может только всегда возвращать одно и то же. С вводом он может вернуть **конкретного студента**, **отфильтровать** и **создать** новых студентов. И огромный бонус: FastAPI **проверяет и переводит ввод за нас**. Сервер-калькулятор, написанный вручную, падал на `"hello"`. Здесь неверный ввод автоматически получит **422** с точным объяснением, а сервер продолжит работать.

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

### Подсказки типов

**Подсказка типа** (Type Hint) = пометка в коде, которая говорит, какой **ожидаемый тип** у параметра:

```python
def get_student(student_id: int):
```

`student_id: int` = «этот параметр должен быть `int`». В обычном Python подсказки типов **не обязательны к исполнению**: это лишь документация, которая помогает VS Code и разработчикам. **Но FastAPI читает их и использует**:

1. **Преобразование** — из URL приходит строка `"7"`, и FastAPI превращает её в `7`, потому что мы попросили `int`.
2. **Проверка** — если пришло `"abc"`, FastAPI **не** вызывает функцию, а возвращает **422** с объяснением.
3. **Документация** — `/docs` показывает тип каждого параметра.

### Параметр пути

```python
@app.get("/students/{student_id}")
def get_student(student_id: int):
```

`{student_id}` в пути = «эта часть меняется». FastAPI передаёт её в параметр с **точно таким же именем**.

### Параметры запроса

**Каждый параметр функции, которого нет в пути (и который не модель), = параметр запроса.**

```python
def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):
```

| Определение | Значение |
|---|---|
| `min_age: int = 0` | **Необязательный**, по умолчанию 0. |
| `limit: int` (без `=`) | **Обязательный**. Без него → 422. |
| `city: str \| None = None` | **Необязательный, без значения по умолчанию**: «либо строка, либо `None`». `None` = «не отправлено, поэтому не фильтруем». |

`str | None` читается «str **или** None». Это подсказка типа, которая говорит, что допустимы два типа.

### Тело с Pydantic

**Pydantic** — библиотека (устанавливается вместе с FastAPI), которая определяет **структуру данных** с типами. Вы создаёте **класс**, который наследует `BaseModel` (наследование, тема ООП!):

```python
from pydantic import BaseModel

class StudentIn(BaseModel):
    name: str
    age: int
    city: str = "Unknown"
```

- Каждая строка = **поле** с типом. Поле с `=` необязательное, со значением по умолчанию.
- **Нет `__init__`!** `BaseModel` сам строит его из полей.
- Когда параметр функции имеет этот тип, FastAPI понимает, что это **тело**: он читает JSON, проверяет, что все поля есть и правильного типа, и строит объект. К полям обращаются через точку: `student.name`.

{{< box "analogy" "Аналогия: анкета с обязательными полями" >}}
Модель Pydantic — это **печатная анкета**: «Имя (обязательно, текст). Возраст (обязательно, число). Город (необязательно)». Сотрудник (FastAPI) не передаёт в обработку (в вашу функцию) анкету, в которой нет обязательного поля или в поле возраста написано «двадцать». Он сразу возвращает её подавшему (**422**) с отмеченным проблемным полем. Ваша функция получает **только правильные анкеты**.
{{< /box >}}

### `status_code` и `HTTPException`

- **`@app.post("/students", status_code=201)`** — код успеха этого Endpoint-а — **201 Created**, а не 200.
- **`raise HTTPException(status_code=404, detail="...")`** — останавливает функцию и возвращает клиенту код ошибки с JSON `{"detail": "..."}`. Это обычный `raise` (вспомните тему исключений!), а FastAPI ловит его и превращает в HTTP-ответ.

## Синтаксис

```python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class ItemIn(BaseModel):              # body model
    name: str
    price: float
    in_stock: bool = True

@app.get("/items/{item_id}")          # path parameter
def get_item(item_id: int):
    ...
    raise HTTPException(status_code=404, detail="item not found")

@app.get("/items")                    # query parameters
def list_items(max_price: float | None = None, limit: int = 10):
    ...

@app.post("/items", status_code=201)  # body
def create_item(item: ItemIn):
    item.name, item.price             # use the validated fields
```

## Первый пример кода

**API студентов в памяти** (в RAM. Базы данных ещё нет, она появится в теме баз данных). `week-4/hello-api/students_memory.py`:

```python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Students (in memory)")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
    {"id": 3, "name": "Noa", "age": 22, "city": "Haifa"},
]

class StudentIn(BaseModel):
    name: str
    age: int
    city: str = "Unknown"

@app.get("/students")
def list_students(city: str | None = None, min_age: int = 0, limit: int = 10):
    result = []
    for student in students:
        if city is not None and student["city"] != city:
            continue
        if student["age"] < min_age:
            continue
        result.append(student)
    return result[:limit]

@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student " + str(student_id) + " not found")

@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    new_id = max(s["id"] for s in students) + 1
    new_student = {"id": new_id, "name": student.name, "age": student.age, "city": student.city}
    students.append(new_student)
    return new_student
```

**Запуск:** `uvicorn students_memory:app --reload` (обратите внимание: имя файла изменилось!).

## Разбор кода построчно

| Код | Объяснение |
|---|---|
| `students = [...]` | Временная «база данных» в RAM: список словарей. **Она стирается при каждом перезапуске** (вспомните RAM из темы о компьютере!). В теме баз данных перейдём к настоящей базе данных. |
| `class StudentIn(BaseModel):` | Модель тела: то, что клиент **отправляет** при создании. В ней нет `id`, потому что id задаёт сервер. |
| `city: str = "Unknown"` | Необязательное поле в теле. |
| `list_students(city: str \| None = None, min_age: int = 0, limit: int = 10)` | Три **параметра запроса**, все необязательные. `/students?city=Haifa&min_age=23`. |
| `if city is not None and ...: continue` | Фильтрация, только если `city` отправлен. `continue` переходит к следующему студенту. |
| `return result[:limit]` | Срез: не более `limit` результатов. |
| `@app.get("/students/{student_id}")` + `student_id: int` | **Параметр пути** с преобразованием в `int`. |
| `raise HTTPException(status_code=404, ...)` | Не найдено, поэтому 404 с сообщением. |
| `@app.post(..., status_code=201)` | Создание возвращает **201**. |
| `create_student(student: StudentIn)` | Тип — модель, значит, это **тело**. FastAPI разбирает и проверяет его. |
| `student.name`, `student.age` | Доступ к полям объекта (тема ООП). |
| `max(s["id"] for s in students) + 1` | Новый идентификатор: наибольший + 1. |

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

**Полная проверка всех видов ввода и настоящие ответы.** (Проверять можно и в `/docs`, и через curl.)

| Запрос | Код | Ответ |
|---|---|---|
| `GET /students` | 200 | Все трое |
| `GET /students?city=Haifa` | 200 | Дана и Ноа |
| `GET /students?min_age=23&limit=1` | 200 | `[{"id": 1, "name": "Dana", ...}]` |
| `GET /students/2` | 200 | `{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"}` |
| `GET /students/42` | **404** | `{"detail": "student 42 not found"}` |
| `GET /students/abc` | **422** | См. ниже |
| `GET /students?min_age=old` | **422** | См. ниже |
| `POST /students` + `{"name": "Tom", "age": 27}` | **201** | `{"id": 4, "name": "Tom", "age": 27, "city": "Unknown"}` |
| `POST /students` + `{"name": "Rina", "age": "twenty"}` | **422** | См. ниже |

**Ошибка 422 — как она выглядит** (`GET /students/abc`):

```json
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "student_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}
```

**Как читать 422:**

- `loc` (Location) — **где** проблема: `["path", "student_id"]`. В строке запроса было бы `["query", "min_age"]`, а в теле — `["body", "age"]`.
- `msg` — **в чём** проблема.
- `input` — **что** на самом деле пришло.
- **Наша функция вообще не выполнялась.** FastAPI остановил запрос до неё. Не нужен `try`/`except` вокруг `int()`.

**А после `POST` Тома:** `GET /students?city=Unknown` возвращает Тома, потому что город получил значение по умолчанию. Но **остановите сервер и запустите снова**: Том **исчез**. Данные были в RAM. Именно поэтому нужна база данных (она — следующая!).

## Типичная ошибка

**Ошибка 1: имя в пути не совпадает с параметром.**

```python
@app.get("/students/{student_id}")
def get_student(id: int):
```

FastAPI не находит параметр с именем `student_id` и считает `id` **обязательным параметром запроса**. Каждый запрос к `/students/7` вернёт 422 (`"loc": ["query", "id"]`, отсутствует). **Имя в фигурных скобках должно быть идентично имени параметра.**

**Ошибка 2: параметр пути без подсказки типа.**

```python
def get_student(student_id):
    for student in students:
        if student["id"] == student_id:
```

Без `: int` параметр приходит как **строка** `"2"`, а `2 == "2"` — `False`. Результат: всегда 404, без всякой ошибки. **Тихая Logical Error.**

**Ошибка 3: порядок маршрутов с фиксированным путём и путём с параметром.**

```python
@app.get("/students/{student_id}")
def get_student(student_id: int): ...

@app.get("/students/top")
def top_students(): ...
```

`GET /students/top` → **422**! FastAPI проверяет маршруты **по порядку**, и `/students/{student_id}` «ловит» `top` как student_id и не может преобразовать его в int. **Фиксированные пути — до путей с параметрами.**

**Ошибка 4: `return` вместо `raise` для HTTPException.**

```python
return HTTPException(status_code=404, detail="not found")
```

Возвращает **200** со странным объектом внутри JSON. Ошибку **выбрасывают** (`raise`), а не возвращают.

**Ошибка 5: включить `id` в модель тела.** Если `StudentIn` включает `id: int`, клиент **обязан** отправить id и может отправить уже существующий. Идентификатор задаёт **сервер**. Модель тела содержит только то, что клиент должен отправить.

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

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

### Упражнение 1: Параметр пути с подсказкой типа

Напишите API со списком студентов в памяти (словари с `id`, `name`, `age`, `city`) и маршрутом `GET /students/{student_id}`, который возвращает студента или 404 с `detail` `"student not found"`.

1. Что произойдёт при запросе `GET /students/abc`?

{{< solution >}}
```python
from fastapi import FastAPI, HTTPException

app = FastAPI(title="Students API")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]


@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student not found")
```

- `GET /students/2` → 200 с Ави. `GET /students/9` → **404** `{"detail": "student not found"}`.
- `GET /students/abc` → **422**: подсказка типа `student_id: int` заставляет FastAPI самому проверять и преобразовывать. Функция вообще не выполняется.
{{< /solution >}}

### Упражнение 2: Параметры запроса

Добавьте маршрут `GET /students`, который получает необязательный параметр запроса `city` (по умолчанию `None`) и возвращает только студентов из этого города — или всех, если город не отправлен.

{{< solution >}}
```python
from fastapi import FastAPI

app = FastAPI(title="Students API")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]


@app.get("/students")
def list_students(city: str = None):
    if city is None:
        return students
    result = []
    for student in students:
        if student["city"] == city:
            result.append(student)
    return result
```

- `GET /students` → оба студента.
- `GET /students?city=Haifa` → только Дана.

Параметр функции, который **не** является частью пути, автоматически становится **параметром запроса**. Значение по умолчанию делает его необязательным.
{{< /solution >}}

### Упражнение 3: POST с телом

Добавьте модель Pydantic с именем `StudentIn` (`name`, `age`, `city`) и маршрут `POST /students`, который возвращает 201, даёт студенту новый `id` и добавляет его в список. Возраст ниже 16 → 400.

{{< solution >}}
```python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Students API")

MIN_AGE = 16

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
]


class StudentIn(BaseModel):
    name: str
    age: int
    city: str


@app.post("/students", status_code=201)
def create_student(student: StudentIn):
    if student.age < MIN_AGE:
        raise HTTPException(status_code=400, detail="age must be at least 16")
    new_student = {"id": len(students) + 1, "name": student.name,
                   "age": student.age, "city": student.city}
    students.append(new_student)
    return new_student
```

- `POST /students` с `{"name": "Noa", "age": 22, "city": "Eilat"}` → **201** с `id: 2`.
- Возраст 12 → **400**. Тело без `age` → **422** — Pydantic отклоняет неправильную структуру до того, как функция выполнится.
{{< /solution >}}

### Упражнение 4: 422 или 400

1. В чём разница между 422 и 400 в FastAPI? Кто возвращает каждый из них?
2. Для каждого запроса — какой код вернётся?

```text
POST /students  {"name": "Noa", "age": "abc", "city": "Eilat"}
POST /students  {"name": "Noa", "age": 10, "city": "Eilat"}
POST /students  {"name": "Noa", "city": "Eilat"}
```

{{< solution >}}
1. **422** — его автоматически возвращают **FastAPI и Pydantic**, когда структура или тип неправильные. **400** — его возвращает **наш код**, когда значение допустимо по типу, но не соответствует бизнес-правилу.
2. Ответы:
   - `"age": "abc"` → **422** (возраст — не число).
   - `"age": 10` → **400** (допустимое число, но ниже минимального возраста — наше правило).
   - Без `age` → **422** (отсутствует обязательное поле).
{{< /solution >}}

### Упражнение 5: Статистика

Добавьте маршрут `GET /students/stats`, который возвращает количество студентов и средний возраст (округлённый до одного знака). Где его нужно определить относительно `GET /students/{student_id}` и почему?

{{< solution >}}
```python
from fastapi import FastAPI, HTTPException

app = FastAPI(title="Students API")

students = [
    {"id": 1, "name": "Dana", "age": 24, "city": "Haifa"},
    {"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"},
]


@app.get("/students/stats")
def students_stats():
    total_age = 0
    for student in students:
        total_age = total_age + student["age"]
    return {"count": len(students), "average_age": round(total_age / len(students), 1)}


@app.get("/students/{student_id}")
def get_student(student_id: int):
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail="student not found")
```

`GET /students/stats` → `{"count": 2, "average_age": 27.5}`.

Определите `/students/stats` **до** `/students/{student_id}`. FastAPI проверяет маршруты по порядку — если бы `{student_id}` был первым, `stats` считался бы id и упал бы с 422, потому что это не число.
{{< /solution >}}

<!-- exercises:end -->

## Итоги

- **Подсказки типов** (`x: int`) — FastAPI преобразует и проверяет по ним. Недопустимый ввод автоматически получает **422**, и функция не выполняется.
- **Параметр пути**: `"/students/{student_id}"` + `student_id: int`, с точно таким же именем.
- **Строка запроса**: каждый параметр, которого нет в пути. `x: int = 0` — необязательный. `x: str | None = None` — необязательный без значения по умолчанию. Без `=` — обязательный.
- **Тело**: класс, наследующий `BaseModel` (Pydantic). Параметр этого типа = JSON из запроса после проверки.
- `status_code=201` — для создания. **`raise HTTPException(404, ...)`** — для «не найдено», а 400 — для бизнес-правила.
- **Фиксированные пути — до путей с параметром.** А данные в RAM стираются при перезапуске, поэтому нужна база данных.

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

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

- [FastAPI — Path Parameters](https://fastapi.tiangolo.com/tutorial/path-params/)
- [FastAPI — Query Parameters](https://fastapi.tiangolo.com/tutorial/query-params/)
- [FastAPI — Request Body](https://fastapi.tiangolo.com/tutorial/body/)
- [FastAPI — Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/)

<!-- docs:end -->
