FastAPI: параметры пути, строка запроса, тело и HTTPException

Цели урока
  • Понимать подсказки типов (Type Hints) и то, как FastAPI использует их для преобразования и проверки
  • Определять параметр пути и параметры запроса (обязательные, со значением по умолчанию и необязательные)
  • Определять тело с помощью модели Pydantic (BaseModel) и принимать его в POST
  • Возвращать правильные коды — 201 при создании, 404 с HTTPException — и понимать автоматическую 422

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

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

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

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

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

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

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

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

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

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 показывает тип каждого параметра.

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

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

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

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

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

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 (наследование, тема ООП!):

from pydantic import BaseModel

class StudentIn(BaseModel):
    name: str
    age: int
    city: str = "Unknown"
  • Каждая строка = поле с типом. Поле с = необязательное, со значением по умолчанию.
  • Нет __init__! BaseModel сам строит его из полей.
  • Когда параметр функции имеет этот тип, FastAPI понимает, что это тело: он читает JSON, проверяет, что все поля есть и правильного типа, и строит объект. К полям обращаются через точку: student.name.
Аналогия: анкета с обязательными полями

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

status_code и HTTPException

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

Синтаксис

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:

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 /students200Все трое
GET /students?city=Haifa200Дана и Ноа
GET /students?min_age=23&limit=1200[{"id": 1, "name": "Dana", ...}]
GET /students/2200{"id": 2, "name": "Avi", "age": 31, "city": "Tel Aviv"}
GET /students/42404{"detail": "student 42 not found"}
GET /students/abc422См. ниже
GET /students?min_age=old422См. ниже
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):

{
  "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: имя в пути не совпадает с параметром.

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

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

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

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

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

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

@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.

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

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

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

Практика

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

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

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

  1. Что произойдёт при запросе GET /students/abc?
Показать решение
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 самому проверять и преобразовывать. Функция вообще не выполняется.

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

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

Показать решение
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 → только Дана.

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

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

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

Показать решение
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 отклоняет неправильную структуру до того, как функция выполнится.

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

  1. В чём разница между 422 и 400 в FastAPI? Кто возвращает каждый из них?
  2. Для каждого запроса — какой код вернётся?
POST /students  {"name": "Noa", "age": "abc", "city": "Eilat"}
POST /students  {"name": "Noa", "age": 10, "city": "Eilat"}
POST /students  {"name": "Noa", "city": "Eilat"}
Показать решение
  1. 422 — его автоматически возвращают FastAPI и Pydantic, когда структура или тип неправильные. 400 — его возвращает наш код, когда значение допустимо по типу, но не соответствует бизнес-правилу.
  2. Ответы:
    • "age": "abc" → 422 (возраст — не число).
    • "age": 10 → 400 (допустимое число, но ниже минимального возраста — наше правило).
    • Без age → 422 (отсутствует обязательное поле).

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

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

Показать решение
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, потому что это не число.

Итоги

  • Подсказки типов (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 стираются при перезапуске, поэтому нужна база данных.

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