openapi: 3.0.3
info:
  title: Car2B Import API
  version: "1.0"
  description: |
    Публичный API для систем клиентов Car2B: создание, обновление и снятие объявлений
    своей организации по собственному идентификатору `external_id`.

    **Доступ.** Ключ (client_id + секрет) выпускает организация в личном кабинете
    (раздел «Интеграции»). Секрет обменивается на короткоживущий токен:

    ```
    POST https://api.car2b.ru/api/v1/identity/oauth/token
    Authorization: Basic base64(client_id:client_secret)
    Content-Type: application/x-www-form-urlencoded

    grant_type=client_credentials
    ```

    Ответ: `{"access_token":"…","token_type":"Bearer","expires_in":300,"scope":"import:listings.write import:listings.read"}`.
    Токен живёт 5 минут; клиент обновляет его заранее и повторяет запрос один раз при 401.

    **Идемпотентность.** `PUT /import/listings/{external_id}` создаёт или полностью заменяет объявление;
    повтор безопасен. Поля, которых нет в теле, считаются пустыми.

    **Жизненный цикл.** `draft` (не хватает обязательных полей) → `pending_photos` (фото качаются) →
    `moderation` → `published` | `rejected`; `archived` после `DELETE`. Объявление без единого
    удачного фото остаётся в `pending_photos` и на модерацию не уходит.

    **Лимиты.** 10 запросов/с (всплеск 50) на ключ, 5 000 upsert в сутки на организацию,
    тело `PUT` до 1 МБ, фото до 20 МБ и 50 Мпикс, до 30 фото на объявление, до 5 ключей на организацию.

    **Ошибки** — `application/problem+json` с массивом `errors[]`, где `code` машинный, `message` по-русски.
servers:
  - url: https://api.car2b.ru/api/v1/import
security:
  - partnerToken: []
tags:
  - name: listings
    description: Объявления организации, загруженные по ключу
  - name: photos
    description: Загрузка фото файлом
  - name: reference
    description: Схема полей и справочники

paths:
  /listings:
    get:
      tags: [listings]
      summary: Список объявлений ключа
      description: Всё, что загружено этим ключом, включая архивные — для сверки с системой клиента. Порядок по `external_id`.
      operationId: listListings
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
          description: Значение `next_cursor` предыдущей страницы
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Страница списка
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListingList" }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /listings:batch:
    post:
      tags: [listings]
      summary: Батч-загрузка до 100 объявлений
      description: |
        Каждая строка — тело `PUT` плюс `external_id`. Строки проверяются сразу: невалидные возвращаются в `rejected`
        с ошибками, принятые применяются асинхронно в прогоне `run_id` (статус — `GET /runs/{run_id}`).
        Заголовок `Idempotency-Key` (до 128 символов): повтор с тем же ключом в течение 24 часов возвращает
        первый ответ с заголовком `Idempotency-Replayed: true` и не создаёт второй прогон. Квота считает каждую принятую строку.
      operationId: batchUpsertListings
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BatchInput" }
      responses:
        "202":
          description: Батч принят
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BatchView" }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413":
          description: "`payload_too_large` — тело батча больше 5 МБ"
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /runs/{run_id}:
    get:
      tags: [listings]
      summary: Статус прогона батча
      description: Агрегат по строкам и построчные исходы с курсором. Ошибки строки — те же коды, что у `PUT` (например `vin_conflict`, `slots_exhausted`, `unresolved_reference`).
      operationId: getRun
      parameters:
        - name: run_id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
      responses:
        "200":
          description: Прогон
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RunView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /listings/{external_id}:
    parameters:
      - $ref: "#/components/parameters/externalId"
    put:
      tags: [listings]
      summary: Создать или полностью заменить объявление
      description: |
        Ответ `202`: объявление принято. Фото по URL выкачиваются асинхронно, модерация начинается,
        когда заполнены обязательные поля и загружено хотя бы одно фото. Без обязательных полей
        объявление сохраняется черновиком (`status: draft`, `missing_fields`).
        VIN сверяется с маркой; объявление той же организации с тем же VIN обновляется, а не дублируется.
      operationId: upsertListing
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ListingInput" }
            example:
              vin: XTT316300P1234567
              brand: УАЗ
              model: Патриот
              year: 2023
              mileage: 0
              condition: new
              availability: in_stock
              body_type: suv
              color: white
              engine_volume: 2.7
              engine_power: 150
              fuel_type: petrol
              transmission: manual
              drive_type: awd
              steering_wheel: left
              price: { amount: 1990000, currency: RUB, vat_included: true }
              price_car2b: 1890000
              city: Москва
              description: Новый автомобиль, в наличии.
              quantity: 1
              photos:
                - { url: "https://cdn.dealer.ru/cars/123/1.jpg" }
                - { url: "https://cdn.dealer.ru/cars/123/2.jpg" }
                - { file_id: "6d1f2c8e-9b7a-4c1e-8f3d-2a5b6c7d8e9f" }
      responses:
        "202":
          description: Принято
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListingView" }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: "`slots_exhausted` — тариф организации не даёт свободных слотов"
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "`vin_conflict` — VIN занят активным объявлением другого владельца; `listing_detached` — объявление отвязано от API в кабинете"
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }
    patch:
      tags: [listings]
      summary: Частично обновить объявление
      description: |
        JSON merge patch (RFC 7386) поверх последнего принятого тела: присланные поля заменяются, `null` очищает поле,
        остальное не меняется. Если затронуты только `price`, `price_car2b`, `availability`, `mileage`, объявление
        обновляется точечно — без пересборки и без повторной отправки фото. Любое другое поле применяется как `PUT`
        с объединённым телом (те же ответы и ошибки).

        Модерация: изменение розничной цены, валюты, названия, описания, VIN, фото, марки, модели или года возвращает
        опубликованное объявление на модерацию (правило Car2B); наличие, пробег и `price_car2b` применяются без смены статуса.
      operationId: patchListing
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: "#/components/schemas/ListingInput" }
            example: { price: { amount: 1950000 }, availability: on_order }
          application/json:
            schema: { $ref: "#/components/schemas/ListingInput" }
      responses:
        "202":
          description: Принято
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListingView" }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: "`slots_exhausted`"
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`vin_conflict` | `listing_detached`"
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [listings]
      summary: Статус объявления
      operationId: getListing
      responses:
        "200":
          description: Текущее состояние
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListingView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [listings]
      summary: Снять с публикации и архивировать
      description: Архив. Повторный `PUT` с тем же `external_id` создаёт новое объявление.
      operationId: archiveListing
      responses:
        "204": { description: Архивировано }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhooks/deliveries:
    get:
      tags: [listings]
      summary: Журнал доставок вебхуков
      description: |
        Вебхуки настраиваются на ключе в кабинете (`webhook_url`, секрет). События: `listing.status_changed` на каждом
        переходе статуса (в том числе вердикт модерации) и `photo.failed`. Тело — `{"id","type","occurred_at","data":{…}}`;
        заголовки `X-Car2B-Signature: sha256=<hex HMAC-SHA256(secret, тело)>`, `X-Car2B-Delivery`, `X-Car2B-Event`.
        Таймаут 10 с, 2xx = доставлено, иначе повторы через 1, 5, 15, 60 и 300 с (всего 5 попыток). Журнал — новые сверху.
      operationId: listWebhookDeliveries
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Страница журнала
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeliveryList" }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /photos:
    post:
      tags: [photos]
      summary: Загрузить фото файлом
      description: Для фото, не опубликованных по URL. Одно фото за вызов, до 20 МБ. Полученный `file_id` вставляется в `photos[]` объявления; принимается только от того же ключа.
      operationId: uploadPhoto
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201":
          description: Файл принят
          content:
            application/json:
              schema:
                type: object
                required: [file_id]
                properties:
                  file_id: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /schema:
    get:
      tags: [reference]
      summary: Поля объявления
      description: Актуальный список полей тела `PUT` с типом, обязательностью, допустимыми значениями и классом справочника. Модель данных динамическая, поэтому схема отдаётся отсюда, а не зашита в документацию.
      operationId: getSchema
      responses:
        "200":
          description: Схема
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Schema" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /reference/brands:
    get:
      tags: [reference]
      summary: Справочник марок
      operationId: listBrands
      responses:
        "200":
          description: Марки
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReferenceList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /reference/models:
    get:
      tags: [reference]
      summary: Справочник моделей марки
      operationId: listModels
      parameters:
        - name: brand
          in: query
          required: true
          schema: { type: string }
          description: "`name` или название марки из /reference/brands"
      responses:
        "200":
          description: Модели
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReferenceList" }
        "400": { $ref: "#/components/responses/Malformed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/Unavailable" }

components:
  securitySchemes:
    partnerToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Токен из `POST /api/v1/identity/oauth/token` (client_credentials). Скоупы `import:listings.write` (PUT, DELETE, POST /photos) и `import:listings.read` (GET).

  parameters:
    externalId:
      name: external_id
      in: path
      required: true
      schema: { type: string, minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }
      description: Идентификатор объявления в системе клиента, уникален в рамках ключа

  responses:
    Malformed:
      description: "`malformed` — невалидный JSON, неизвестное поле, неверный тип; `invalid_value` для параметров"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: "`token_invalid` | `token_expired` — получите новый токен через /oauth/token; `client_revoked` — ключ отозван или неизвестен, выпустите новый в кабинете"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Forbidden:
      description: "`scope_missing` | `organization_unverified` | `organization_blocked` | `ip_not_allowed` (адрес вне allowlist ключа)"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: "`listing_not_found` — у этого ключа нет объявления с таким external_id (чужой external_id неотличим от несуществующего)"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    PayloadTooLarge:
      description: "`payload_too_large` — тело больше 1 МБ или фото больше 20 МБ / 50 Мпикс"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unprocessable:
      description: "Поля разобраны, но не проходят правила: `unresolved_reference` (с `candidates`), `vin_brand_mismatch`, `price_car2b_above_retail`, `invalid_enum`, `invalid_value`, `required_field_missing`, `unknown_file_id`, `not_image`, `rejected_by_catalog`"
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: "`rate_limited` | `daily_quota_exceeded`, с заголовком Retry-After"
      headers:
        Retry-After:
          schema: { type: integer }
          description: Через сколько секунд повторить
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unavailable:
      description: "`catalog_unavailable` — справочник ещё загружается; `temporarily_unavailable` — проверка ключа временно недоступна; повторите позже"
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Price:
      type: object
      required: [amount]
      properties:
        amount: { type: integer, format: int64, minimum: 1, description: Розничная цена в рублях, без копеек }
        currency: { type: string, enum: [RUB], default: RUB }
        vat_included: { type: boolean, description: Цена включает НДС }

    PhotoInput:
      type: object
      description: Ровно одно из `url` или `file_id`
      properties:
        url: { type: string, format: uri, description: Публичный http(s)-адрес, порты 80/443; фото выкачивается нами }
        file_id: { type: string, format: uuid, description: Идентификатор из POST /photos }

    ListingInput:
      type: object
      additionalProperties: false
      required: [brand, model, year, price, availability]
      description: Обязательные поля не блокируют приём — без них объявление сохраняется черновиком со списком `missing_fields`.
      properties:
        vin: { type: string, pattern: "^[A-HJ-NPR-Z0-9]{17}$", description: Рекомендуется; сверяется с маркой }
        brand: { type: string, description: Название или `name` из /reference/brands }
        model: { type: string, description: Название или `name` из /reference/models, разрешается в контексте марки }
        generation: { type: string }
        year: { type: integer, minimum: 1900 }
        mileage: { type: integer, minimum: 0, description: км; 0 или пусто — новый }
        condition: { type: string, enum: [new, used] }
        availability:
          type: string
          description: "`in_stock` принимается всегда; остальные значения — по актуальному enum в /import/schema (справочник контура; cars может дополнительно отклонить значение бизнес-правилом — придёт 422 с полем availability и текстом)"
          example: in_stock
        body_type: { type: string, description: Значение справочника (см. /schema, reference) }
        color: { type: string }
        interior_color: { type: string }
        interior_material: { type: string }
        engine_volume: { type: number, description: литры }
        engine_power: { type: integer, description: л.с. }
        fuel_type: { type: string }
        transmission: { type: string }
        drive_type: { type: string }
        steering_wheel: { type: string }
        price: { $ref: "#/components/schemas/Price" }
        price_car2b: { type: integer, format: int64, description: Цена Car2B, строго ниже розничной }
        city: { type: string, description: По умолчанию город организации }
        description: { type: string, maxLength: 4000 }
        quantity: { type: integer, minimum: 1, maximum: 100, description: N одинаковых новых машин = N объявлений }
        photos:
          type: array
          maxItems: 30
          items: { $ref: "#/components/schemas/PhotoInput" }
          description: Порядок в массиве = порядок показа

    ResolvedRef:
      type: object
      properties:
        title: { type: string }
        confidence: { type: number, minimum: 0, maximum: 1 }

    PhotoStatus:
      type: object
      required: [status]
      properties:
        url: { type: string }
        file_id: { type: string }
        status: { type: string, enum: [pending, uploaded, failed] }
        reason:
          type: string
          enum: [fetch_timeout, not_image, too_large, forbidden_host, http_4xx, fetch_failed]
          description: Причина для `failed`

    Moderation:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [pending, approved, rejected, revision_requested] }
        reason: { type: string, description: Комментарий модератора при отклонении }

    ListingView:
      type: object
      required: [external_id, status, created, missing_fields, resolved, photos, warnings, updated_at]
      properties:
        external_id: { type: string }
        listing_id: { type: string, format: uuid, description: Идентификатор объявления в Car2B; пусто у черновика }
        status: { type: string, enum: [draft, pending_photos, moderation, published, rejected, archived] }
        created: { type: boolean, description: Объявление создано этим вызовом (иначе обновлено) }
        missing_fields:
          type: array
          items: { type: string }
        resolved:
          type: object
          additionalProperties: { $ref: "#/components/schemas/ResolvedRef" }
          description: Как поняты `brand` и `model`
        photos:
          type: array
          items: { $ref: "#/components/schemas/PhotoStatus" }
        warnings:
          type: array
          items: { type: string }
        moderation: { $ref: "#/components/schemas/Moderation" }
        updated_at: { type: string, format: date-time }
      example:
        external_id: DLR-000123
        listing_id: 9f2c1a44-3c1e-4b7a-9d2f-1a2b3c4d5e6f
        status: pending_photos
        created: true
        missing_fields: []
        resolved:
          brand: { title: УАЗ, confidence: 1 }
          model: { title: Патриот, confidence: 0.97 }
        photos:
          - { url: "https://cdn.dealer.ru/cars/123/1.jpg", status: pending }
          - { file_id: "6d1f2c8e-9b7a-4c1e-8f3d-2a5b6c7d8e9f", status: uploaded }
        warnings: []
        updated_at: "2026-09-24T14:02:11Z"

    BatchItem:
      allOf:
        - type: object
          required: [external_id]
          properties:
            external_id: { type: string, minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }
        - $ref: "#/components/schemas/ListingInput"

    BatchInput:
      type: object
      required: [listings]
      properties:
        listings:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/BatchItem" }

    BatchView:
      type: object
      required: [run_id, accepted, rejected]
      properties:
        run_id: { type: string, format: uuid }
        accepted: { type: integer, description: Принято в прогон }
        rejected:
          type: array
          items:
            type: object
            required: [external_id, errors]
            properties:
              external_id: { type: string }
              errors:
                type: array
                items: { $ref: "#/components/schemas/FieldError" }

    RunRow:
      type: object
      required: [external_id, status]
      properties:
        external_id: { type: string }
        status: { type: string, enum: [rejected, queued, created, updated, failed] }
        listing_id: { type: string, format: uuid }
        errors:
          type: array
          items: { $ref: "#/components/schemas/FieldError" }

    RunView:
      type: object
      required: [run_id, status, total, accepted, rejected, created, updated, failed, photos, rows, next_cursor, created_at]
      properties:
        run_id: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, done] }
        total: { type: integer }
        accepted: { type: integer }
        rejected: { type: integer }
        created: { type: integer }
        updated: { type: integer }
        failed: { type: integer }
        photos:
          type: object
          properties:
            pending: { type: integer }
            uploaded: { type: integer }
            failed: { type: integer }
        rows:
          type: array
          items: { $ref: "#/components/schemas/RunRow" }
        next_cursor: { type: string, description: Пусто на последней странице }
        created_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time }

    WebhookEvent:
      type: object
      description: Тело вебхука
      required: [id, type, occurred_at, data]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [listing.status_changed, photo.failed] }
        occurred_at: { type: string, format: date-time }
        data:
          type: object
          description: "listing.status_changed: {external_id, listing_id, status, previous_status, reason}; photo.failed: {external_id, listing_id, url|file_id, reason}"
          additionalProperties: true

    Delivery:
      type: object
      required: [event_id, event_type, url, attempt, status_code, state, created_at]
      properties:
        event_id: { type: string, format: uuid }
        event_type: { type: string }
        url: { type: string }
        attempt: { type: integer }
        status_code: { type: integer, description: 0 — ответа не было }
        error: { type: string }
        state: { type: string, enum: [pending, delivered, failed] }
        delivered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    DeliveryList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Delivery" }
        next_cursor: { type: string }
        webhook_failing_since: { type: string, format: date-time, description: Есть, если последние 5 доставок подряд провалились }

    ListingList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/ListingView" }
        next_cursor: { type: string, description: Пусто на последней странице }

    SchemaField:
      type: object
      required: [name, type, required]
      properties:
        name: { type: string }
        type: { type: string, enum: [string, int, number, bool, enum, object, array] }
        required: { type: boolean }
        enum:
          type: array
          items: { type: string }
        reference: { type: string, description: Класс справочника, значения принимаются по названию }
        description: { type: string }

    Schema:
      type: object
      required: [fields]
      properties:
        fields:
          type: array
          items: { $ref: "#/components/schemas/SchemaField" }

    ReferenceItem:
      type: object
      required: [name, title]
      properties:
        name: { type: string, description: Стабильный идентификатор, принимается вместо названия }
        title: { type: string }

    ReferenceList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/ReferenceItem" }

    Candidate:
      type: object
      properties:
        name: { type: string }
        title: { type: string }
        confidence: { type: number }

    FieldError:
      type: object
      required: [code, message]
      properties:
        field: { type: string, description: "Поле тела, например `model` или `photos[2].url`" }
        code: { type: string }
        message: { type: string, description: По-русски, без внутренних идентификаторов }
        candidates:
          type: array
          items: { $ref: "#/components/schemas/Candidate" }
          description: Для `unresolved_reference`

    Problem:
      type: object
      description: RFC 9457
      required: [type, title, status]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        errors:
          type: array
          items: { $ref: "#/components/schemas/FieldError" }
      example:
        type: https://api.car2b.ru/errors/validation
        title: Объявление не прошло проверку
        status: 422
        instance: /api/v1/import/listings/DLR-000123
        errors:
          - field: model
            code: unresolved_reference
            message: Модель «Патриот Спорт» не найдена у марки УАЗ
            candidates:
              - { name: patriot, title: Патриот, confidence: 0.71 }
