FastAPI выводит почти всё из сигнатуры функции-обработчика. Объявил параметр с типом — получил его разбор, валидацию и документацию бесплатно. Это сильно сокращает шаблонный код, но требует понимать, как именно фреймворк решает, откуда взять каждый аргумент.

Path operations

Эндпоинт — это функция, помеченная декоратором метода на роутере: @router.get, @router.post и так далее. В FastAPI их называют path operations.

from fastapi import APIRouter

router = APIRouter(prefix="/products", tags=["products"])


@router.get("/{product_id}")
async def get_product(product_id: int):
    ...

prefix задаёт общий путь для всех роутов группы, tags группирует их в автодокументации. Это не косметика: на сервисе с десятком доменов префиксы и теги — то, что держит API читаемым.

Откуда берутся параметры

FastAPI определяет источник каждого аргумента по его типу и месту:

  • если имя аргумента совпадает с частью пути ({product_id}) — это параметр пути;
  • если аргумент — простой тип (int, str, bool) и его нет в пути — это параметр запроса (query);
  • если аргумент — модель Pydantic — это тело запроса.
from typing import Annotated

from fastapi import Query


@router.get("/")
async def list_products(
    category: str,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
    ...

Annotated[int, Query(ge=1, le=100)] добавляет ограничения прямо в объявление — FastAPI проверит их до входа в функцию и вернёт понятную ошибку, если значение вне диапазона. То же делает Path(...) для параметров пути.

Тело запроса описывается моделью Pydantic — FastAPI разберёт JSON, провалидирует и передаст готовый объект:

@router.post("/", status_code=201)
async def create_product(body: CreateProductRequest):
    ...

Подробно про модели — в статье про Pydantic.

response_model и статус-коды

То, что эндпоинт возвращает наружу, стоит описывать явно через response_model (или возвращаемую аннотацию). FastAPI отфильтрует ответ по модели — лишние поля не утекут, — и опишет схему в документации.

@router.get("/{product_id}", response_model=ProductResponse)
async def get_product(product_id: int):
    return await handler.handle(product_id)

Возврат разной модели на вход и на выход — не формальность, а защита: внутреннюю модель с техническими полями нельзя случайно отдать клиенту. status_code задаёт код успеха (201 на создание, 204 на удаление без тела); ошибки — через HTTPException или обработчики исключений.

Тонкий контроллер UCP

Соблазн FastAPI — написать всю логику прямо в функции-эндпоинте: фреймворк это позволяет, и для одной операции выглядит компактно. На сервисе это быстро превращается в роуты по двести строк с базой, правилами и ветвлением вперемешку.

В UCP роутер — это контроллер, и он тонкий: разобрать вход, вызвать Handler, отдать ответ. Никакой бизнес-логики.

@router.post("/", status_code=201, response_model=ProductResponse)
async def create_product(
    body: CreateProductRequest,
    handler: CreateProductHandlerDep,
) -> ProductResponse:
    product = await handler.handle(body.to_command())
    return ProductResponse.from_domain(product)

Эндпоинт здесь не знает ни про базу, ни про правила — только про HTTP. Сценарий — в Handler-е, собранном через зависимость. Это та же граница «контроллер тонкий», что и в Spring MVC, и именно она позволяет продукт-инженеру держать сервис понятным, когда роутов становится много.