openapi: 3.0.3

# Спецификация лежит в war и отдаётся по /thebus/openapi.yaml.
#
# Написана руками, а не собрана подсистемой WildFly, потому что war по условию
# ничего не просит у сервера: microprofile-openapi-smallrye пришлось бы включать
# в общий профиль, а там живут чужие приложения. Аннотации в коде при этом
# оставлены — если подсистему когда-нибудь включат, она соберёт то же самое.
#
# Отсюда обязанность: добавили эндпоинт или поле — впишите сюда. Молча
# разъехавшаяся спецификация хуже отсутствующей, ровно как со списком миграций.

info:
  title: TheBus
  version: 0.1.0
  description: |
    Шина обмена сообщениями: продюсер кладёт JSON в тему, потребитель забирает.
    Клиентской библиотеки нет — всё делается обычными HTTP-запросами.

    Гарантия доставки — at-least-once: сообщение может прийти дважды, обработчик
    обязан быть идемпотентным. Подробное руководство по подключению — `docs/integration.md`
    в репозитории.

servers:
  - url: https://thebus.mjsty.ru/thebus/api/v1
    description: рабочая шина
  - url: http://localhost:8080/thebus/api/v1
    description: локальная сборка

tags:
  - name: Темы
    description: Публикация и просмотр сообщений
  - name: Подписки
    description: Чтение, подтверждение, DLQ и перепроигрывание
  - name: Администрирование
    description: Темы, подписки и токены
  - name: Служебное
    description: Состояние и метрики

security:
  - bearer: []

paths:

  /health:
    get:
      tags: [Служебное]
      summary: Жива ли шина и видна ли база
      description: Единственный эндпоинт без токена.
      security: []
      responses:
        "200":
          description: Состояние
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: up }
                  version: { type: string, example: 0.1.0 }
                  builtAt: { type: string, example: "2026-09-01T20:41:57" }
                  database: { type: string, example: ok }

  /metrics:
    get:
      tags: [Служебное]
      summary: Состояние очередей
      description: |
        Смотреть надо не на длину очереди, а на `oldestReadySeconds`: тысяча сообщений,
        которые разбирают, — норма, три сообщения, которые лежат час, — вставший потребитель.
        Нужен токен с правом `admin`.
      parameters:
        - name: format
          in: query
          description: json (по умолчанию) либо prometheus
          schema: { type: string, enum: [json, prometheus], default: json }
      responses:
        "200":
          description: Метрики
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Metrics" }
            text/plain:
              schema: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }

  /topics:
    get:
      tags: [Темы]
      summary: Список тем
      responses:
        "200":
          description: Темы
          content:
            application/json:
              schema:
                type: object
                properties:
                  topics:
                    type: array
                    items: { $ref: "#/components/schemas/Topic" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /topics/{topic}/messages:
    post:
      tags: [Темы]
      summary: Опубликовать сообщение
      description: |
        Тело — любой JSON, если на тему не поставлена схема. Нужен скоуп `pub:<тема>`.
      parameters:
        - $ref: "#/components/parameters/TopicName"
        - name: Idempotency-Key
          in: header
          description: |
            Защита от двойной отправки. Повтор с тем же ключом вернёт тот же `id`
            и `duplicate: true`, второй раз никому ничего не разложится.
          schema: { type: string }
        - name: Ordering-Key
          in: header
          description: |
            Цепочка, внутри которой соблюдается порядок: следующее сообщение не выдаётся,
            пока предыдущее не подтвердят или оно не уедет в DLQ. Ключ стоит делать узким
            (клиент, документ), иначе очередь станет однопоточной.
          schema: { type: string, example: client-7 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
            example: { orderId: 12345, sum: 990.00 }
      responses:
        "202":
          description: |
            Принято. `subscriptions` — скольким подпискам разложено; ноль означает,
            что читателей у темы нет или все фильтры сообщение отвергли.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer, format: int64, example: 84213 }
                  duplicate: { type: boolean }
                  subscriptions: { type: integer }
        "400":
          description: Тело не JSON или не подходит под схему темы
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error: bad_request
                message: "тело не подходит под схему темы: orderId — ожидалось integer, пришло string"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      tags: [Темы]
      summary: Что шло по теме
      description: |
        Сообщения не удаляются после обработки — это и есть разбор полётов.
        Нужен скоуп `pub:<тема>`.
      parameters:
        - $ref: "#/components/parameters/TopicName"
        - name: fromId
          in: query
          description: с какого номера сообщения
          schema: { type: integer, format: int64 }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 500 }
      responses:
        "200":
          description: Сообщения темы
          content:
            application/json:
              schema:
                type: object
                properties:
                  topic: { type: string }
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/StoredMessage" }
        "404": { $ref: "#/components/responses/NotFound" }

  /subscriptions:
    get:
      tags: [Подписки]
      summary: Список подписок с состоянием очередей
      responses:
        "200":
          description: Подписки
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscriptions:
                    type: array
                    items: { $ref: "#/components/schemas/Subscription" }

  /subscriptions/{name}/pull:
    get:
      tags: [Подписки]
      summary: Забрать пачку сообщений
      description: |
        `wait` держит соединение до появления сообщения — опрашивать в цикле не нужно.
        Таймаут HTTP-клиента обязан быть больше `wait`, иначе клиент оборвёт соединение сам.
        Выданные сообщения держатся под лизингом: не подтвердили за `leaseSeconds` —
        вернутся в очередь. Нужен скоуп `sub:<подписка>`.
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
        - name: max
          in: query
          description: сколько сообщений за раз (1–100)
          schema: { type: integer, default: 10, maximum: 100 }
        - name: wait
          in: query
          description: сколько секунд ждать появления сообщения (0–60)
          schema: { type: integer, default: 0, maximum: 60 }
      responses:
        "200":
          description: Сообщения выданы под лизинг
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/ClaimedMessage" }
        "204":
          description: Пусто — за отведённое время ничего не появилось
        "400":
          description: Подписка работает в режиме push, забирать из неё нельзя
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /subscriptions/{name}/ack:
    post:
      tags: [Подписки]
      summary: Подтвердить обработку
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tokens]
              properties:
                tokens:
                  type: array
                  items: { type: string }
                  description: значения `token` из ответа pull
            example: { tokens: ["b3f1…"] }
      responses:
        "200":
          description: |
            `stale` — расхождение между запрошенным и закрытым: чаще всего протухший
            лизинг, то есть обработка не укладывается в `leaseSeconds`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Counted" }

  /subscriptions/{name}/nack:
    post:
      tags: [Подписки]
      summary: Вернуть сообщения в очередь
      description: Исчерпавшие попытки уедут в DLQ.
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tokens]
              properties:
                tokens:
                  type: array
                  items: { type: string }
                delaySeconds:
                  type: integer
                  description: через сколько показать снова
                reason:
                  type: string
                  description: попадёт в DLQ как lastError — напишите что-нибудь осмысленное
            example: { tokens: ["b3f1…"], delaySeconds: 60, reason: "склад не отвечает" }
      responses:
        "200":
          description: Возвращено
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Counted" }

  /subscriptions/{name}/stats:
    get:
      tags: [Подписки]
      summary: Состояние очереди подписки
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
      responses:
        "200":
          description: Подписка со счётчиками
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }

  /subscriptions/{name}/dead:
    get:
      tags: [Подписки]
      summary: Разобрать DLQ
      description: "Доставки, исчерпавшие попытки: что не разобралось и почему."
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
        - name: fromId
          in: query
          description: с какого номера доставки
          schema: { type: integer, format: int64 }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          description: Содержимое DLQ
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { type: string }
                  dead:
                    type: array
                    items: { $ref: "#/components/schemas/DeadDelivery" }

  /subscriptions/{name}/dead/requeue:
    post:
      tags: [Подписки]
      summary: Вернуть из DLQ в очередь
      description: |
        Без `ids` возвращается вся DLQ подписки. Счётчик попыток сбрасывается:
        причину чинили снаружи, значит лимит даётся заново.
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items: { type: integer, format: int64 }
                  description: номера доставок из ответа /dead
            example: { ids: [17, 18] }
      responses:
        "200":
          description: Возвращено
          content:
            application/json:
              schema:
                type: object
                properties:
                  requeued: { type: integer }
                  all: { type: boolean, description: возвращали ли всю DLQ }

  /subscriptions/{name}/replay:
    post:
      tags: [Подписки]
      summary: Перепроиграть сообщения темы
      description: |
        Границы — `fromId`/`toId` либо `fromTime`/`toTime`; нижняя обязательна, иначе
        перепроигралась бы вся тема. Обработанные и лежащие в DLQ доставки встают в очередь
        заново с нулём попыток; взятые под лизинг не трогаются — их сейчас обрабатывают.
        Фильтр подписки действует и здесь.
      parameters:
        - $ref: "#/components/parameters/SubscriptionName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fromId: { type: integer, format: int64 }
                toId: { type: integer, format: int64 }
                fromTime: { type: string, format: date-time }
                toTime: { type: string, format: date-time }
                limit: { type: integer, default: 1000, maximum: 10000 }
            example: { fromTime: "2026-09-01T00:00:00Z" }
      responses:
        "200":
          description: |
            `more: true` означает, что упёрлись в `limit` и надо повторить с новой границы.
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { type: string }
                  topic: { type: string }
                  queued: { type: integer }
                  limit: { type: integer }
                  more: { type: boolean }
        "400":
          description: Не задана нижняя граница или время не в ISO-8601
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /admin/topics:
    post:
      tags: [Администрирование]
      summary: Создать тему
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: orders.created }
                retentionDays: { type: integer, default: 30 }
                schema:
                  type: object
                  nullable: true
                  description: необязательная JSON Schema, см. /admin/topics/{name}/schema
      responses:
        "201":
          description: Тема создана
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Topic" }
        "409":
          description: Тема с таким именем уже есть
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /admin/topics/{name}/schema:
    post:
      tags: [Администрирование]
      summary: Поставить, заменить или снять схему темы
      description: |
        Поддержано подмножество JSON Schema: type, properties, required, additionalProperties,
        items, enum, const, minimum/maximum, exclusiveMinimum/exclusiveMaximum,
        minLength/maxLength, pattern, minItems/maxItems, uniqueItems.

        Незнакомое ключевое слово шина не примет — иначе автор схемы считал бы, что правило
        работает, а оно молча игнорировалось бы. `schema: null` снимает проверку; уже лежащие
        сообщения не пересматриваются.
      parameters:
        - $ref: "#/components/parameters/TopicPathName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                schema: { type: object, nullable: true }
            example:
              schema:
                type: object
                required: [orderId]
                properties:
                  orderId: { type: integer, minimum: 1 }
      responses:
        "200":
          description: Тема со схемой
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Topic" }
        "400":
          description: Схема непонятна — неподдержанное ключевое слово или кривой pattern
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /admin/subscriptions:
    post:
      tags: [Администрирование]
      summary: Создать подписку
      description: |
        Имя подписки уникально на всю шину — оно же адрес в URL. Для режима push в ответе
        придёт `pushSecret`, и это единственный раз, когда он показывается.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, topic]
              properties:
                name: { type: string, example: billing }
                topic: { type: string, example: orders.created }
                mode: { type: string, enum: [pull, push], default: pull }
                endpointUrl: { type: string, description: обязателен для push }
                maxAttempts: { type: integer, default: 8 }
                leaseSeconds: { type: integer, default: 30 }
                pushTimeoutSeconds:
                  type: integer
                  default: 10
                  description: обязан быть меньше leaseSeconds
                filter:
                  type: object
                  nullable: true
                  description: см. /admin/subscriptions/{name}/filter
      responses:
        "201":
          description: Подписка создана
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Subscription"
                  - type: object
                    properties:
                      pushSecret:
                        type: string
                        description: только при mode=push и только в этом ответе
        "409":
          description: Подписка с таким именем уже есть
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /admin/subscriptions/{name}/filter:
    post:
      tags: [Администрирование]
      summary: Поставить, заменить или снять фильтр подписки
      description: |
        Пути начинаются с `headers.` или `payload.`, вложенность через точку. Массив в
        значении — «любое из», все пары складываются по И. Сравнение идёт как jsonb:
        `12` не совпадёт с `"12"`.

        Фильтр решает судьбу сообщения в момент публикации: уже созданные доставки не
        пересматриваются, а не подошедшее не появится и при replay.
      parameters:
        - $ref: "#/components/parameters/SubscriptionPathName"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                filter: { type: object, nullable: true }
            example:
              filter:
                headers.region: spb
                payload.type: [order, refund]
      responses:
        "200":
          description: Подписка с фильтром
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }

  /admin/subscriptions/{name}/push-secret:
    post:
      tags: [Администрирование]
      summary: Сменить секрет подписи push-доставок
      description: Новый секрет показывается один раз. Старый перестаёт работать сразу.
      parameters:
        - $ref: "#/components/parameters/SubscriptionPathName"
      responses:
        "200":
          description: Новый секрет
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscription: { type: string }
                  pushSecret: { type: string }

  /admin/tokens:
    post:
      tags: [Администрирование]
      summary: Выпустить токен сервису
      description: |
        Права: `pub:<тема>`, `sub:<подписка>`, `admin`. Звёздочка в конце закрывает любой
        хвост: `pub:orders.*`. Токен показывается один раз.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service, scopes]
              properties:
                service: { type: string, example: billing }
                scopes:
                  type: array
                  items: { type: string }
                  example: ["sub:billing"]
                days: { type: integer, default: 365 }
      responses:
        "200":
          description: Токен выпущен
          content:
            application/json:
              schema:
                type: object
                properties:
                  service: { type: string }
                  token: { type: string }
                  expiresInDays: { type: integer }

  /admin/tokens/revoke:
    post:
      tags: [Администрирование]
      summary: Отозвать токен по jti
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jti]
              properties:
                jti: { type: string }
                reason: { type: string }
      responses:
        "200":
          description: Отозван
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked: { type: string }

components:

  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    TopicName:
      name: topic
      in: path
      required: true
      schema: { type: string }
      example: orders.created
    TopicPathName:
      name: name
      in: path
      required: true
      schema: { type: string }
      example: orders.created
    SubscriptionName:
      name: name
      in: path
      required: true
      schema: { type: string }
      example: billing
    SubscriptionPathName:
      name: name
      in: path
      required: true
      schema: { type: string }
      example: billing

  responses:
    Unauthorized:
      description: Токен не передан, не принят, протух или отозван
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Токену не хватает прав на эту тему или подписку
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Нет такой темы или подписки
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:

    Error:
      type: object
      properties:
        error:
          type: string
          enum: [bad_request, unauthorized, forbidden, not_found, conflict, internal_error]
        message: { type: string }

    Counted:
      type: object
      properties:
        updated: { type: integer }
        requested: { type: integer }
        stale: { type: integer }

    Topic:
      type: object
      properties:
        name: { type: string }
        retention: { type: string, example: "30 days" }
        messages: { type: integer }
        createdAt: { type: string, format: date-time }
        schema:
          type: object
          description: присутствует, только если на тему поставлена схема

    Subscription:
      type: object
      properties:
        name: { type: string }
        topic: { type: string }
        mode: { type: string, enum: [pull, push] }
        maxAttempts: { type: integer }
        leaseSeconds: { type: integer }
        endpointUrl: { type: string, description: только для push }
        pushTimeoutSeconds: { type: integer, description: только для push }
        filter: { type: object, description: присутствует, только если фильтр задан }
        createdAt: { type: string, format: date-time }
        queue:
          type: object
          description: счётчики доставок, если запрошены
          properties:
            ready: { type: integer }
            inflight: { type: integer }
            done: { type: integer }
            dead: { type: integer }

    ClaimedMessage:
      type: object
      properties:
        token:
          type: string
          description: этим подтверждают именно эту доставку
        id: { type: integer, format: int64 }
        attempt:
          type: integer
          description: какая это попытка; больше единицы — сообщение уже не разобралось
        producer: { type: string }
        publishedAt: { type: string, format: date-time }
        headers:
          type: object
          description: заголовки X-Bus-* публикации
        payload: { type: object }
        orderingKey: { type: string, description: если сообщение публиковали с Ordering-Key }

    StoredMessage:
      type: object
      properties:
        id: { type: integer, format: int64 }
        producer: { type: string }
        publishedAt: { type: string, format: date-time }
        headers: { type: object }
        payload: { type: object }
        idempotencyKey: { type: string }
        orderingKey: { type: string }

    DeadDelivery:
      type: object
      properties:
        deliveryId:
          type: integer
          format: int64
          description: им же возвращают в очередь, это не номер сообщения
        messageId: { type: integer, format: int64 }
        attempts: { type: integer }
        lastError: { type: string, description: чем закончилась последняя попытка }
        producer: { type: string }
        publishedAt: { type: string, format: date-time }
        failedAt: { type: string, format: date-time }
        headers: { type: object }
        payload: { type: object }

    Metrics:
      type: object
      properties:
        totals:
          type: object
          properties:
            topics: { type: integer }
            subscriptions: { type: integer }
            messages: { type: integer }
            ready: { type: integer }
            inflight: { type: integer }
            dead: { type: integer }
        subscriptions:
          type: array
          items:
            type: object
            properties:
              subscription: { type: string }
              topic: { type: string }
              mode: { type: string }
              ready: { type: integer }
              inflight: { type: integer }
              done: { type: integer }
              dead: { type: integer }
              oldestReadySeconds:
                type: integer
                description: возраст самого старого невыданного сообщения
        topics:
          type: array
          items:
            type: object
            properties:
              topic: { type: string }
              messages: { type: integer }
              oldestSeconds: { type: integer }
