Learning Platform
Глоссарий Troubleshooting
Урок 08.02 · 22 мин
Начальный
OpenAPIJSON SchemaSchemasValidation

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) описывает значения через примитивные и составные типы. Их семь.

Базовые типы JSON Schema 2020-12
stringСтрока JSON. Поддерживает constraints: minLength, maxLength, pattern (regex), format (email/uri/date-time/uuid)
numberЛюбое число с плавающей точкой. Constraints: minimum, maximum, exclusiveMinimum, multipleOf
integerЦелое число. JSON-у это всё ещё number, но JSON Schema выделяет integer как отдельный тип для строгости
booleantrue или false. Никаких особых constraints
objectJSON-объект (dict). Constraints: properties (поля), required (обязательные), additionalProperties (запрет лишних полей)
arrayJSON-массив (list). Constraints: items (схема элементов), minItems, maxItems, uniqueItems
nullJSON-значение null. В OpenAPI 3.1 -- отдельный тип, можно использовать в type array: ['string', 'null']

Минимальная схема для каждого типа:

StringExample:
  type: string

IntExample:
  type: integer

ObjectExample:
  type: object
  properties:
    name:
      type: string

ArrayExample:
  type: array
  items:
    type: integer

Constraints: ограничения на значения

Тип говорит «это строка», но не говорит «это строка email длиной от 5 до 254 символов». Для этого есть constraints. Каждому базовому типу соответствует свой набор:

Constraints по типам
string constraintsminLength (минимальная длина), maxLength (максимальная), pattern (regex, ECMA-262), format (подсказка для валидаторов)
number constraintsminimum / maximum (включительно), exclusiveMinimum / exclusiveMaximum (без равенства), multipleOf (кратность)
array constraintsminItems / maxItems (длина), uniqueItems: true (запрет дубликатов), prefixItems (типы первых N элементов -- для tuple)
object constraintsrequired (массив обязательных полей), minProperties / maxProperties, additionalProperties (по умолчанию true -- лишние поля разрешены)
enumУниверсальный constraint для любого типа: значение должно быть одним из элементов списка. enum: [active, inactive, banned]
constЗначение должно строго равняться константе. Эквивалент enum с одним элементом
formatПодсказка о семантике. Стандартные: email, uri, date, date-time, uuid, ipv4, ipv6. Не все валидаторы их проверяют -- это hint, не строгая проверка

Пример 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 символов в формате email
  • age — целое в диапазоне 13-130
  • username — строка, соответствующая регулярке (только маленькие латинские буквы, цифры и подчёркивания, 3-20 символов)
  • role — одна из трёх строк
  • tags — массив строк длиной 0-10, без дубликатов
  • additionalProperties: false — лишние поля запрещены
WARNING

По умолчанию 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; если пишете новые — пишите массив типов.

nullable в разных версиях
OpenAPI 2.0Не поддерживалось вообще. Если поле могло быть null -- приходилось писать в description и надеяться
OpenAPI 3.0Специальный флаг nullable: true. Применялся к полю с type: string. Не работал внутри $ref
OpenAPI 3.1Стандартный JSON Schema подход: type как массив. Работает везде, согласовано с остальным JSON Schema

$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) — изменение применится везде автоматически.

TIP

$ref может ссылаться не только на schemas, но и на parameters, responses, requestBodies, examples, headers, securitySchemes. Любую переиспользуемую сущность можно вынести в components/<тип> и ссылаться через $ref. Это превращает спеку из стены текста в библиотеку DRY-определений.


TypedDict и Protocol: структурная типизация в Python

allOf / oneOf / anyOf: композиция схем

Составные схемы — способ описать «не просто User, а User плюс ещё что-то». Три ключевых слова описывают разные виды композиции.

allOf vs oneOf vs anyOf
allOfЛогическое И. Объект должен соответствовать ВСЕМ перечисленным схемам. Используется как 'наследование': User + поля Admin = Admin
oneOfЭксклюзивное ИЛИ. Объект должен соответствовать ровно ОДНОЙ из схем. Используется для тэгированных union: либо CreditCardPayment, либо PaypalPayment, не оба
anyOfВключающее ИЛИ. Объект должен соответствовать хотя бы одной схеме. Менее строгий, чем oneOf -- 'может быть и тем, и другим'

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}")
NOTE

В 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 addresses
  • OrderItem — позиция заказа. Через $ref встраивается в items массив
  • Order — корневая схема. Использует все остальные через $ref
  • additionalProperties: 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. Есть простое правило:

Inline vs components/schemas
InlineПрямо в operation. Подходит для одноразовых форм: например, PUT /preferences принимает уникальный PreferencesPayload, который больше нигде не используется
OK inlineНе загромождает components, не плодит ложно-важные сущности
ПереиспользуетсяUser появляется в 5 endpoints как response, в 2 как request, в 3 как часть других схем -- выносим обязательно
В componentsОдин источник правды. Изменение в одном месте -- обновление везде
Семантически важнаяUser, Order, Product -- это бизнес-сущности. Даже если используются один раз, лучше вынести: имя в components сразу делает их 'каноничными'
В componentsЧтобы кодогенератор создал понятный класс User, а не InlineUserResponse42

На практике в нормальном 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 руками.


Проверка знанийKnowledge check
В OpenAPI 3.1 спеке вы видите: schema для поля 'metadata' описана как 'type: object, additionalProperties: true' (без properties). Что это значит для клиента, и почему такая схема -- обычно code smell?
ОтветAnswer
Это значит, что 'metadata' -- произвольный JSON-объект с любыми ключами и любыми значениями. Кодогенератор не может создать typed класс -- он сделает что-то вроде Dict[str, Any] (или просто dict). Клиент знает, что в ответе будет dict, но не знает, какие в нём поля и какого типа. Это code smell, потому что: (1) теряется главное преимущество OpenAPI -- типизация и валидация; (2) если структура metadata реально варьируется по запросам, то это надо описать через oneOf с дискриминатором; (3) если структура одна, но автор спеки поленился её описать -- спека сделана халтурно. Допустимый use case -- действительно произвольные user-provided metadata (типа freeform tags), но даже там лучше явно ограничить типы значений (additionalProperties: { type: string } -- словарь string->string).

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. В OpenAPI 3.1 поле email может быть строкой или null. Как это правильно описать?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3