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 читает их и использует:
- Преобразование — из URL приходит строка
"7", и FastAPI превращает её в7, потому что мы попросилиint. - Проверка — если пришло
"abc", FastAPI не вызывает функцию, а возвращает 422 с объяснением. - Документация —
/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 /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):
{
"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".
- Что произойдёт при запросе
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 resultGET /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_studentPOST /studentsс{"name": "Noa", "age": 22, "city": "Eilat"}→ 201 сid: 2.- Возраст 12 → 400. Тело без
age→ 422 — Pydantic отклоняет неправильную структуру до того, как функция выполнится.
Упражнение 4: 422 или 400
- В чём разница между 422 и 400 в FastAPI? Кто возвращает каждый из них?
- Для каждого запроса — какой код вернётся?
POST /students {"name": "Noa", "age": "abc", "city": "Eilat"}
POST /students {"name": "Noa", "age": 10, "city": "Eilat"}
POST /students {"name": "Noa", "city": "Eilat"}Показать решение
- 422 — его автоматически возвращают FastAPI и Pydantic, когда структура или тип неправильные. 400 — его возвращает наш код, когда значение допустимо по типу, но не соответствует бизнес-правилу.
- Ответы:
"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 стираются при перезапуске, поэтому нужна база данных.