Schemas: ядро OpenAPI 3.1
Если paths — это карта endpoints, то schemas — это словарь форм данных, которые гуляют между клиентом и сервером. Schema описывает: какие поля есть, какого типа, что обязательно, какие ограничения. От качества schemas зависит, насколько удобно работать с API: хорошие schemas дают typed клиент с автодополнением и валидацию на этапе разработки; плохие — превращают каждый запрос в исследование «а какой формат он реально вернёт».
Главное событие OpenAPI 3.1 — полная совместимость с JSON Schema Draft 2020-12. До 3.1 OpenAPI Schema был «JSON Schema-подобным», с расхождениями. С 3.1 это буквально валидный JSON Schema (с микро-надстройками). Это значит: всё, что вы знаете про JSON Schema, работает в OpenAPI без поправок.
Базовые типы JSON Schema
JSON Schema (и, следовательно, OpenAPI 3.1) описывает значения через примитивные и составные типы. Их семь.
Минимальная схема для каждого типа:
StringExample:
type: string
IntExample:
type: integer
ObjectExample:
type: object
properties:
name:
type: string
ArrayExample:
type: array
items:
type: integer
Constraints: ограничения на значения
Тип говорит «это строка», но не говорит «это строка email длиной от 5 до 254 символов». Для этого есть constraints. Каждому базовому типу соответствует свой набор:
Пример realistic schema с constraints:
User:
type: object
required: [id, email, age]
properties:
id:
type: integer
minimum: 1
description: "Уникальный ID пользователя"
email:
type: string
format: email
maxLength: 254
description: "Email-адрес. Уникален на уровне БД"
age:
type: integer
minimum: 13
maximum: 130
username:
type: string
pattern: '^[a-z0-9_]{3,20}$'
description: "Латинские буквы, цифры, подчёркивание; 3-20 символов"
role:
type: string
enum: [user, moderator, admin]
default: user
tags:
type: array
items:
type: string
minItems: 0
maxItems: 10
uniqueItems: true
additionalProperties: false
Что мы здесь объявили:
id— целое от 1 и большеemail— строка не длиннее 254 символов в формате emailage— целое в диапазоне 13-130username— строка, соответствующая регулярке (только маленькие латинские буквы, цифры и подчёркивания, 3-20 символов)role— одна из трёх строкtags— массив строк длиной 0-10, без дубликатовadditionalProperties: false— лишние поля запрещены
По умолчанию additionalProperties: true — JSON может содержать любые лишние поля, и это валидно. Если хотите strict-валидацию (поломать запрос с опечаткой emaiL вместо email), явно ставьте false. Для входящих запросов это часто нужно, для исходящих ответов — почти всегда нет (forward compatibility).
Nullable: было nullable: true, стало type: ["string", "null"]
В OpenAPI 3.0 поле, которое может быть null, описывалось через специальный флаг:
# OpenAPI 3.0 -- устаревший синтаксис
email:
type: string
nullable: true
В OpenAPI 3.1 (и JSON Schema 2020-12) nullable упразднён. Вместо него — массив типов:
# OpenAPI 3.1 -- современный синтаксис
email:
type: ["string", "null"]
Это идеологически чище: null — отдельный JSON-тип, а тип поля — это объединение возможных типов. Для DE-инженера важно: если читаете спеки старых API, ожидайте nullable: true; если пишете новые — пишите массив типов.
$ref: переиспользование схем
Самая мощная и самая используемая фича OpenAPI — это $ref, ссылка на другую часть документа. Без $ref спека на 30 endpoints, возвращающих User, превратилась бы в копипасту определения User 30 раз.
Синтаксис $ref — JSON Pointer (RFC 6901): путь от корня документа через /. Например, #/components/schemas/User означает «корень документа -> components -> schemas -> User».
components:
schemas:
User:
type: object
required: [id, email]
properties:
id:
type: integer
email:
type: string
format: email
UserList:
type: array
items:
$ref: '#/components/schemas/User'
paths:
/users:
get:
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserList'
/users/{id}:
get:
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
Здесь User определён один раз, а используется в трёх местах: внутри UserList и в двух endpoints. Поменяете определение User (например, добавите поле created_at) — изменение применится везде автоматически.
$ref может ссылаться не только на schemas, но и на parameters, responses, requestBodies, examples, headers, securitySchemes. Любую переиспользуемую сущность можно вынести в components/<тип> и ссылаться через $ref. Это превращает спеку из стены текста в библиотеку DRY-определений.
TypedDict и Protocol: структурная типизация в Python
allOf / oneOf / anyOf: композиция схем
Составные схемы — способ описать «не просто User, а User плюс ещё что-то». Три ключевых слова описывают разные виды композиции.
allOf: «наследование» / расширение
components:
schemas:
BaseUser:
type: object
required: [id, email]
properties:
id:
type: integer
email:
type: string
Admin:
allOf:
- $ref: '#/components/schemas/BaseUser'
- type: object
required: [permissions]
properties:
permissions:
type: array
items:
type: string
Admin — это BaseUser плюс дополнительное поле permissions. JSON, валидный по схеме Admin, должен быть валиден по обеим схемам внутри allOf. Это самый частый паттерн расширения в OpenAPI.
oneOf: тегированный union
Допустим, наш API принимает payload разных типов платежей. Они структурно разные:
components:
schemas:
CreditCardPayment:
type: object
required: [type, card_number, cvv]
properties:
type:
const: credit_card
card_number:
type: string
pattern: '^\d{16}$'
cvv:
type: string
pattern: '^\d{3}$'
PaypalPayment:
type: object
required: [type, paypal_email]
properties:
type:
const: paypal
paypal_email:
type: string
format: email
Payment:
oneOf:
- $ref: '#/components/schemas/CreditCardPayment'
- $ref: '#/components/schemas/PaypalPayment'
Объект-payment должен быть либо credit_card, либо paypal. Поле type — дискриминатор: по нему легко понять, к какому варианту относится конкретный объект. Дискриминатор можно объявить явно — об этом ниже.
anyOf: гибкий union
anyOf менее строгий: объект должен соответствовать хотя бы одной схеме. Используется реже — типичный кейс: «либо строка, либо число»:
StringOrNumber:
anyOf:
- type: string
- type: number
Технически это можно описать и через массив типов: type: [string, number]. Так короче и понятнее. anyOf нужен, когда варианты сложнее одного типа.
Discriminator: подсказка для кодогенератора
oneOf сам по себе говорит «один из этих вариантов», но не говорит, как отличить. При парсинге JSON клиенту нужно понять: «у меня в руках Payment — это CreditCardPayment или PaypalPayment?». Можно перебирать схемы и пытаться валидировать каждую — это медленно и неоднозначно. Решение — discriminator.
components:
schemas:
Payment:
oneOf:
- $ref: '#/components/schemas/CreditCardPayment'
- $ref: '#/components/schemas/PaypalPayment'
discriminator:
propertyName: type
mapping:
credit_card: '#/components/schemas/CreditCardPayment'
paypal: '#/components/schemas/PaypalPayment'
Discriminator говорит: «смотри в поле type объекта. Если там credit_card — это CreditCardPayment. Если paypal — это PaypalPayment». Кодогенератор использует это, чтобы создать правильный класс при парсинге:
# Сгенерированный код выглядит примерно так
def parse_payment(data: dict) -> Payment:
type_value = data["type"]
if type_value == "credit_card":
return CreditCardPayment(**data)
elif type_value == "paypal":
return PaypalPayment(**data)
raise ValueError(f"Unknown payment type: {type_value}")
В OpenAPI 3.1 (и JSON Schema 2020-12) discriminator — это OpenAPI-extension, его нет в чистом JSON Schema. В JSON Schema аналогом служит if/then/else, но это сложнее в работе. Discriminator проще и понятнее для практических целей.
Practical schema: полный пример с композицией
Соберём всё вместе на realistic примере: API для управления заказами в e-commerce.
components:
schemas:
OrderStatus:
type: string
enum: [pending, paid, shipped, delivered, cancelled]
Address:
type: object
required: [country, city, street]
properties:
country:
type: string
minLength: 2
maxLength: 2
description: "ISO 3166-1 alpha-2"
city:
type: string
street:
type: string
postal_code:
type: ["string", "null"]
OrderItem:
type: object
required: [product_id, quantity, price]
properties:
product_id:
type: integer
minimum: 1
quantity:
type: integer
minimum: 1
maximum: 1000
price:
type: number
minimum: 0
description: "Цена за единицу в валюте заказа"
Order:
type: object
required: [id, status, items, total, shipping_address]
properties:
id:
type: integer
minimum: 1
status:
$ref: '#/components/schemas/OrderStatus'
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
minItems: 1
total:
type: number
minimum: 0
shipping_address:
$ref: '#/components/schemas/Address'
notes:
type: ["string", "null"]
maxLength: 500
additionalProperties: false
Что здесь видно:
OrderStatus— enum, переиспользуется в любом месте, где нужен статус заказаAddress— отдельная схема для адреса. Может переиспользоваться для billing/shipping/return addressesOrderItem— позиция заказа. Через$refвстраивается вitemsмассивOrder— корневая схема. Использует все остальные через$refadditionalProperties: false— strict, лишние поля запрещеныnotes— необязательное поле, может бытьnull
В сгенерированном Python-клиенте это превратится в красивые dataclasses:
from enum import Enum
from typing import Optional
from dataclasses import dataclass
class OrderStatus(str, Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
@dataclass
class Address:
country: str
city: str
street: str
postal_code: Optional[str] = None
@dataclass
class OrderItem:
product_id: int
quantity: int
price: float
@dataclass
class Order:
id: int
status: OrderStatus
items: list[OrderItem]
total: float
shipping_address: Address
notes: Optional[str] = None
Когда схему вынести в components, а когда — inline
Не каждую схему стоит выносить в components/schemas. Есть простое правило:
На практике в нормальном API components/schemas содержит 30-100 схем, а в paths каждый response — это $ref на одну из них. Inline-схемы — 5-10% случаев, для специфичных ad-hoc payloads.
Schema Validation в Python: на стороне клиента
Если у вас на руках OpenAPI-спека, вы можете валидировать ответы сервера на стороне клиента. Это полезно для:
- Раннего обнаружения изменений API (сервер начал слать новое поле — клиент сразу заметил)
- Защиты от мусора, если сервер не очень дисциплинирован
Самая простая библиотека — jsonschema:
import json
from jsonschema import validate, ValidationError
# Извлечённая из OpenAPI схема User
user_schema = {
"type": "object",
"required": ["id", "email"],
"properties": {
"id": {"type": "integer", "minimum": 1},
"email": {"type": "string", "format": "email"},
},
"additionalProperties": False,
}
response_data = {"id": 42, "email": "[email protected]"}
try:
validate(instance=response_data, schema=user_schema)
print("Valid")
except ValidationError as exc:
print(f"Invalid: {exc.message}")
В реальной интеграции схему обычно не извлекают руками, а используют openapi-python-client или pydantic с авто-генерацией моделей. Но jsonschema полезен для quick-and-dirty валидации, когда лень тянуть тяжёлый кодогенератор.
Итоги урока
OpenAPI 3.1 schemas — это JSON Schema 2020-12 с микро-надстройками. Семь базовых типов (string, number, integer, boolean, object, array, null), constraints под каждый тип (minLength, pattern, enum, minimum, maxItems, required), $ref для переиспользования, allOf/oneOf/anyOf для композиции, discriminator для тэгированных union.
Ключевое отличие от 3.0: nullable: true упразднён, теперь type: ["string", "null"]. Это согласовано с JSON Schema и работает везде.
Для Junior DE главный урок: научиться читать components/schemas так же быстро, как Python-код. Хорошо сделанная спека — это документация плюс типизация плюс валидация в одном файле. В следующем уроке посмотрим, как из этой спеки получить готовый Python-клиент через openapi-python-client и не писать requests.get руками.