Справочник «Стандартизация HTTP API» · модуль 3 · OpenAPI
Глава 3.3
FastAPI
и OpenAPI
Описание учебного сервиса никто не писал руками: его собирает FastAPI из кода. В этой главе проследим путь от строки в Python до поля в документе и разберём, что делать, когда сгенерированное описание не совпадает с тем, что сервис отвечает на самом деле.
- Категория
- Описание контракта
- Уровень
- Средний
- Связанные главы
- 3.2 Разделы документа · 2.4 Схемы · 4.1 Problem Details
- Зачем это знать
- Чтобы менять контракт осознанно: правкой кода, а не правкой файла описания
§1Откуда берётся описание
FastAPI читает три источника: параметры конструктора приложения, аргументы декораторов маршрутов и аннотации типов вместе с моделями Pydantic. Из них собирается документ OpenAPI, который отдаётся по адресу /openapi.json.
откуда что берётся
info: название, версия, описание, контакты, лицензия; список servers; описания теговpaths: summary, description, operationId, ответыin: path, in: query, in: header, их типы и ограниченияcomponents.schemas: поля, типы, обязательность, примерыsecurity у операции, если зависимость использует схему безопасностиОписание целиком получается из кода. Отсюда практическое следствие: чтобы изменить контракт, меняют код, а не файл в папке openapi/.
§2Метаданные приложения → info
Раздел info задаётся параметрами конструктора. В учебном проекте это файл app/main.py.
app = FastAPI(
title="Service Desk API",
version="1.1.0",
summary="Учебный стандартизированный REST API",
description=API_DESCRIPTION,
contact={"name": "API Team", "email": "api-team@example.edu", …},
license_info={"name": "MIT", "identifier": "MIT"},
openapi_tags=[{"name": "Tickets", "description": "Обращения в службу поддержки…"}],
servers=[{"url": "http://127.0.0.1:8000", "description": "Local development"}],
)Всё это видно в шапке Swagger UI: название, версия, описание с разметкой Markdown, ссылка на контакт и лицензию, выпадающий список серверов.
version — это версия контракта, а не версия FastAPI и не редакция OpenAPI.§3Роутер и декоратор → операция
Адрес операции складывается из префикса роутера и пути в декораторе. Остальные поля операции — аргументы декоратора.
router = APIRouter(prefix="/tickets", tags=["Tickets"]) @router.get( "", response_model=TicketPage, summary="Получить список обращений", description="Возвращает страницу обращений. Размер страницы ограничен параметром limit.", operation_id="listTickets", ) def list_tickets( status_filter: TicketStatus | None = Query(None, alias="status", description="Показать только обращения с этим статусом"), limit: int = Query(20, ge=1, le=100, description="Сколько обращений вернуть, от 1 до 100"), ) -> TicketPage: …
/api/v1, у роутера свой /tickets, у операции — пустой путь. Итог: /api/v1/tickets.адрес
summary стоит рядом с адресом, description — под ним, ограничения ge и le превратились в подписи minimum и maximum.§4Ответы: response_model и responses
Успешный ответ FastAPI описывает сам — по значению response_model. Об остальных ответах он не догадывается: то, что операция может вернуть 404 или 412, известно только из кода обработчика, а туда FastAPI не заглядывает. Поэтому ошибки перечисляют явно.
@router.patch(
"/{ticket_id}",
response_model=Ticket,
status_code=200,
responses={
401: {"description": "Токен не передан"},
403: {"description": "Недостаточно прав"},
404: {"description": "Обращение не найдено"},
412: {"description": "Версия ресурса изменилась: If-Match не совпал"},
},
…
)200; для создания задают 201, для удаления — 204.успехПройдите по коду обработчика и выпишите каждый raise. Если код ошибки встречается в коде, но его нет в responses, — контракт неполный: клиент узнает об этом ответе в рабочем контуре, а не при чтении документации.
§5Схемы Pydantic → components
Каждая модель Pydantic, которая встречается в аннотациях, попадает в components.schemas. Имя схемы — имя класса, поля — поля модели, ограничения — параметры Field.
class TicketCreate(BaseModel): model_config = ConfigDict( extra="forbid", json_schema_extra={"examples": [{"title": "Не работает VPN", …}]}, ) title: str = Field(min_length=3, max_length=120, description="Короткая суть обращения") description: str = Field(default="", max_length=2000, description="Что делали и что увидели")
TicketCreate:
type: object
required: [title]
additionalProperties: false
properties:
title: { type: string, minLength: 3, maxLength: 120, description: … }
description: { type: string, maxLength: 2000, default: '', description: … }
examples:
- { title: "Не работает VPN", description: "Ошибка подключения после обновления клиента" }Обратите внимание на additionalProperties: false — так в описании выглядит настройка extra="forbid". Так в контракте выражен прямой запрет присылать поля, которых нет в схеме, — одна строка в коде превращается в проверяемое требование к запросу.
§6Разные модели для входа и выхода
Ресурс можно описать одной моделью и использовать её и в запросе, и в ответе. Делать так не следует: у этих моделей разный состав полей, потому что часть значений заполняет сервер.
title— обязательноdescription— необязательно
id— выдал серверstatus— начальное значениеnewcreated_at,updated_at— время сервераtitle,description— от клиента
Если объединить их в одну модель, клиент увидит в теле запроса поля id и created_at и решит, что их нужно присылать. Сервер эти значения проигнорирует, и клиент получит поведение, не совпадающее с описанием, — только из-за того, как составлена схема.
Отдельная модель нужна и для изменения. У TicketUpdate все поля необязательны: PATCH меняет только то, что прислали. Та же разница между PUT и PATCH, о которой шла речь в главе 2.2, выражена здесь схемами.
class TicketCreate(BaseModel): # вход: создание class TicketUpdate(BaseModel): # вход: изменение, все поля Optional class Ticket(BaseModel): # выход: полное состояние ресурса class TicketPage(BaseModel): # выход: страница списка
Четыре класса вместо одного кажутся избыточными, пока не появляется вопрос «какие поля присылать при создании» — на него отвечает именно разделение схем.
§7Зависимости → security
Требование токена в FastAPI выражается зависимостью. Если зависимость использует схему безопасности, операция получает поле security автоматически.
bearer_scheme = HTTPBearer(scheme_name="BearerAuth", auto_error=False) def require_writer(credentials = Depends(bearer_scheme)) -> str: … @router.post("", …) def create_ticket(data: TicketCreate, token: str = Depends(write_rate_limit)): …
security:
- BearerAuth: []Цепочка здесь длиннее, чем кажется: операция зависит от write_rate_limit, та — от require_writer, а та — от схемы bearer_scheme. FastAPI проходит цепочку целиком, поэтому security появляется даже при косвенной зависимости.
§8Когда описание нужно доработать
Автоматическая сборка описывает не всё. FastAPI не знает, что обработчики ошибок в проекте возвращают Problem Details, и по умолчанию описывает ответ 422 своей моделью с media type application/json. Получилось бы расхождение: контракт обещает одно, сервис отвечает другим.
Поэтому в учебном проекте описание дорабатывается после сборки — один раз, в одном месте:
def openapi_with_problem_details() -> dict: if app.openapi_schema: return app.openapi_schema schema = _generate_openapi() schemas = schema["components"]["schemas"] schemas.pop("HTTPValidationError", None) schemas["ProblemDetails"] = ProblemDetails.model_json_schema() for path_item in schema["paths"].values(): for operation in path_item.values(): for code, response in operation["responses"].items(): response.setdefault("headers", {})["X-Request-Id"] = {…} if code.startswith(("4", "5")): response["content"] = {"application/problem+json": {…}} return schema
Функция делает две вещи: переводит все ответы 4xx и 5xx на единый формат ошибок и добавляет к каждому ответу заголовок X-Request-Id, который в действительности добавляет middleware. После этого описание совпадает с поведением.
Выполните операцию и сверьте ответ сервиса с её описанием. Если разошлись media type, заголовки или набор кодов, описание нужно поправить. Проверять это можно тестом, и в проекте такой тест есть: он требует, чтобы у каждой операции все ответы 4xx и 5xx были описаны как application/problem+json.
§9Проверка описания тестом
Описание — обычный словарь Python, который возвращает метод app.openapi(). Значит, его можно проверять так же, как любые данные.
def test_errors_use_problem_details(): for path, method, operation in operations(app.openapi()): for code, response in operation["responses"].items(): if code.startswith(("4", "5")): assert list(response["content"]) == ["application/problem+json"], f"{method.upper()} {path} {code}" def test_committed_openapi_matches_code(): committed = json.loads((ROOT / "openapi" / "openapi.json").read_text(encoding="utf-8")) assert committed == app.openapi(), "run: python scripts/export_openapi.py"
Второй тест — тот самый механизм против расхождения из главы 3.1, но со стороны тестов: он не даст объединить изменение, в котором код и выгруженный файл разошлись.
§10Частые ошибки
§11Проверьте себя
- Из каких трёх источников FastAPI собирает описание? Приведите пример поля для каждого.
- Как из
prefixроутера и пути в декораторе получается адрес/api/v1/tickets? - Что означает
additionalProperties: falseв схеме и какая строка кода его породила? - Почему у операции
createTicketпоявилось полеsecurity, хотя схема безопасности не указана в декораторе? - Зачем описание дорабатывается после автоматической сборки и что случилось бы без этого?
- Почему коды ошибок приходится перечислять вручную, а код успешного ответа — нет?
- Назовите две причины разделить модель запроса и модель ответа.
FastAPI(...) → infoAPIRouter → tags, префикс@router.get(...) → operationPydantic → schemasDepends → securityoperation_idsummary, descriptionresponse_modelresponses для ошибокstatus_code для 201 и 204XCreate — входXUpdate — вход, всё OptionalX — выходXPage — страница спискаконтракт меняют кодомфайл в openapi/ — снимокpytest tests/test_contract.pyexport_openapi.py --checkСправочник «Стандартизация HTTP API» · модуль 3 · глава 3.3Макаров Максим Николаевич