From ef98146b06579a5af75c352acbd805636fcfb359 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Fri, 30 Oct 2026 19:10:11 +0300 Subject: [PATCH] AsyncAPI 2.x and 3.0: loader and reference page loadAsyncApi reads both versions into one shape (servers, channels, operations as send/receive from the application's side, messages, replies, bindings, traits); detectSpecKind tells AsyncAPI from OpenAPI. AsyncApiReference shows a document with the reference's schema view. Tests, browser check, docs in four languages. Co-Authored-By: Claude Opus 5.5 --- .changeset/asyncapi.md | 5 + README.md | 4 +- docs/de/asyncapi.md | 60 ++++ docs/en/asyncapi.md | 58 ++++ docs/ru/asyncapi.md | 59 ++++ docs/zh/asyncapi.md | 43 +++ playground/main.ts | 9 +- scripts/browser-check.mjs | 25 ++ src/asyncapi/index.ts | 15 + src/asyncapi/load.ts | 396 +++++++++++++++++++++++++ src/index.ts | 1 + src/vue/asyncapi/AsyncApiMessage.vue | 71 +++++ src/vue/asyncapi/AsyncApiReference.vue | 279 +++++++++++++++++ src/vue/asyncapi/labels.ts | 85 ++++++ src/vue/index.ts | 2 + src/vue/openapi.css | 16 + tests/asyncapi.spec.ts | 101 +++++++ tests/fixtures/asyncapi/orders-2.yaml | 61 ++++ tests/fixtures/asyncapi/orders-3.yaml | 165 +++++++++++ 19 files changed, 1452 insertions(+), 3 deletions(-) create mode 100644 .changeset/asyncapi.md create mode 100644 docs/de/asyncapi.md create mode 100644 docs/en/asyncapi.md create mode 100644 docs/ru/asyncapi.md create mode 100644 docs/zh/asyncapi.md create mode 100644 src/asyncapi/index.ts create mode 100644 src/asyncapi/load.ts create mode 100644 src/vue/asyncapi/AsyncApiMessage.vue create mode 100644 src/vue/asyncapi/AsyncApiReference.vue create mode 100644 src/vue/asyncapi/labels.ts create mode 100644 tests/asyncapi.spec.ts create mode 100644 tests/fixtures/asyncapi/orders-2.yaml create mode 100644 tests/fixtures/asyncapi/orders-3.yaml diff --git a/.changeset/asyncapi.md b/.changeset/asyncapi.md new file mode 100644 index 0000000..0e88752 --- /dev/null +++ b/.changeset/asyncapi.md @@ -0,0 +1,5 @@ +--- +'@dskripchenko/openapi': minor +--- + +AsyncAPI: `loadAsyncApi` reads AsyncAPI 2.x and 3.0 into one shape — servers, channels with parameters, operations as send/receive from the application's side (2.x `publish`/`subscribe` turned around), messages with headers, payloads, examples and correlation ids, replies, bindings, traits applied — and `detectSpecKind` tells AsyncAPI and OpenAPI documents apart. `AsyncApiReference` in `@dskripchenko/openapi/vue` shows such a document as a reference page, with the schema view of the OpenAPI reference; payloads in other schema languages are shown as written. diff --git a/README.md b/README.md index 568be30..8bb6386 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Swagger 2.0, OpenAPI 3.0 and OpenAPI 3.1 specs in YAML or JSON: read them, validate them, show them as an API reference with "Try it" and code samples, -and edit them in a visual editor. The core has **no runtime dependencies**; +and edit them in a visual editor. AsyncAPI 2.x and 3.0 documents of +event-driven APIs are read and shown too. The core has **no runtime dependencies**; the Vue components are built on [`@dskripchenko/ui`](https://github.com/dskripchenko/ui). @@ -132,6 +133,7 @@ English and Russian. | Embedding an operation | [en](docs/en/embed.md) | [ru](docs/ru/embed.md) | [de](docs/de/embed.md) | [zh](docs/zh/embed.md) | | API blocks | [en](docs/en/blocks.md) | [ru](docs/ru/blocks.md) | [de](docs/de/blocks.md) | [zh](docs/zh/blocks.md) | | TypeScript types | [en](docs/en/types.md) | [ru](docs/ru/types.md) | [de](docs/de/types.md) | [zh](docs/zh/types.md) | +| AsyncAPI | [en](docs/en/asyncapi.md) | [ru](docs/ru/asyncapi.md) | [de](docs/de/asyncapi.md) | [zh](docs/zh/asyncapi.md) | | OpenApiEditor | [en](docs/en/editor.md) | [ru](docs/ru/editor.md) | [de](docs/de/editor.md) | [zh](docs/zh/editor.md) | | Core API | [en](docs/en/core.md) | [ru](docs/ru/core.md) | [de](docs/de/core.md) | [zh](docs/zh/core.md) | | Code samples | [en](docs/en/snippets.md) | [ru](docs/ru/snippets.md) | [de](docs/de/snippets.md) | [zh](docs/zh/snippets.md) | diff --git a/docs/de/asyncapi.md b/docs/de/asyncapi.md new file mode 100644 index 0000000..05d5fe2 --- /dev/null +++ b/docs/de/asyncapi.md @@ -0,0 +1,60 @@ +# AsyncAPI + +Ereignisgesteuerte APIs, beschrieben mit AsyncAPI 2.x (2.0–2.6) oder 3.0, +werden wie OpenAPI-Spezifikationen gelesen und gezeigt. + +## Auf einer Seite + +```vue + + + +``` + +Die Seite zeigt die Beschreibung des Dokuments und die Server (Protokoll, +Adresse, Variablen), dann die Operationen, gruppiert nach Tag — oder nach +Kanal, wenn keine Operation Tags hat. Jede Operation zeigt, ob die Anwendung +**sendet** oder **empfängt**, die Adresse des Kanals mit hervorgehobenen +`{Parametern}`, die Parameter, jede Nachricht (Schemata von Kopfzeilen und +Nutzlast, Inhaltstyp, Korrelations-ID, ein Beispiel — aus dem Dokument oder +aus den Schemata erzeugt — und Bindings), die Antwort einer +Request/Reply-Operation (3.0) und die Bindings der Operation. Danach folgen +die Nachrichten und Schemata der Komponenten. Schemata werden wie in der +OpenAPI-Referenz dargestellt. + +Eine Nutzlast in einer anderen Schemasprache (Avro, Protobuf, RAML…) wird so +gezeigt, wie sie geschrieben ist, mit ihrem Format. + +| Prop | Typ | Bedeutung | +| ---------- | ------------------------ | -------------------------------------------------- | +| `spec` | `string \| object` | Das Dokument: YAML- oder JSON-Text oder ein geparstes Objekt | +| `locale` | `string` | `en` oder `ru`; standardmäßig die Browsersprache | +| `labels` | `Partial`| Ersetzte Texte | +| `external` | `(url) => unknown` | Lädt Dokumente, auf die `$ref`s per URL verweisen | + +Slots: `actions` (im Kopf) und `footer`. + +## Im Code + +```ts +import { loadAsyncApi, detectSpecKind } from '@dskripchenko/openapi' + +detectSpecKind(parsed) // 'asyncapi' | 'openapi' | null +const spec = loadAsyncApi(text) +spec.operations // [{ id, action: 'send' | 'receive', channel, messages, reply, bindings, … }] +spec.channels // [{ id, address, parameters, messages, servers, … }] +spec.servers // [{ id, url, protocol, variables, … }] +``` + +Beide Versionen werden in eine Form gebracht. AsyncAPI 2.x beschreibt +Operationen aus Sicht des Clients; sie werden wie in 3.0 auf die Sicht der +Anwendung gedreht: Ein Kanal, den die Anwendung „abonniert“ (`subscribe`), ist +einer, auf dem sie **sendet**, `publish` ist **Empfang**. Traits von +Nachrichten und Operationen werden angewendet, `$ref`s aufgelöst, und eine +Nachricht, auf die mehrere Stellen verweisen, ist ein einziges Objekt. +`warnings` nennt, was nicht aufgelöst werden konnte. diff --git a/docs/en/asyncapi.md b/docs/en/asyncapi.md new file mode 100644 index 0000000..56d5dd9 --- /dev/null +++ b/docs/en/asyncapi.md @@ -0,0 +1,58 @@ +# AsyncAPI + +Event-driven APIs described with AsyncAPI 2.x (2.0–2.6) or 3.0 are read and +shown the same way as OpenAPI specs. + +## On a page + +```vue + + + +``` + +The page shows the document's description and servers (protocol, address, +variables), then the operations, grouped by tag — or by channel when no +operation has tags. Each operation shows whether the application **sends** +or **receives**, the channel's address with its `{parameters}` marked, the +parameters, every message (headers and payload schemas, content type, +correlation id, an example — the document's own or one made from the +schemas — and bindings), the reply of a request/reply operation (3.0) and +the operation's bindings. Then come the messages and schemas of the +components. Schemas use the same view as the OpenAPI reference. + +A payload in another schema language (Avro, Protobuf, RAML…) is shown as +written, with its format. + +| Prop | Type | Meaning | +| ---------- | ------------------------ | ----------------------------------------------- | +| `spec` | `string \| object` | The document: YAML or JSON text, or a parsed object | +| `locale` | `string` | `en` or `ru`; the browser's language by default | +| `labels` | `Partial`| Overrides of the texts | +| `external` | `(url) => unknown` | Loads documents that `$ref`s point to by URL | + +Slots: `actions` (the header) and `footer`. + +## In code + +```ts +import { loadAsyncApi, detectSpecKind } from '@dskripchenko/openapi' + +detectSpecKind(parsed) // 'asyncapi' | 'openapi' | null +const spec = loadAsyncApi(text) +spec.operations // [{ id, action: 'send' | 'receive', channel, messages, reply, bindings, … }] +spec.channels // [{ id, address, parameters, messages, servers, … }] +spec.servers // [{ id, url, protocol, variables, … }] +``` + +Both versions are read into one shape. AsyncAPI 2.x describes operations +from the client's side; they are turned around to the application's, as in +3.0: a channel the application `subscribe`s to is one it **sends** on, +`publish` is **receive**. Message and operation traits are applied, `$ref`s +are followed, and a message referred to from several places is one object. +`warnings` lists what could not be resolved. diff --git a/docs/ru/asyncapi.md b/docs/ru/asyncapi.md new file mode 100644 index 0000000..6bf6365 --- /dev/null +++ b/docs/ru/asyncapi.md @@ -0,0 +1,59 @@ +# AsyncAPI + +Событийные API, описанные в AsyncAPI 2.x (2.0–2.6) или 3.0, читаются и +показываются так же, как спецификации OpenAPI. + +## На странице + +```vue + + + +``` + +Страница показывает описание документа и серверы (протокол, адрес, +переменные), затем операции, сгруппированные по тегам — или по каналам, если +тегов нет ни у одной операции. У каждой операции видно, **отправляет** +приложение или **принимает**, адрес канала с выделенными `{параметрами}`, +параметры, каждое сообщение (схемы заголовков и содержимого, тип содержимого, +идентификатор корреляции, пример — из документа или построенный по схемам — и +привязки), ответ операции «запрос–ответ» (3.0) и привязки операции. Дальше +идут сообщения и схемы из компонентов. Схемы показываются тем же видом, что в +справочнике OpenAPI. + +Содержимое на другом языке схем (Avro, Protobuf, RAML…) показывается как +есть, с указанием формата. + +| Свойство | Тип | Значение | +| ---------- | ------------------------ | ------------------------------------------------- | +| `spec` | `string \| object` | Документ: текст YAML или JSON либо разобранный объект | +| `locale` | `string` | `en` или `ru`; по умолчанию — язык браузера | +| `labels` | `Partial`| Замена текстов | +| `external` | `(url) => unknown` | Загружает документы, на которые `$ref` ссылается по адресу | + +Слоты: `actions` (в шапке) и `footer`. + +## В коде + +```ts +import { loadAsyncApi, detectSpecKind } from '@dskripchenko/openapi' + +detectSpecKind(parsed) // 'asyncapi' | 'openapi' | null +const spec = loadAsyncApi(text) +spec.operations // [{ id, action: 'send' | 'receive', channel, messages, reply, bindings, … }] +spec.channels // [{ id, address, parameters, messages, servers, … }] +spec.servers // [{ id, url, protocol, variables, … }] +``` + +Обе версии приводятся к одному виду. AsyncAPI 2.x описывает операции со +стороны клиента; они разворачиваются к стороне приложения, как в 3.0: канал, +на который приложение «подписано» (`subscribe`), — тот, куда оно +**отправляет**, а `publish` — это **приём**. Применяются трейты сообщений и +операций, разворачиваются `$ref`, а сообщение, на которое ссылаются из +нескольких мест, — один и тот же объект. В `warnings` — то, что не удалось +разрешить. diff --git a/docs/zh/asyncapi.md b/docs/zh/asyncapi.md new file mode 100644 index 0000000..7e70336 --- /dev/null +++ b/docs/zh/asyncapi.md @@ -0,0 +1,43 @@ +# AsyncAPI + +使用 AsyncAPI 2.x(2.0–2.6)或 3.0 描述的事件驱动 API,可以像 OpenAPI 规范一样被读取和展示。 + +## 在页面上 + +```vue + + + +``` + +页面会显示文档的描述和服务器(协议、地址、变量),然后是按标签分组的操作——若所有操作都没有标签,则按通道分组。每个操作显示应用是**发送**还是**接收**、标出 `{参数}` 的通道地址、参数、每条消息(头部与负载的 schema、内容类型、关联 ID、示例——来自文档或根据 schema 生成——以及绑定)、请求/响应操作的回复(3.0)以及操作的绑定。随后是组件中的消息和 schema。schema 使用与 OpenAPI 参考相同的视图展示。 + +使用其他 schema 语言(Avro、Protobuf、RAML 等)的负载按原样显示,并注明其格式。 + +| 属性 | 类型 | 含义 | +| ---------- | ------------------------ | ---------------------------------------- | +| `spec` | `string \| object` | 文档:YAML 或 JSON 文本,或已解析的对象 | +| `locale` | `string` | `en` 或 `ru`;默认使用浏览器语言 | +| `labels` | `Partial`| 覆盖界面文本 | +| `external` | `(url) => unknown` | 加载 `$ref` 通过 URL 引用的文档 | + +插槽:`actions`(页眉)和 `footer`。 + +## 在代码中 + +```ts +import { loadAsyncApi, detectSpecKind } from '@dskripchenko/openapi' + +detectSpecKind(parsed) // 'asyncapi' | 'openapi' | null +const spec = loadAsyncApi(text) +spec.operations // [{ id, action: 'send' | 'receive', channel, messages, reply, bindings, … }] +spec.channels // [{ id, address, parameters, messages, servers, … }] +spec.servers // [{ id, url, protocol, variables, … }] +``` + +两个版本会被统一为同一种结构。AsyncAPI 2.x 从客户端的角度描述操作;这里会像 3.0 那样转换为应用的视角:应用 `subscribe` 的通道是它**发送**消息的通道,`publish` 则表示**接收**。会应用消息和操作的 traits,解析 `$ref`,被多处引用的消息是同一个对象。`warnings` 列出无法解析的内容。 diff --git a/playground/main.ts b/playground/main.ts index 1af007e..d6495b4 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -1,11 +1,13 @@ import { createApp, h, ref } from 'vue' import '@dskripchenko/ui/styles/all.css' -import { OpenApiBlock, OpenApiEditor, OpenApiOperation, OpenApiReference } from '../src/vue' +import { AsyncApiReference, OpenApiBlock, OpenApiEditor, OpenApiOperation, OpenApiReference } from '../src/vue' import '../src/vue/openapi.css' import petstore from '../tests/fixtures/specs/petstore-30.yaml?raw' import kitchen from '../tests/fixtures/specs/kitchen-sink-31.json?raw' import swagger from '../tests/fixtures/specs/petstore-swagger2.json?raw' import oauth from '../tests/fixtures/specs/oauth-31.json?raw' +import asyncOrders3 from '../tests/fixtures/asyncapi/orders-3.yaml?raw' +import asyncOrders2 from '../tests/fixtures/asyncapi/orders-2.yaml?raw' const specs: Record = { petstore, kitchen, swagger, oauth, empty: '' } @@ -78,7 +80,10 @@ createApp({ spacer ? h('div', { class: 'playground-spacer', style: `height: ${spacer}px` }) : null, h('div', { class: 'playground-box', style: boxStyle }, [ // ?mode=embed&op=createOrder: one operation in an article, as OpenApiOperation. - mode === 'block' + // ?mode=asyncapi&spec=orders2: an AsyncAPI document (3.0 by default). + mode === 'asyncapi' + ? h(AsyncApiReference, { spec: params.get('spec') === 'orders2' ? asyncOrders2 : asyncOrders3, locale }) + : mode === 'block' ? h('article', { class: 'playground-article', style: 'max-width: 980px; margin: 0 auto' }, [ h('h1', { style: 'font: 600 22px/1.3 var(--uid-font-family-sans); color: var(--uid-text-primary)' }, 'A page with an API block'), h(OpenApiBlock, { diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs index a619ddd..054b245 100644 --- a/scripts/browser-check.mjs +++ b/scripts/browser-check.mjs @@ -983,6 +983,31 @@ try { check('embed (Vue) and print: no page errors', evErrors.length === 0, evErrors.join('; ')) await ev.close() + // AsyncAPI: operations with their channels, messages, reply and schemas; both versions. + const aa = await browser.newPage({ viewport: { width: 1440, height: 1200 } }) + const aaErrors = [] + aa.on('pageerror', (e) => aaErrors.push(String(e))) + await aa.goto(`${base}?mode=asyncapi`) + await aa.locator('.oa-async-op').first().waitFor() + check('asyncapi 3.0: every operation, send and receive', (await aa.locator('.oa-async-op').count()) === 3 && (await aa.locator('.oa-async-op .oa-action--receive').count()) === 1) + check('asyncapi 3.0: channel parameters marked in the address', (await aa.locator('[data-operation="publishOrderPlaced"] .oa-async__param').innerText()) === '{region}') + check('asyncapi 3.0: payload schema resolved through $ref', (await aa.locator('[data-operation="publishOrderPlaced"] .oa-async-message').first().innerText()).includes('items')) + check('asyncapi 3.0: reply with its messages', (await aa.locator('[data-operation="requestPayment"] .oa-async-reply [data-message="PaymentResult"]').count()) === 1) + check('asyncapi 3.0: Avro payload shown as written', (await aa.locator('[id="message/PaymentResult"]').innerText()).toLowerCase().includes('vnd.apache.avro')) + await aa.locator('.oa-nav__op', { hasText: 'Item' }).click() + // Smooth scrolling takes a moment. + await aa.waitForTimeout(1200) + check('asyncapi: navigation scrolls to a schema', await aa.locator('[id="schema/Item"]').evaluate((el) => el.getBoundingClientRect().top < window.innerHeight)) + await aa.screenshot({ path: resolve(out, 'asyncapi-3--light.png') }) + await aa.goto(`${base}?mode=asyncapi&spec=orders2&theme=dark&locale=ru`) + await aa.locator('.oa-async-op').first().waitFor() + check('asyncapi 2.6: publish and subscribe as receive and send', (await aa.locator('.oa-async-op .oa-action--send').count()) === 1 && (await aa.locator('.oa-async-op .oa-action--receive').count()) === 1) + check('asyncapi 2.6: oneOf messages', (await aa.locator('[data-operation="payments/commands.publish"] .oa-async-message').count()) === 2) + await aa.screenshot({ path: resolve(out, 'asyncapi-2--dark.png') }) + check('asyncapi: labels in the page language', (await aa.locator('.oa-async-op').first().innerText()).toLowerCase().includes('отправка')) + check('asyncapi: no page errors', aaErrors.length === 0, aaErrors.join('; ')) + await aa.close() + // The editor: visual edits land in the text, the source view reports problems. const ed = await browser.newPage({ viewport: { width: 1440, height: 1200 } }) const edErrors = [] diff --git a/src/asyncapi/index.ts b/src/asyncapi/index.ts new file mode 100644 index 0000000..5d86910 --- /dev/null +++ b/src/asyncapi/index.ts @@ -0,0 +1,15 @@ +export { + loadAsyncApi, + detectSpecKind, + groupAsyncOperations, + type AsyncApiSpec, + type AsyncServer, + type AsyncChannel, + type AsyncOperation, + type AsyncMessage, + type AsyncMessageExample, + type AsyncParameter, + type AsyncTag, + type AsyncAction, + type SpecKind, +} from './load' diff --git a/src/asyncapi/load.ts b/src/asyncapi/load.ts new file mode 100644 index 0000000..c02f009 --- /dev/null +++ b/src/asyncapi/load.ts @@ -0,0 +1,396 @@ +/** + * AsyncAPI 2.x and 3.0, read into one shape. + * + * The two versions describe the same things differently: 2.x hangs + * `publish`/`subscribe` operations under channels keyed by their address, + * 3.0 keeps channels and operations apart and says `send`/`receive` from the + * application's point of view. Both become operations with an `action` + * (2.x `subscribe` → `send`, `publish` → `receive`), channels with an + * address, and messages with their headers, payload and examples. Traits + * are applied; `$ref`s are followed. + */ +import type { OpenApiDocument, Schema } from '../core/types' +import { parseSpec } from '../load' +import { OpenApiError } from '../model/errors' +import { createResolver, type Resolver, type ResolverOptions } from '../model/resolver' +import { isObject, isRef, type Obj } from '../model/util' + +export interface AsyncTag { + name: string + description?: string +} + +export interface AsyncServer { + id: string + /** `protocol://host/path` as written, variables still in braces. */ + url: string + protocol: string + protocolVersion?: string + title?: string + summary?: string + description?: string + variables: { name: string; default?: string; enum?: string[]; description?: string }[] + security: unknown[] + tags: AsyncTag[] + bindings?: Obj +} + +export interface AsyncParameter { + name: string + description?: string + schema?: Schema + /** Where the value comes from in the message (`$message.payload#/user/id`). */ + location?: string +} + +export interface AsyncMessageExample { + name?: string + summary?: string + headers?: unknown + payload?: unknown +} + +export interface AsyncMessage { + /** The component name, or a made-up one for an inline message. */ + key: string + name?: string + title?: string + summary?: string + description?: string + contentType?: string + /** A JSON Schema payload (AsyncAPI schemas are JSON Schema). */ + payload?: Schema + /** A payload in another schema language (Avro, Protobuf, RAML…), as written, with its format. */ + foreignPayload?: { format: string; schema: unknown } + headers?: Schema + examples: AsyncMessageExample[] + correlationId?: { location?: string; description?: string } + tags: AsyncTag[] + bindings?: Obj + deprecated?: boolean +} + +export interface AsyncChannel { + id: string + /** The address with its `{parameters}`; null in 3.0 for a dynamic channel. */ + address: string | null + title?: string + summary?: string + description?: string + parameters: AsyncParameter[] + messages: AsyncMessage[] + /** Server ids the channel is available on; all of them when empty. */ + servers: string[] + tags: AsyncTag[] + bindings?: Obj +} + +export type AsyncAction = 'send' | 'receive' + +export interface AsyncOperation { + id: string + action: AsyncAction + /** The channel's id. */ + channel: string + title?: string + summary?: string + description?: string + tags: AsyncTag[] + messages: AsyncMessage[] + security: unknown[] + bindings?: Obj + deprecated?: boolean + /** AsyncAPI 3.0 request/reply. */ + reply?: { address?: string; channel?: string; messages: AsyncMessage[] } +} + +export interface AsyncApiSpec { + /** `2.6.0`, `3.0.0`… */ + version: string + info: Obj & { title: string; version: string; description?: string } + defaultContentType?: string + servers: AsyncServer[] + channels: AsyncChannel[] + operations: AsyncOperation[] + /** Messages defined under `components.messages`. */ + messages: AsyncMessage[] + /** Names of `components.schemas`. */ + schemas: string[] + tags: AsyncTag[] + /** The document as given. */ + raw: Obj + resolver: Resolver + warnings: string[] +} + +export type SpecKind = 'openapi' | 'asyncapi' + +/** Whether a parsed document is OpenAPI/Swagger or AsyncAPI; null for neither. */ +export function detectSpecKind(raw: unknown): SpecKind | null { + if (!isObject(raw)) return null + if (raw.asyncapi !== undefined) return 'asyncapi' + if (raw.openapi !== undefined || raw.swagger !== undefined) return 'openapi' + return null +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) +const obj = (v: unknown): Obj | undefined => (isObject(v) ? v : undefined) +const lastSegment = (ref: string) => decodeURIComponent(ref.slice(ref.lastIndexOf('/') + 1)).replace(/~1/g, '/').replace(/~0/g, '~') + +/** Whether a `schemaFormat` names JSON Schema (or AsyncAPI's superset of it). */ +function isJsonSchemaFormat(format: string | undefined): boolean { + if (!format) return true + return /asyncapi|schema\+json|schema\+yaml|application\/json|application\/yaml/i.test(format) +} + +/** Loads an AsyncAPI document from YAML/JSON text or a parsed object. */ +export function loadAsyncApi(input: string | object, opts: ResolverOptions = {}): AsyncApiSpec { + const raw = typeof input === 'string' ? parseSpec(input).value : input + if (!isObject(raw) || raw.asyncapi === undefined) throw new OpenApiError('Not an AsyncAPI document: no "asyncapi" field found') + const version = String(raw.asyncapi) + const major = /^(\d+)\./.exec(version)?.[1] + if (major !== '2' && major !== '3') throw new OpenApiError(`Unsupported AsyncAPI version "${version}"`) + const resolver = createResolver(raw as unknown as OpenApiDocument, opts) + const warnings: string[] = [] + const deref = (v: unknown): T | undefined => resolver.deref(v as T) + const tagsOf = (v: unknown): AsyncTag[] => + (Array.isArray(v) ? v : []) + .map((t) => deref(t)) + .filter((t): t is Obj => !!t && typeof t.name === 'string') + .map((t) => ({ name: t.name as string, description: str(t.description) })) + + /** Applies traits: their fields fill what the object does not set itself. */ + const withTraits = (value: Obj): Obj => { + const traits = Array.isArray(value.traits) ? value.traits : [] + if (traits.length === 0) return value + const out: Obj = {} + for (const t of traits) { + const trait = deref(t) + if (trait) Object.assign(out, trait) + } + Object.assign(out, value) + delete out.traits + return out + } + + const schemaOf = (v: unknown): Schema | undefined => { + if (v === undefined) return undefined + // Keep refs: the schema component resolves them and shows their names. + return (isRef(v) || isObject(v) ? v : undefined) as Schema | undefined + } + + const messageCache = new Map() + const messageOf = (value: unknown, fallbackKey: string): AsyncMessage | undefined => { + const refKey = isRef(value) ? value.$ref : undefined + if (refKey && messageCache.has(refKey)) return messageCache.get(refKey) + const m0 = deref(value) + if (!m0) { + if (refKey) warnings.push(`The message "${refKey}" cannot be resolved`) + return undefined + } + const m = withTraits(m0) + let payload: Schema | undefined + let foreignPayload: AsyncMessage['foreignPayload'] + // 3.0: a payload may be a multi-format schema `{ schemaFormat, schema }`. + const p = deref(m.payload) + const multi = major === '3' && p && typeof p.schemaFormat === 'string' && 'schema' in p + const format = multi ? (p!.schemaFormat as string) : str(m.schemaFormat) + const body = multi ? p!.schema : m.payload + if (isJsonSchemaFormat(format)) payload = schemaOf(body) + else foreignPayload = { format: format!, schema: deref(body) ?? body } + const headers = deref(m.headers) + const headerSchema = headers && typeof headers.schemaFormat === 'string' && 'schema' in headers ? schemaOf(headers.schema) : schemaOf(m.headers) + const correlation = deref(m.correlationId) + const message: AsyncMessage = { + key: refKey ? lastSegment(refKey) : (str(m.name) ?? str(m.messageId) ?? fallbackKey), + name: str(m.name) ?? str(m.messageId), + title: str(m.title), + summary: str(m.summary), + description: str(m.description), + contentType: str(m.contentType) ?? str(raw.defaultContentType), + payload, + foreignPayload, + headers: headerSchema, + examples: (Array.isArray(m.examples) ? m.examples : []).map((e) => deref(e)).filter((e): e is Obj => !!e).map((e) => ({ name: str(e.name), summary: str(e.summary), headers: e.headers, payload: e.payload })), + correlationId: correlation ? { location: str(correlation.location), description: str(correlation.description) } : undefined, + tags: tagsOf(m.tags), + bindings: obj(deref(m.bindings)), + deprecated: m.deprecated === true ? true : undefined, + } + if (refKey) messageCache.set(refKey, message) + return message + } + + /** 2.x: a message, a `oneOf` of messages, or a ref to either. */ + const messagesOf2 = (value: unknown, key: string): AsyncMessage[] => { + const m = deref(value) + if (!m) return [] + if (Array.isArray(m.oneOf)) return m.oneOf.map((x, i) => messageOf(x, `${key}-${i + 1}`)).filter((x): x is AsyncMessage => !!x) + const one = messageOf(value, key) + return one ? [one] : [] + } + + const parametersOf = (v: unknown): AsyncParameter[] => + Object.entries(obj(deref(v)) ?? {}).map(([name, p0]) => { + const p = deref(p0) ?? {} + let schema = schemaOf(p.schema) + // 3.0 parameters carry the schema's fields themselves. + if (!schema && (p.enum || p.default !== undefined || p.examples)) { + schema = { type: 'string', enum: p.enum as unknown[] | undefined, default: p.default, examples: p.examples as unknown[] | undefined } + } + return { name, description: str(p.description), schema, location: str(p.location) } + }) + + // Servers. + const servers: AsyncServer[] = Object.entries(obj(raw.servers) ?? {}).map(([id, s0]) => { + const s = deref(s0) ?? {} + const protocol = str(s.protocol) ?? '' + const url = major === '3' ? `${protocol ? `${protocol}://` : ''}${str(s.host) ?? ''}${str(s.pathname) ?? ''}` : (str(s.url) ?? '') + return { + id, + url, + protocol, + protocolVersion: str(s.protocolVersion), + title: str(s.title), + summary: str(s.summary), + description: str(s.description), + variables: Object.entries(obj(s.variables) ?? {}).map(([name, v0]) => { + const v = deref(v0) ?? {} + return { name, default: str(v.default), enum: Array.isArray(v.enum) ? v.enum.map(String) : undefined, description: str(v.description) } + }), + security: Array.isArray(s.security) ? s.security : [], + tags: tagsOf(s.tags), + bindings: obj(deref(s.bindings)), + } + }) + + const channels: AsyncChannel[] = [] + const operations: AsyncOperation[] = [] + + if (major === '2') { + for (const [address, c0] of Object.entries(obj(raw.channels) ?? {})) { + const c = deref(c0) ?? {} + const channel: AsyncChannel = { + id: address, + address, + description: str(c.description), + parameters: parametersOf(c.parameters), + messages: [], + servers: Array.isArray(c.servers) ? c.servers.map(String) : [], + tags: [], + bindings: obj(deref(c.bindings)), + } + for (const verb of ['subscribe', 'publish'] as const) { + const o0 = deref(c[verb]) + if (!o0) continue + const o = withTraits(o0) + const id = str(o.operationId) ?? `${address}.${verb}` + const messages = messagesOf2(o.message, `${id}-message`) + for (const m of messages) if (!channel.messages.includes(m)) channel.messages.push(m) + operations.push({ + id, + // 2.x speaks from the client's side: a channel the application + // "subscribes" to is one the application sends on. + action: verb === 'subscribe' ? 'send' : 'receive', + channel: address, + summary: str(o.summary), + description: str(o.description), + tags: tagsOf(o.tags), + messages, + security: Array.isArray(o.security) ? o.security : [], + bindings: obj(deref(o.bindings)), + deprecated: o.deprecated === true ? true : undefined, + }) + } + channels.push(channel) + } + } else { + const channelIdOf = (ref: unknown): string | undefined => (isRef(ref) && ref.$ref.startsWith('#/channels/') ? lastSegment(ref.$ref) : undefined) + for (const [id, c0] of Object.entries(obj(raw.channels) ?? {})) { + const c = deref(c0) ?? {} + channels.push({ + id, + address: c.address === null ? null : (str(c.address) ?? null), + title: str(c.title), + summary: str(c.summary), + description: str(c.description), + parameters: parametersOf(c.parameters), + messages: Object.entries(obj(deref(c.messages)) ?? {}) + .map(([key, m]) => messageOf(m, key)) + .filter((m): m is AsyncMessage => !!m), + servers: (Array.isArray(c.servers) ? c.servers : []).map((s) => (isRef(s) ? lastSegment(s.$ref) : '')).filter(Boolean), + tags: tagsOf(c.tags), + bindings: obj(deref(c.bindings)), + }) + } + for (const [id, o0] of Object.entries(obj(raw.operations) ?? {})) { + const raw0 = deref(o0) + if (!raw0) continue + const o = withTraits(raw0) + const channelId = channelIdOf(o.channel) + const channel = channels.find((c) => c.id === channelId) + if (!channel) warnings.push(`The operation "${id}" refers to a channel that is not in the document`) + const listed = Array.isArray(o.messages) ? o.messages.map((m, i) => messageOf(m, `${id}-message-${i + 1}`)).filter((m): m is AsyncMessage => !!m) : (channel?.messages ?? []) + const reply0 = deref(o.reply) + let reply: AsyncOperation['reply'] + if (reply0) { + const address = deref(reply0.address) + const replyChannel = channelIdOf(reply0.channel) + reply = { + address: str(address?.location), + channel: replyChannel, + messages: Array.isArray(reply0.messages) + ? reply0.messages.map((m, i) => messageOf(m, `${id}-reply-${i + 1}`)).filter((m): m is AsyncMessage => !!m) + : (channels.find((c) => c.id === replyChannel)?.messages ?? []), + } + } + const action = o.action === 'receive' ? 'receive' : 'send' + if (o.action !== 'send' && o.action !== 'receive') warnings.push(`The operation "${id}" has no valid action; "send" is assumed`) + operations.push({ + id, + action, + channel: channelId ?? '', + title: str(o.title), + summary: str(o.summary), + description: str(o.description), + tags: tagsOf(o.tags), + messages: listed, + security: Array.isArray(o.security) ? o.security : [], + bindings: obj(deref(o.bindings)), + deprecated: o.deprecated === true ? true : undefined, + reply, + }) + } + } + + const components = obj(raw.components) ?? {} + const messages = Object.entries(obj(components.messages) ?? {}) + .map(([key]) => messageOf({ $ref: `#/components/messages/${key.replace(/~/g, '~0').replace(/\//g, '~1')}` }, key)) + .filter((m): m is AsyncMessage => !!m) + const info = obj(raw.info) ?? {} + return { + version, + info: { ...info, title: str(info.title) ?? 'AsyncAPI', version: str(info.version) ?? '' }, + defaultContentType: str(raw.defaultContentType), + servers, + channels, + operations, + messages, + schemas: Object.keys(obj(components.schemas) ?? {}), + tags: tagsOf(major === '3' ? info.tags : raw.tags), + raw, + resolver, + warnings, + } +} + +/** Operations grouped by their first tag, or by channel when none has tags. */ +export function groupAsyncOperations(spec: AsyncApiSpec): { name: string; operations: AsyncOperation[] }[] { + const tagged = spec.operations.some((o) => o.tags.length > 0) + const groups = new Map() + for (const o of spec.operations) { + const key = tagged ? (o.tags[0]?.name ?? '') : (spec.channels.find((c) => c.id === o.channel)?.address ?? o.channel) + groups.set(key, [...(groups.get(key) ?? []), o]) + } + return [...groups].map(([name, operations]) => ({ name, operations })) +} diff --git a/src/index.ts b/src/index.ts index 4789b8c..66fb0e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,3 +87,4 @@ export { type OperationTypesOptions, } from './typescript' export * from './blocks' +export * from './asyncapi' diff --git a/src/vue/asyncapi/AsyncApiMessage.vue b/src/vue/asyncapi/AsyncApiMessage.vue new file mode 100644 index 0000000..9f22443 --- /dev/null +++ b/src/vue/asyncapi/AsyncApiMessage.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/vue/asyncapi/AsyncApiReference.vue b/src/vue/asyncapi/AsyncApiReference.vue new file mode 100644 index 0000000..e489f5f --- /dev/null +++ b/src/vue/asyncapi/AsyncApiReference.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/src/vue/asyncapi/labels.ts b/src/vue/asyncapi/labels.ts new file mode 100644 index 0000000..0cc8a7a --- /dev/null +++ b/src/vue/asyncapi/labels.ts @@ -0,0 +1,85 @@ +export interface AsyncApiLabels { + send: string + receive: string + operations: string + channels: string + channel: string + messages: string + message: string + schemas: string + servers: string + parameters: string + headers: string + payload: string + payloadIn: (format: string) => string + example: string + bindings: string + reply: string + replyAddress: string + correlationId: string + contentType: string + protocol: string + variables: string + deprecated: string + loadError: string + noOperations: string + downloadSpec: string +} + +export const asyncEn: AsyncApiLabels = { + send: 'Send', + receive: 'Receive', + operations: 'Operations', + channels: 'Channels', + channel: 'Channel', + messages: 'Messages', + message: 'Message', + schemas: 'Schemas', + servers: 'Servers', + parameters: 'Parameters', + headers: 'Headers', + payload: 'Payload', + payloadIn: (format) => `Payload (${format})`, + example: 'Example', + bindings: 'Bindings', + reply: 'Reply', + replyAddress: 'Reply address', + correlationId: 'Correlation id', + contentType: 'Content type', + protocol: 'Protocol', + variables: 'Variables', + deprecated: 'Deprecated', + loadError: 'The AsyncAPI document cannot be read', + noOperations: 'The document describes no operations', + downloadSpec: 'Download the document', +} + +export const asyncRu: AsyncApiLabels = { + send: 'Отправка', + receive: 'Приём', + operations: 'Операции', + channels: 'Каналы', + channel: 'Канал', + messages: 'Сообщения', + message: 'Сообщение', + schemas: 'Схемы', + servers: 'Серверы', + parameters: 'Параметры', + headers: 'Заголовки', + payload: 'Содержимое', + payloadIn: (format) => `Содержимое (${format})`, + example: 'Пример', + bindings: 'Привязки', + reply: 'Ответ', + replyAddress: 'Адрес ответа', + correlationId: 'Идентификатор корреляции', + contentType: 'Тип содержимого', + protocol: 'Протокол', + variables: 'Переменные', + deprecated: 'Устарело', + loadError: 'Документ AsyncAPI не читается', + noOperations: 'В документе нет операций', + downloadSpec: 'Скачать документ', +} + +export const ASYNC_LOCALES: Record = { en: asyncEn, ru: asyncRu } diff --git a/src/vue/index.ts b/src/vue/index.ts index 252a473..af2de1f 100644 --- a/src/vue/index.ts +++ b/src/vue/index.ts @@ -27,3 +27,5 @@ export { type BlockMountHandle, } from './blocks/mount' export { proxySend } from '../request/proxy' +export { default as AsyncApiReference } from './asyncapi/AsyncApiReference.vue' +export { asyncEn, asyncRu, ASYNC_LOCALES, type AsyncApiLabels } from './asyncapi/labels' diff --git a/src/vue/openapi.css b/src/vue/openapi.css index 56d9521..fc281a7 100644 --- a/src/vue/openapi.css +++ b/src/vue/openapi.css @@ -1373,3 +1373,19 @@ @media (max-width: 480px) { .oa-bk--editable { padding: 2px 10px 10px; } } + +/* AsyncAPI */ +.oa-action { text-transform: uppercase; } +.oa-action--send { color: var(--oa-send, var(--uid-success)); background: var(--oa-send-bg, var(--uid-success-subtle)); } +.oa-action--receive { color: var(--oa-receive, var(--uid-info)); background: var(--oa-receive-bg, var(--uid-info-subtle)); } +.oa-async__param { color: var(--uid-primary); font-weight: 600; } +.oa-async__address { font-size: 13px; word-break: break-all; } +.oa-async__summary { margin: 0 0 8px; color: var(--uid-text-muted); } +.oa-async__meta { font-size: 12px; color: var(--uid-text-muted); } +.oa-async__part { margin-top: 10px; } +.oa-async-message { padding: 12px 14px; border: 1px solid var(--uid-border); border-radius: 8px; margin-top: 12px; } +.oa-async-message__head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.oa-async-message__head .oa-block__title { margin: 0; } +.oa-async-op__body { display: flex; flex-direction: column; gap: 4px; } +.oa-async-reply { padding-left: 12px; border-left: 3px solid var(--uid-border); } +.oa-async-server { margin-bottom: 8px; } diff --git a/tests/asyncapi.spec.ts b/tests/asyncapi.spec.ts new file mode 100644 index 0000000..4d8a00d --- /dev/null +++ b/tests/asyncapi.spec.ts @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs' +import { detectSpecKind, groupAsyncOperations, loadAsyncApi, OpenApiError } from '../src' + +const text = (name: string) => readFileSync(new URL(`./fixtures/asyncapi/${name}`, import.meta.url), 'utf8') + +describe('loadAsyncApi: 3.0', () => { + const spec = loadAsyncApi(text('orders-3.yaml')) + + it('reads info, servers and channels', () => { + expect(spec.version).toBe('3.0.0') + expect(spec.info).toMatchObject({ title: 'Order events', version: '1.2.0' }) + expect(spec.servers.map((s) => [s.id, s.url, s.protocol])).toEqual([ + ['production', 'kafka://broker.example.com:9092', 'kafka'], + ['development', 'kafka://localhost:{port}', 'kafka'], + ]) + expect(spec.servers[1]!.variables).toEqual([{ name: 'port', default: '9092', enum: ['9092', '29092'], description: undefined }]) + const placed = spec.channels.find((c) => c.id === 'orderPlaced')! + expect(placed.address).toBe('orders.{region}.placed') + expect(placed.parameters).toEqual([ + { name: 'region', description: 'Region code of the shop', location: '$message.payload#/region', schema: { type: 'string', enum: ['eu', 'us'], default: 'eu', examples: undefined } }, + ]) + }) + + it('reads operations with their messages, traits and replies', () => { + expect(spec.operations.map((o) => [o.id, o.action, o.channel])).toEqual([ + ['publishOrderPlaced', 'send', 'orderPlaced'], + ['requestPayment', 'send', 'paymentRequests'], + ['onPaymentResult', 'receive', 'paymentReplies'], + ]) + const publish = spec.operations[0]! + expect(publish.bindings).toEqual({ kafka: { clientId: { type: 'string', enum: ['order-service'] } } }) + const message = publish.messages[0]! + expect(message.key).toBe('OrderPlaced') + expect(message.payload).toEqual({ $ref: '#/components/schemas/Order' }) + expect(message.headers).toMatchObject({ type: 'object', properties: { correlationId: { type: 'string' } } }) + expect(message.examples[0]).toMatchObject({ name: 'Small order', payload: { id: 1001 } }) + expect(message.correlationId).toEqual({ location: '$message.header#/correlationId', description: undefined }) + expect(spec.operations[1]!.reply).toMatchObject({ channel: 'paymentReplies', messages: [{ key: 'PaymentResult' }] }) + }) + + it('keeps payloads in other schema languages as they are', () => { + const result = spec.messages.find((m) => m.key === 'PaymentResult')! + expect(result.payload).toBeUndefined() + expect(result.foreignPayload).toMatchObject({ format: 'application/vnd.apache.avro;version=1.9.0', schema: { type: 'record', name: 'PaymentResult' } }) + }) + + it('shares one message object between the places that refer to it', () => { + const a = spec.channels.find((c) => c.id === 'orderPlaced')!.messages[0] + expect(spec.operations[0]!.messages[0]).toBe(a) + expect(spec.messages.find((m) => m.key === 'OrderPlaced')).toBe(a) + }) + + it('groups operations by tag', () => { + expect(groupAsyncOperations(spec).map((g) => [g.name, g.operations.map((o) => o.id)])).toEqual([ + ['orders', ['publishOrderPlaced']], + ['payments', ['requestPayment', 'onPaymentResult']], + ]) + }) +}) + +describe('loadAsyncApi: 2.x', () => { + const spec = loadAsyncApi(text('orders-2.yaml')) + + it('turns publish/subscribe into receive/send from the application side', () => { + expect(spec.operations.map((o) => [o.id, o.action, o.channel])).toEqual([ + ['publishOrderPlaced', 'send', 'orders/{region}/placed'], + ['payments/commands.publish', 'receive', 'payments/commands'], + ]) + expect(spec.servers[0]).toMatchObject({ url: 'broker.example.com:9092', protocol: 'kafka' }) + }) + + it('reads oneOf messages and applies message traits', () => { + const messages = spec.operations[1]!.messages + expect(messages.map((m) => m.key)).toEqual(['CancelOrder', 'RetryPayment']) + expect(messages[0]!.contentType).toBe('application/json') + expect(spec.channels[0]!.parameters[0]).toMatchObject({ name: 'region', schema: { enum: ['eu', 'us'] } }) + }) +}) + +describe('detecting and refusing', () => { + it('tells OpenAPI and AsyncAPI apart', () => { + expect(detectSpecKind({ asyncapi: '3.0.0' })).toBe('asyncapi') + expect(detectSpecKind({ openapi: '3.1.0' })).toBe('openapi') + expect(detectSpecKind({ swagger: '2.0' })).toBe('openapi') + expect(detectSpecKind([])).toBeNull() + }) + + it('refuses other documents and versions', () => { + expect(() => loadAsyncApi('openapi: 3.1.0')).toThrow(OpenApiError) + expect(() => loadAsyncApi({ asyncapi: '1.2.0' })).toThrow(/Unsupported AsyncAPI version/) + }) + + it('reports what it cannot resolve', () => { + const spec = loadAsyncApi({ + asyncapi: '3.0.0', + info: { title: 'x', version: '1' }, + operations: { lost: { action: 'send', channel: { $ref: '#/channels/missing' }, messages: [{ $ref: '#/components/messages/Nope' }] } }, + }) + expect(spec.warnings).toEqual(['The operation "lost" refers to a channel that is not in the document', 'The message "#/components/messages/Nope" cannot be resolved']) + }) +}) diff --git a/tests/fixtures/asyncapi/orders-2.yaml b/tests/fixtures/asyncapi/orders-2.yaml new file mode 100644 index 0000000..250b402 --- /dev/null +++ b/tests/fixtures/asyncapi/orders-2.yaml @@ -0,0 +1,61 @@ +asyncapi: 2.6.0 +info: + title: Order events + version: 1.1.0 +servers: + production: + url: broker.example.com:9092 + protocol: kafka +channels: + orders/{region}/placed: + description: Orders placed in a region + parameters: + region: + description: Region code + schema: + type: string + enum: [eu, us] + subscribe: + operationId: publishOrderPlaced + summary: An order was placed + tags: + - name: orders + message: + $ref: '#/components/messages/OrderPlaced' + payments/commands: + publish: + summary: Commands for the order service + tags: + - name: payments + message: + oneOf: + - $ref: '#/components/messages/CancelOrder' + - name: RetryPayment + payload: + type: object + properties: + orderId: + type: integer +components: + messages: + OrderPlaced: + name: OrderPlaced + payload: + type: object + properties: + id: + type: integer + CancelOrder: + name: CancelOrder + traits: + - $ref: '#/components/messageTraits/json' + payload: + type: object + properties: + orderId: + type: integer + reason: + type: string + messageTraits: + json: + contentType: application/json diff --git a/tests/fixtures/asyncapi/orders-3.yaml b/tests/fixtures/asyncapi/orders-3.yaml new file mode 100644 index 0000000..a3b6082 --- /dev/null +++ b/tests/fixtures/asyncapi/orders-3.yaml @@ -0,0 +1,165 @@ +asyncapi: 3.0.0 +info: + title: Order events + version: 1.2.0 + description: | + Events the **order service** publishes and the commands it listens to. + tags: + - name: orders + description: Order lifecycle + - name: payments +defaultContentType: application/json +servers: + production: + host: broker.example.com:9092 + protocol: kafka + protocolVersion: '3.6' + description: Production Kafka cluster + security: + - $ref: '#/components/securitySchemes/sasl' + development: + host: localhost:{port} + protocol: kafka + variables: + port: + default: '9092' + enum: ['9092', '29092'] +channels: + orderPlaced: + address: orders.{region}.placed + title: Order placed + messages: + orderPlaced: + $ref: '#/components/messages/OrderPlaced' + parameters: + region: + description: Region code of the shop + enum: [eu, us] + default: eu + location: $message.payload#/region + paymentRequests: + address: payments.requests + messages: + paymentRequested: + $ref: '#/components/messages/PaymentRequested' + paymentReplies: + address: payments.replies + messages: + paymentResult: + $ref: '#/components/messages/PaymentResult' +operations: + publishOrderPlaced: + action: send + channel: + $ref: '#/channels/orderPlaced' + summary: An order was placed + tags: + - name: orders + traits: + - $ref: '#/components/operationTraits/kafka' + requestPayment: + action: send + channel: + $ref: '#/channels/paymentRequests' + summary: Ask the payment service to charge an order + tags: + - name: payments + reply: + channel: + $ref: '#/channels/paymentReplies' + onPaymentResult: + action: receive + channel: + $ref: '#/channels/paymentReplies' + summary: Payment finished + tags: + - name: payments +components: + messages: + OrderPlaced: + name: OrderPlaced + title: Order placed + contentType: application/json + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: '#/components/schemas/Order' + correlationId: + location: $message.header#/correlationId + examples: + - name: Small order + headers: + correlationId: 6f1e + payload: + id: 1001 + region: eu + total: 42.5 + items: + - sku: BOOK-1 + quantity: 2 + PaymentRequested: + name: PaymentRequested + payload: + type: object + required: [orderId, amount] + properties: + orderId: + type: integer + amount: + type: number + minimum: 0 + PaymentResult: + name: PaymentResult + payload: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + schema: + type: record + name: PaymentResult + fields: + - name: orderId + type: long + - name: ok + type: boolean + schemas: + Order: + type: object + required: [id, region, total] + properties: + id: + type: integer + description: Order number + region: + type: string + enum: [eu, us] + total: + type: number + items: + type: array + items: + $ref: '#/components/schemas/Item' + Item: + type: object + properties: + sku: + type: string + quantity: + type: integer + minimum: 1 + messageTraits: + commonHeaders: + headers: + type: object + properties: + correlationId: + type: string + description: Ties a reply to its request + operationTraits: + kafka: + bindings: + kafka: + clientId: + type: string + enum: [order-service] + securitySchemes: + sasl: + type: scramSha256