Как разделить FastAPI на APIRouter и не удвоить /api/v1

После переноса обработчиков в отдельный файл путь неожиданно стал /api/v1/api/v1/items. Префикс версии записан и в APIRouter, и в include_router. FastAPI складывает эти части, поэтому структура файлов сама по себе не делает URL правильными.

Разделите локальный путь и адрес приложения

Роутер items знает свою область /items. Приложение при подключении добавляет общий /api/v1. Такая договорённость позволяет переиспользовать группу и видеть версию на одном уровне. В большом проекте возможна другая структура, но каждому уровню следует назначить понятную часть пути.

Функции обработчиков остаются обычными функциями; APIRouter собирает операции и метаданные до подключения к FastAPI. Не создавайте отдельный FastAPI в каждом модуле только ради разделения файлов: для обычной группы операций достаточно роутера. Отдельные приложения и mount нужны, когда действительно требуются отдельные границы приложения.

Проверим конечные пути, а не исходные строки

Сохраните пример в один файл, затем вынесите router и endpoints в модуль без изменения префиксов. Понадобятся FastAPI и HTTPX. Тест проверяет доступный URL, отсутствие удвоенного варианта и точное имя пути в OpenAPI.

Сохраните весь блок в файл и запустите Python 3.11+ в отдельном окружении с пакетами fastapi, httpx. Для моделей используется Pydantic v2; отдельный сервер для TestClient не нужен.

from fastapi import FastAPI, APIRouter
from fastapi.testclient import TestClient

items = APIRouter(prefix="/items", tags=["items"])
@items.get("/{item_id}", name="read_item")
def read_item(item_id: int):
    return {"id": item_id}

app = FastAPI()
app.include_router(items, prefix="/api/v1")

with TestClient(app) as client:
    assert client.get("/api/v1/items/7").json() == {"id": 7}
    assert client.get("/api/v1/api/v1/items/7").status_code == 404
    assert client.get("/items/7").status_code == 404
    schema = client.get("/openapi.json").json()
    assert set(schema["paths"]) == {"/api/v1/items/{item_id}"}
    assert schema["paths"]["/api/v1/items/{item_id}"]["get"]["tags"] == ["items"]
print("router prefix and API version composed once")

Как организовать файлы без циклических импортов

В модуле маршрутов создайте router и объявите операции. В точке входа импортируйте router и подключите к app. Модуль маршрутов не должен импортировать app обратно, иначе порядок загрузки станет зависеть от цикла. Общие схемы, зависимости и настройки вынесите в модули, которые не создают приложение при импорте.

Если всем операциям группы нужна проверка доступа, добавьте dependencies на уровне роутера или include_router. Но если обработчику нужен сам пользователь как значение, объявите Depends в аргументе либо общей зависимости. Установка зависимости только ради выполнения не передаёт её результат произвольным аргументам функции.

Что оставить после рефакторинга

Сохраните HTTP-тесты старых публичных адресов. Перенос файлов должен менять устройство проекта, а не неожиданно ломать URL. Сравнение OpenAPI до и после помогает заметить удалённый маршрут, задвоенный префикс и изменение имён операций для сгенерированного клиента.

Разделение по роутерам не обязывает делить каждую функцию в отдельный файл. Начните с групп ресурса и общих зависимостей. Когда модули становятся легче читать и тестировать, структура выполняет свою задачу. Если для простого GET нужно пройти цепочку из пяти промежуточных модулей без логики, полезно упростить границы.

Следующая практика

В открытом уроке первого GET вы соберёте минимальное приложение и увидите его Swagger. После этой базы перенесите маршрут в APIRouter и проверьте, что публичный путь и схема сохранились. Откройте бесплатный урок первого FastAPI-приложения.

В бесплатной программе FastAPI приложение постепенно получает модули, зависимости, базу и тесты. Такой путь позволяет усложнять структуру вместе с задачами, сохраняя рабочие HTTP-маршруты. Откройте программу курса.

Продолжить на практике