OpenAPI как описание договора «Окна»
«Окно» уже создаёт бронь, защищает расписание, различает владельцев и сохраняет событие. Теперь рассмотрим приложение глазами другого разработчика, который не читал весь курс. Ему нужен договор: какие данные отправлять, где находится ключ повторения и какие ответы ожидать. FastAPI строит OpenAPI из маршрутов и моделей; наша задача — сделать описание соответствующим реальному поведению, а не превратить документацию в рекламный перечень возможностей.
OpenAPI описывает HTTP-интерфейс, но не доказывает выполнение программы. Наличие схемы ответа 409 не подтверждает, что PostgreSQL уже получил exclusion constraint. Мы отдельно сохраняем историю миграций и статус not_run. Эта граница особенно важна в учебном материале: просмотр документации и реальный запуск конкурирующих операций являются разными видами свидетельства. В этой главе работаем с определениями и ожидаемой схемой, не открывая запущенный сервер.
Имена операций и модели
Созданию задаём operation_id createBooking, чтению readBooking, отмене cancelBooking, списку listBookings. Уникальные стабильные имена полезны для клиентов, генерируемых по схеме. Они не меняют URL и не предоставляют права. Если переименовать Python-функцию, явно заданный operation_id останется прежним. Если изменить внешний договор, имя нужно сохранять или менять осознанно вместе с правилами совместимости, а не случайно при переносе обработчика в другой модуль.
Тег «Бронирование» собирает связанные операции в документации. Это организационная метка, а не новый префикс маршрутов. Каталог ресурсов остаётся отдельным чтением. В описании приложения явно сказано, что кабинеты вымышлены. Version="0.1.0" относится к нашему API, а не к версии установленного FastAPI. Различение этих номеров помогает читателю не искать пакет с совпадающим номером и не путать изменение предметного договора с обновлением инструмента.
BookingIn показывает только resource_id, starts_at и ends_at. BookingOut добавляет серверные поля, а BookingPage описывает items и next_after. Такой набор отражает уже изученную границу входа и выхода. Если actor_id внезапно появится во входной схеме создания, надо исследовать выбранный тип параметра, а не просто скрыть поле визуально. Реальный запрет extra="forbid" по-прежнему работает независимо от того, как интерфейс документации рисует форму.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-15/.
main.py
from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from drain import drain_once
from auth import get_actor
from db import get_session
from errors import register_errors
from models import Booking, Resource
from schemas import BookingIn, BookingOut, BookingPage, ErrorOut, ResourceOut
from service import create_booking
from settings import get_settings
app = FastAPI(
title=get_settings().app_name, version="0.1.0",
description="Учебная система бронирования вымышленных кабинетов",
)
booking_errors = {status: {"model": ErrorOut} for status in (401, 404, 409, 422, 503)}
register_errors(app)
DB = Annotated[Session, Depends(get_session)]
Actor = Annotated[int, Depends(get_actor)]
@app.get("/resources", response_model=list[ResourceOut])
def list_resources(db: DB):
return db.scalars(select(Resource).order_by(Resource.id)).all()
@app.get("/resources/{resource_id}", response_model=ResourceOut)
def read_resource(resource_id: int, db: DB):
item = db.get(Resource, resource_id)
if item is None:
raise HTTPException(404, "Ресурс не найден")
return item
@app.post("/booking-preview", response_model=BookingIn)
def preview_booking(data: BookingIn):
return data
@app.post(
"/bookings", response_model=BookingOut, status_code=201,
operation_id="createBooking", tags=["Бронирование"], responses=booking_errors,
)
def add_booking(
data: BookingIn, db: DB, actor: Actor, background: BackgroundTasks,
request_key: Annotated[str, Header(alias="Idempotency-Key")],
):
result = create_booking(db, data, actor_id=actor, request_key=request_key)
background.add_task(drain_once)
return result
@app.get(
"/bookings/{booking_id}", response_model=BookingOut,
operation_id="readBooking", tags=["Бронирование"], responses=booking_errors,
)
def read_booking(booking_id: int, db: DB, actor: Actor):
row = db.scalar(select(Booking).where(
Booking.id == booking_id, Booking.actor_id == actor
))
if row is None:
raise HTTPException(404, "Бронь не найдена")
return row
@app.post(
"/bookings/{booking_id}/cancel", response_model=BookingOut,
operation_id="cancelBooking", tags=["Бронирование"], responses=booking_errors,
)
def cancel_booking(booking_id: int, db: DB, actor: Actor):
try:
row = db.scalar(select(Booking).where(
Booking.id == booking_id, Booking.actor_id == actor
).with_for_update())
if row is None:
raise HTTPException(404, "Бронь не найдена")
row.state = "cancelled"
db.flush()
result = BookingOut.model_validate(row)
db.commit()
return result
except Exception:
db.rollback()
raise
@app.get(
"/bookings", response_model=BookingPage, operation_id="listBookings",
tags=["Бронирование"], responses=booking_errors,
)
def list_bookings(
db: DB, actor: Actor,
after: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=50)] = 10,
resource_id: Annotated[int | None, Query(gt=0)] = None,
):
query = select(Booking).where(Booking.actor_id == actor, Booking.id > after)
if resource_id is not None:
query = query.where(Booking.resource_id == resource_id)
rows = db.scalars(query.order_by(Booking.id).limit(limit + 1)).all()
items = [BookingOut.model_validate(row) for row in rows[:limit]]
return BookingPage(
items=items, next_after=items[-1].id if len(rows) > limit else None
)
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.
Ожидаемые operationId: createBooking, readBooking, cancelBooking, listBookings
Создание требует Idempotency-Key и учебный X-Demo-Token.
Известные отказы используют ErrorOut.
Ошибка тоже часть контракта
ErrorOut содержит вложенную ErrorDetail с code, необязательным message и необязательным fields. Этот тип охватывает фактические формы обработчиков: предметный отказ имеет сообщение, валидация имеет список путей. Responses описывает известные статусы 401, 404, 409, 422 и 503 для операций бронирования. Он не заменяет сами exception handlers и не заставляет функцию автоматически возвращать эти данные. Описание и реализация должны сопоставляться отдельно.
Ранее FastAPI мог бы показать стандартную форму 422, хотя мы уже переопределили её на error. Явное указание модели делает публичное описание ближе к коду. Это типичный пример документирования результата кастомизации: изменив обработчик, следует обновить объявление ответа. Не достаточно оставить только красивый пример JSON в комментарии. Другой клиент может использовать машинную схему и ожидать прежние поля loc и msg, которых наш договор напрямую не возвращает.
Модель ErrorDetail допускает None для двух необязательных свойств, но обработчики обычно не добавляют отсутствующее поле. Допустимость в схеме шире конкретного экземпляра. Клиент должен проверять code и присутствующие сведения, а не рассчитывать, что каждый отказ содержит одновременно message и fields. Если продукту понадобится более строгий discriminated union разных ошибок, это отдельное уточнение договора. Здесь не вводим такую структуру только ради усложнения примера.
Заголовки и учебный вход
Idempotency-Key объявлен параметром Header с alias, поэтому схема должна описывать его как обязательный заголовок создания. Название request_key в Python не является внешним написанием поля. X-Demo-Token описывается через APIKeyHeader, что позволяет документации показать учебную схему входа. Такое отображение не повышает надёжность провайдера и не делает его JWT. В тексте и курсе по-прежнему сказано, что это отдельный выключенный локальный механизм двух вымышленных участников.
Не записывайте рабочие значения токенов в examples схемы или description. Примеры нужны для формы ресурса и интервала, а секреты относятся к окружению конкретного читателя. В нашей версии их нет ни в ZIP, ни в статье. Предварительный маршрут booking-preview остаётся проверкой модели, а не подтверждением свободного времени. Чтобы читатель не принял его за бронь, название и фактическая логика должны сохранять этот смысл при дальнейшем документировании.
Ожидаемая схема содержит четыре именованные операции бронирования, модели входа и выхода, параметр ключа, учебную схему входа и описанные ошибки. Полный main.py на этой странице показывает декларации рядом с фактическими обработчиками. Это полезнее отдельной вручную составленной схемы, которая может разойтись с кодом. Однако мы не утверждаем, что openapi.json реально получен от работающего приложения: все наблюдения здесь относятся к ожидаемому результату определений.
Для самостоятельного разбора составьте запрос создания только по объявленным данным и объясните, почему тело без actor_id является полным. Затем найдите в исходниках, кто добавляет владельца, и кто обеспечивает запрет пересечения. Схема помогает потребителю разговаривать с API, а ограничения базы обеспечивают допустимое состояние. Последняя глава соединит эти части в передачу учебного проекта с readiness, закрытием пула и честно указанной непроверенной совместимостью зависимостей.
Описание не должно назначать лишние возможности
У схемы полезно читать не только существующие операции, но и отсутствующие. В «Окне» нет изменения времени уже созданной брони, удаления пользователей и публичной выдачи всего расписания. Если описание обещает эти действия без маршрута и правил доступа, потребитель получает ложный договор. Поэтому документацию строим вокруг реально введённых результатов, а дальнейшие идеи обозначаем только как возможные новые задачи.
Пример ответа также должен учитывать происхождение значения. Номер брони зависит от базы, поэтому условная карточка не доказывает, что первый запуск всегда выдаёт id=1. Перечень ошибок показывает допустимые ветки, но конкретная операция может выбрать одну из них по состоянию данных. При чтении схемы полезно сопоставить три уровня: тип поля, объявленный статус и предметную причину. Они дают разработчику разные сведения и не заменяют друг друга. Так OpenAPI становится проверяемым описанием интерфейса, которое можно сравнить с исходниками и историей схемы, а не отдельной страницей обещаний.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.