@tertiumorganum/typespec-amqp-ws 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -282,10 +282,6 @@ model M { payload: MPayload; }
282
282
  - `ws-discriminator.tsp` — WebSocket с literal-дискриминатором.
283
283
  - `ws-reply.tsp` — WebSocket с request/reply.
284
284
 
285
- ## Контрибьюции
286
-
287
- Pull-requests и issues приветствуются. Это утилита под конкретный воркфлоу; решения по новым фичам принимаются исходя из того, насколько они вписываются в принцип "узкий набор фич для типичных кейсов".
288
-
289
285
  ## Лицензия
290
286
 
291
287
  [MIT](LICENSE).
package/docs/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Документация `typespec-amqp-ws`
2
+
3
+ Эмиттер TypeSpec для генерации AsyncAPI 3.0 спецификаций.
4
+
5
+ ## Содержание
6
+
7
+ - [usage.md](usage.md) — Установка, конфигурация, синтаксис, поддерживаемые типы, типичные сценарии использования.
8
+ - [architecture.md](architecture.md) — Внутреннее устройство эмиттера: модули, поток данных, принципы преобразования TypeSpec в JSON Schema, особенности реализации.
9
+
10
+ ## TL;DR
11
+
12
+ `typespec-amqp-ws` — это TypeSpec-эмиттер, который превращает декларативное описание сервиса на TypeSpec в YAML-файл AsyncAPI 3.0. Поддерживает два транспорта:
13
+
14
+ - **AMQP (RabbitMQ)** — publish в exchange, consume из очереди, exchange-типы `direct` и `fanout`.
15
+ - **WebSocket** — единый канал `/`, дискриминатор сообщений через literal-типы, request/reply, бинарные сообщения через `contentType: application/octet-stream`.
16
+
17
+ Эмиттер задуман с осознанным **ограниченным** объёмом фич: только то, что реально нужно для микросервисов команды. Не Kafka, не MQTT, не security schemes, не topic-exchanges, не numeric enums. Из-за этого его реализация и API проще, чем у универсальных AsyncAPI-эмиттеров.
18
+
19
+ ## Минимальный пример
20
+
21
+ ```typespec
22
+ import "typespec-amqp-ws";
23
+
24
+ using TspAsyncApi;
25
+ using TspAsyncApi.Amqp;
26
+
27
+ @service(#{ title: "My Service" })
28
+ @info(#{ version: "1.0.0" })
29
+ @server("rabbit", #{ host: "localhost:5672", protocol: "amqp" })
30
+ namespace MyService;
31
+
32
+ model Event {
33
+ id: string;
34
+ payload: string;
35
+ }
36
+
37
+ @publish(#{
38
+ routingKey: "events.created",
39
+ exchange: #{ name: "events", type: "direct", durable: true },
40
+ })
41
+ op sendEvent(): Event;
42
+ ```
43
+
44
+ После `tsp compile .` получите валидный `asyncapi.yaml`, который скармливается стандартным инструментам — `modelina` для генерации Go/TS-моделей, `redocly` для HTML-документации.
45
+
46
+ ## Лицензия
47
+
48
+ [MIT](../LICENSE).
@@ -0,0 +1,319 @@
1
+ # Архитектура `typespec-amqp-ws`
2
+
3
+ Этот документ — для тех, кто разрабатывает или модифицирует эмиттер. Описывает внутреннее устройство, ключевые архитектурные решения и точки расширения.
4
+
5
+ ## Общая картина
6
+
7
+ ```
8
+ TypeSpec sources (.tsp)
9
+
10
+ ▼ tsp compile
11
+ TypeSpec compiler AST + decorator state map
12
+
13
+ ▼ $onEmit($context)
14
+ ┌────────────────────────────────────────────────┐
15
+ │ emitAsyncApi(context, target) │
16
+ │ ├─ buildServiceInfo (info + servers) │
17
+ │ ├─ SchemaBuilder (модели/enum/scalars) │
18
+ │ ├─ buildAmqp либо buildWs │
19
+ │ │ (channels + operations + messages) │
20
+ │ └─ serialize → YAML/JSON │
21
+ └────────────────────────────────────────────────┘
22
+
23
+ ▼ writeFile
24
+ asyncapi.yaml (или .json)
25
+ ```
26
+
27
+ Эмиттер построен на **`@typespec/asset-emitter`** — тот же фреймворк, на котором работают официальные `@typespec/openapi3` и `@typespec/json-schema`. Это даёт нам стандартные механизмы для разрешения `$ref`-ссылок, именования схем, обхода типов компилятора.
28
+
29
+ ## Структура пакета
30
+
31
+ ```
32
+ asyncapi/
33
+ ├── package.json # exports: ".", "./amqp", "./ws", "./testing"
34
+ ├── lib/
35
+ │ ├── main.tsp # точка входа (импортируется при `import "typespec-amqp-ws"`)
36
+ │ ├── amqp.tsp # объявления декораторов TspAsyncApi.Amqp
37
+ │ └── ws.tsp # объявления декораторов TspAsyncApi.WebSocket
38
+ ├── src/
39
+ │ ├── index.ts # корневой entry: экспортирует $lib
40
+ │ ├── shared/
41
+ │ │ ├── lib.ts # createTypeSpecLibrary, диагностики, опции
42
+ │ │ ├── options.ts # JSON Schema опций эмиттера
43
+ │ │ ├── state.ts # Symbol-ключи для program.stateMap
44
+ │ │ ├── document.ts # типы AsyncApiDoc, AsyncApiOperation и т.п.
45
+ │ │ ├── schema-emitter.ts # SchemaBuilder: TypeSpec → JSON Schema
46
+ │ │ ├── decorators-service.ts # @info, @server (namespace = "TspAsyncApi")
47
+ │ │ ├── asyncapi-emitter.ts # оркестратор emitAsyncApi()
48
+ │ │ └── yaml-writer.ts # сериализация YAML/JSON
49
+ │ ├── amqp/
50
+ │ │ ├── index.ts # $onEmit для AMQP-таргета
51
+ │ │ ├── decorators.ts # @publish, @consume, @message (namespace = "TspAsyncApi.Amqp")
52
+ │ │ └── builder.ts # buildAmqp(program, doc)
53
+ │ └── ws/
54
+ │ ├── index.ts # $onEmit для WS-таргета
55
+ │ ├── decorators.ts # @publish, @consume, @reply, @binary, @message (namespace = "TspAsyncApi.WebSocket")
56
+ │ └── builder.ts # buildWs(program, doc)
57
+ └── test/ # vitest-тесты
58
+ ```
59
+
60
+ ## Ключевые решения
61
+
62
+ ### Один пакет — два emit-таргета
63
+
64
+ `typespec-amqp-ws` экспортирует два независимых эмиттера через **sub-path exports** в `package.json`:
65
+
66
+ ```json
67
+ "exports": {
68
+ ".": { ... }, // корневые декораторы (@info, @server)
69
+ "./amqp": { ... }, // $onEmit для AMQP
70
+ "./ws": { ... } // $onEmit для WebSocket
71
+ }
72
+ ```
73
+
74
+ Пользователь подключает один из них в `tspconfig.yaml`:
75
+
76
+ ```yaml
77
+ emit:
78
+ - "typespec-amqp-ws/amqp" # или "/ws"
79
+ ```
80
+
81
+ **Почему так**: каждый сервис должен иметь либо AMQP, либо WebSocket API. Смешивать их в одном `asyncapi.yaml` — концептуально неправильно (разные транспорты, разные паттерны). Два emit-таргета обеспечивают чёткое разделение, при этом весь общий код (схемы, info, servers) живёт в `src/shared/`.
82
+
83
+ Эта возможность (`exports` с sub-paths в TypeSpec-пакете) появилась в TypeSpec 1.0+. До 1.0 пришлось бы делать два отдельных npm-пакета.
84
+
85
+ ### Декораторы — три namespace, три JS-модуля
86
+
87
+ | TypeSpec namespace | JS-модуль (с `export const namespace`) |
88
+ |---|---|
89
+ | `TspAsyncApi` | `src/shared/decorators-service.ts` |
90
+ | `TspAsyncApi.Amqp` | `src/amqp/decorators.ts` |
91
+ | `TspAsyncApi.WebSocket` | `src/ws/decorators.ts` |
92
+
93
+ Каждый JS-модуль с декораторами объявляет `export const namespace = "..."`. Это **обязательное соглашение** TypeSpec — компилятор по этому экспорту узнаёт, к какому namespace относятся `$decoratorName`-функции из этого файла.
94
+
95
+ Все три файла импортируются в `lib/main.tsp` через `import "../dist/src/.../decorators.js"`. Когда пользователь пишет `import "typespec-amqp-ws"`, эта цепочка подгружает декораторы.
96
+
97
+ ### Декораторы только пишут в state, эмиттер только читает
98
+
99
+ Все декораторы устроены одинаково:
100
+
101
+ ```typescript
102
+ export function $publish(context: DecoratorContext, target: Operation, config: PublishConfig): void {
103
+ // (опциональная валидация config)
104
+ context.program.stateMap(AmqpPublishKey).set(target, config);
105
+ }
106
+ ```
107
+
108
+ Они **не** делают эмит, не модифицируют типы, не обращаются к другим декораторам. Это правило TypeSpec — порядок выполнения декораторов относительно других файлов не гарантирован, поэтому декоратор должен только сохранять данные.
109
+
110
+ Эмиттер `$onEmit` затем обходит программу через `navigateProgram` и читает state.
111
+
112
+ ### State-keys через Symbol.for()
113
+
114
+ ```typescript
115
+ // src/shared/state.ts
116
+ export const AmqpPublishKey = Symbol.for("typespec-amqp-ws.amqp.publish");
117
+ export const AmqpConsumeKey = Symbol.for("typespec-amqp-ws.amqp.consume");
118
+ // ...
119
+ ```
120
+
121
+ `Symbol.for(...)` создаёт глобальный symbol — два разных JS-модуля, получающие symbol с одной строкой, получат **тот же** symbol. Это важно, потому что декораторы и эмиттер живут в разных файлах, но обращаются к одному state.
122
+
123
+ ### `@typespec/asset-emitter` vs самописный обход
124
+
125
+ Для большинства схемных типов мы используем **самописный обход** через `navigateProgram`. `@typespec/asset-emitter` (TypeEmitter) обеспечивает только общую инфраструктуру — `$ref` resolution мы делаем вручную через имена.
126
+
127
+ **Почему так**: наш набор типов узкий (запрещены циклические зависимости через `Union`, нет `oneOf`/`anyOf`), и простой `navigateProgram + map имён в namedSchemas` справляется. TypeEmitter добавил бы сложности (lifecycle методы, кэширование), которая для нашего объёма не окупается.
128
+
129
+ ### Запрет анонимных моделей в полях
130
+
131
+ Принципиальное решение: анонимные `{...}`-объекты в полях запрещены. Только именованные `model X`.
132
+
133
+ ```typespec
134
+ // ❌ ошибка эмиттера
135
+ model M {
136
+ payload: { foo: string };
137
+ }
138
+ ```
139
+
140
+ **Почему**: `modelina` (генератор Go/TS моделей) требует осмысленных имён для типов. Авто-генерация имён (`AnonymousSchema1`, и т.п.) ненадёжна и непредсказуема. Запрет на старте проще, чем чинить потом.
141
+
142
+ ### `additionalProperties: false` по умолчанию
143
+
144
+ В отличие от `@typespec/openapi3` (где `additionalProperties` по умолчанию не указан, что эквивалентно "разрешены"), у нас на **каждой** модели объекта по умолчанию `additionalProperties: false`.
145
+
146
+ **Почему**: это соответствует руко­писной AsyncAPI-конвенции команды. "Зачем мне в структуры произвольно добавлять всякую фигню" (с) — лучше быть строгим по умолчанию.
147
+
148
+ ### Узкий белый список типов с диагностиками
149
+
150
+ В `SchemaBuilder.scalarSchema` и связанной логике мы **явно отбрасываем**:
151
+ - Размер-специфичные int (int8/16/32, uint8/16/32) → ошибка `unsupported-sized-int`
152
+ - 64-битные числа → ошибка `unsupported-int64`
153
+ - Float / decimal → ошибка `unsupported-float`
154
+ - Date / time типы → ошибка `unsupported-temporal`
155
+ - URL → ошибка `unsupported-url`
156
+ - `safeint`/`numeric` → ошибка `unsupported-fuzzy-numeric`
157
+
158
+ **Почему**: эмпирически проверено, что modelina и openapi-generator-cli **игнорируют** `format` подсказки и генерируют `int32`/`number`/`string` независимо. Размер-специфичные типы создают ложное ожидание. На границе между языками (C++, Go, TypeScript) переносимо работают только `string`, `boolean`, `integer`, `bytes`. Любой другой числовой тип — мина под кодгеном.
159
+
160
+ ### `allOf`-обёртка для $ref с description
161
+
162
+ Если у поля модели есть `@doc`, а тип — именованный (scalar или другая model), то генерируется:
163
+
164
+ ```yaml
165
+ field:
166
+ allOf:
167
+ - $ref: '#/components/schemas/SomeType'
168
+ description: "пояснение"
169
+ ```
170
+
171
+ **Почему**: JSON Schema запрещает рядом с `$ref` иметь другие keywords. `allOf` — стандартный обход этого ограничения. Это копия поведения `@typespec/openapi3` — гарантирует совместимость с тем же `modelina`.
172
+
173
+ ### Namespace-префикс через `.`
174
+
175
+ ```typespec
176
+ namespace MyService;
177
+ namespace business_event {
178
+ model TokenIssued { ... }
179
+ }
180
+ ```
181
+
182
+
183
+ ```yaml
184
+ components.schemas:
185
+ business_event.TokenIssued: ...
186
+ ```
187
+
188
+ Корневой service-namespace (`MyService`) **не входит** в префикс — он считается "scope" сервиса. Вложенные namespace — входят через `.`.
189
+
190
+ Это копия поведения `@typespec/openapi3` для имён схем (для operationId openapi3 использует `_`, но у AsyncAPI нет operationId как такового — все ключи в `operations:` объекте).
191
+
192
+ ## Поток обработки
193
+
194
+ ### 1. Декораторы пишут state
195
+
196
+ При обходе `.tsp`-файлов компилятор вызывает декораторы. Они записывают конфиги в `program.stateMap(<Key>)`:
197
+
198
+ ```
199
+ @publish(#{...}) → AmqpPublishKey.set(operation, config)
200
+ @consume(#{...}) → AmqpConsumeKey.set(operation, config)
201
+ @reply(M) → WsReplyKey.set(operation, M)
202
+ @binary → WsBinaryKey.set(operation, true)
203
+ @info(#{...}) → InfoKey.set(namespace, config)
204
+ @server(name, ...) → ServerKey.set(namespace, Map<string, ServerConfig>)
205
+ ```
206
+
207
+ ### 2. `$onEmit(context)` запускается компилятором после AST-парсинга
208
+
209
+ `src/amqp/index.ts` (или `src/ws/index.ts`) экспортирует `$onEmit`, который делегирует в `emitAsyncApi(context, "amqp"|"ws")`.
210
+
211
+ ### 3. `emitAsyncApi` собирает документ
212
+
213
+ ```typescript
214
+ const doc: AsyncApiDoc = emptyDoc(); // { asyncapi: "3.0.0", info: ... }
215
+ buildServiceInfo(context, doc); // info + servers
216
+ new SchemaBuilder(program).collect(); // обход моделей/enums/scalars
217
+ // → components.schemas
218
+ buildAmqp(program, doc); // или buildWs // channels + operations + messages
219
+ serialize(doc, opts); // → YAML или JSON
220
+ host.writeFile(path, text); // mkdirp + write
221
+ ```
222
+
223
+ ### 4. SchemaBuilder обход
224
+
225
+ ```typescript
226
+ navigateProgram(program, {
227
+ model: m => this.addModel(m),
228
+ enum: e => this.addEnum(e),
229
+ scalar: s => this.addScalar(s),
230
+ });
231
+ ```
232
+
233
+ Для каждого типа `addX` фильтрует stdlib (`isInLibraryNs`), конструирует JSON Schema-фрагмент, кладёт в `namedSchemas`. Поля модели рекурсивно обрабатываются через `schemaFor(prop.type)`.
234
+
235
+ ### 5. buildAmqp / buildWs
236
+
237
+ ```typescript
238
+ navigateProgram(program, {
239
+ operation(op) {
240
+ const pub = program.stateMap(AmqpPublishKey).get(op);
241
+ const con = program.stateMap(AmqpConsumeKey).get(op);
242
+ if (pub) attachPublish(...);
243
+ else if (con) attachConsume(...);
244
+ },
245
+ });
246
+ ```
247
+
248
+ `attachPublish` собирает channel + operation + message:
249
+
250
+ ```typescript
251
+ doc.channels[channelKey] = {
252
+ address: config.routingKey,
253
+ bindings: { amqp: { is: "routingKey", exchange: cleanExchange(config.exchange) } },
254
+ messages: { [messageKey]: { $ref: ... } },
255
+ };
256
+ doc.operations[op.name] = {
257
+ action: "send",
258
+ channel: { $ref: ... },
259
+ messages: [{ $ref: ... }],
260
+ summary, description,
261
+ };
262
+ doc.components.messages[messageKey] = {
263
+ payload: { $ref: `#/components/schemas/${returnTypeName}` },
264
+ };
265
+ ```
266
+
267
+ `buildWs` устроен похоже, но все операции сворачивает на единый канал `/`, плюс обрабатывает `@reply` и `@binary`.
268
+
269
+ ### 6. Сериализация
270
+
271
+ `yaml.dump(doc, { lineWidth: 120, noRefs: true, sortKeys: false })`. `sortKeys: false` сохраняет порядок вставки полей в JS-объекте — поэтому документ читается в логическом порядке (asyncapi → info → servers → channels → operations → components).
272
+
273
+ ## Тестирование
274
+
275
+ Тесты на `vitest`. Главный харнесс — `test/utils/test-host.ts`:
276
+
277
+ ```typescript
278
+ const result = await emit(`...TypeSpec код...`, "amqp" | "ws");
279
+ expectNoErrors(result);
280
+ expect(result.doc.components.schemas.X).toEqual({...});
281
+ ```
282
+
283
+ Под капотом `emit()` использует `createTestHost` + `createTestWrapper` из `@typespec/compiler/testing`. Все имеющиеся `tsp`-фикстуры компилируются в виртуальной FS, эмиттер пишет туда `asyncapi.yaml`, мы его парсим и инспектим.
284
+
285
+ Snapshot-тесты на нескольких end-to-end фикстурах в `test/fixtures/` (по одному файлу на типичный сценарий: AMQP send+receive, AMQP fanout, WebSocket с дискриминатором, WebSocket с reply и binary) лежат в `test/__snapshots__/fixtures.test.ts.snap` — это контракт регрессии.
286
+
287
+ ## Точки расширения
288
+
289
+ ### Добавить новый AsyncAPI binding (например, Kafka)
290
+
291
+ 1. Создать `src/kafka/` с `decorators.ts` (namespace = "TspAsyncApi.Kafka") и `builder.ts`
292
+ 2. Добавить `lib/kafka.tsp` с extern dec'ами
293
+ 3. Импортировать `lib/kafka.tsp` в `lib/main.tsp`
294
+ 4. Добавить новый emit-таргет: `"./kafka"` в `package.json` exports, `src/kafka/index.ts` с `$onEmit`
295
+ 5. В `emitAsyncApi` (или общем switch) добавить ветку `if (target === "kafka") buildKafka(...)`
296
+ 6. Добавить тесты
297
+
298
+ ### Поддержать новый TypeSpec-тип в схемах
299
+
300
+ В `src/shared/schema-emitter.ts` метод `schemaFor(type)` — это switch по `type.kind`. Добавить ветку и при необходимости — отдельный `addX(...)` для именованных типов.
301
+
302
+ ### Новая диагностика
303
+
304
+ В `src/shared/lib.ts` секция `diagnostics:` — добавить новый код с `severity`, `messages.default` (с `paramMessage` для интерполяции). Использовать в коде через `reportDiagnostic(program, { code, target, format })`.
305
+
306
+ ## Совместимость
307
+
308
+ - `@typespec/compiler` ^1.12.0 — API стабилен (1.0+).
309
+ - `@typespec/asset-emitter` ^0.79.0 — пока pre-1.0, может ломаться в будущем.
310
+ - Тестируется на Node.js 22+ и 24+.
311
+
312
+ Запас совместимости с TypeSpec API минимальный — мы используем `@typespec/compiler` напрямую (типы `Type`, `Model`, `Enum`, `Scalar`, `Operation`, `Namespace`, `Union`). Если они мигрируют — придётся обновлять. Сейчас в 1.x намерение Microsoft — держать API стабильным.
313
+
314
+ ## Известные ограничения
315
+
316
+ - **Циклические зависимости в Union** не поддерживаются (валится в `unionSchema`). На практике для наших сервисов не используются.
317
+ - **Только `T | null` форма Union**. Прочие union'ы — диагностика `unsupported-union`. Если нужны поли­морфные сообщения в будущем — потребуется реализация `oneOf`/`discriminator`.
318
+ - **Реальная FS** — `writeFile` требует чтобы родительская директория существовала (для test-FS она auto-create). Мы делаем `host.mkdirp` перед записью.
319
+ - **Версия AsyncAPI** хардкоднута на `3.0.0`. Для 3.1 нужно поменять одну константу в `document.ts` и проверить, что `modelina`/`redocly` принимают.
package/docs/usage.md ADDED
@@ -0,0 +1,281 @@
1
+ # Использование `@tertiumorganum/typespec-amqp-ws`
2
+
3
+ ## Установка
4
+
5
+ ```bash
6
+ npm install -D @tertiumorganum/typespec-amqp-ws @typespec/compiler @typespec/asset-emitter
7
+ ```
8
+
9
+ Требования:
10
+ - Node.js 22+ (рекомендуется 24+ для совместимости с `@asyncapi/cli`)
11
+ - TypeSpec 1.12+
12
+
13
+ ## Конфигурация
14
+
15
+ В корне папки с TypeSpec-описанием сервиса (например, `<service>/asyncapi/`) создаётся файл `tspconfig.yaml`. Эмиттер `@tertiumorganum/typespec-amqp-ws` имеет два emit-таргета: `/amqp` и `/ws`. Один проект использует **один** из них.
16
+
17
+ ### Конфиг для AMQP-сервиса
18
+
19
+ ```yaml
20
+ emit:
21
+ - "@tertiumorganum/typespec-amqp-ws/amqp"
22
+ options:
23
+ "@tertiumorganum/typespec-amqp-ws/amqp":
24
+ file-type: yaml # yaml (default) или json
25
+ output-file: "asyncapi.yaml"
26
+ new-line: "lf" # lf (default) или crlf
27
+ output-dir: "{project-root}/tsp-output"
28
+ ```
29
+
30
+ ### Конфиг для WebSocket-сервиса
31
+
32
+ ```yaml
33
+ emit:
34
+ - "@tertiumorganum/typespec-amqp-ws/ws"
35
+ options:
36
+ "@tertiumorganum/typespec-amqp-ws/ws":
37
+ file-type: yaml
38
+ output-file: "asyncapi.yaml"
39
+ new-line: "lf"
40
+ output-dir: "{project-root}/tsp-output"
41
+ ```
42
+
43
+ После компиляции (`tsp compile .`) сгенерированный YAML лежит в `tsp-output/@tertiumorganum/typespec-amqp-ws/asyncapi.yaml`. Дальнейший пайплайн (redocly lint, modelina codegen, документация) идёт от этого файла.
44
+
45
+ ## Структура проекта
46
+
47
+ Рекомендованная (соответствует тому, как у нас в команде):
48
+
49
+ ```
50
+ <service>/asyncapi/
51
+ ├── main.tsp # @service / @info / @server + операции
52
+ ├── tsp-components/
53
+ │ └── models.tsp # scalars + enums + модели
54
+ ├── tspconfig.yaml # конфиг эмиттера
55
+ ├── package.json # зависимости (typespec, asset-emitter)
56
+ ├── redocly.yaml # конфиг линтера
57
+ └── Makefile # include шаблонного build pipeline
58
+ ```
59
+
60
+ ## API эмиттера
61
+
62
+ ### Декораторы общего назначения (namespace `TspAsyncApi`)
63
+
64
+ | Декоратор | Применяется к | Что делает |
65
+ |---|---|---|
66
+ | `@service(#{title})` | namespace | Стандартный из `@typespec/compiler`. Маркирует namespace как корневой сервис. |
67
+ | `@info(#{...})` | namespace | Заполняет блок `info:` AsyncAPI. Поля: `version`, `description?`, `contact?{name?, url?, email?}`, `license?{name, url?}`, `externalDocs?{url, description?}` |
68
+ | `@server(name, #{...})` | namespace | Описывает один сервер (брокер). Поля: `host`, `protocol`, `pathname?`, `description?`, `variables?: Record<#{default?, description?, enum?}>` |
69
+
70
+ ### Декораторы AMQP (namespace `TspAsyncApi.Amqp`)
71
+
72
+ | Декоратор | Применяется к | Описание |
73
+ |---|---|---|
74
+ | `@publish(#{...})` | op | Операция-publisher → `action: send`. Поля: `channelName?`, `routingKey?`, `exchange: #{name, type: "direct"\|"fanout", durable?, autoDelete?}` |
75
+ | `@consume(#{...})` | op | Операция-consumer → `action: receive`. Поля: `channelName?`, `routingKey?`, `queue: #{name, durable?, autoDelete?, exclusive?}` |
76
+ | `@message(#{...})` | op | Override параметров сообщения: `name?`, `summary?` |
77
+
78
+ ### Декораторы WebSocket (namespace `TspAsyncApi.WebSocket`)
79
+
80
+ | Декоратор | Применяется к | Описание |
81
+ |---|---|---|
82
+ | `@publish` | op | без аргументов → `action: send` |
83
+ | `@consume` | op | без аргументов → `action: receive` |
84
+ | `@reply(MessageModel)` | op | Модель ответного сообщения (request/reply pattern). Reply-сообщение автоматически добавится в канал и `components.messages`. |
85
+ | `@binary` | op | Помечает сообщение бинарным → `contentType: application/octet-stream` |
86
+ | `@message(#{...})` | op | Override параметров сообщения |
87
+
88
+ ### Стандартные TypeSpec-декораторы
89
+
90
+ Из `@typespec/compiler`:
91
+ - `@doc("...")` — длинное описание (попадает в `description` YAML)
92
+ - `@summary("...")` — короткое summary (попадает в `summary` YAML)
93
+
94
+ ## Поддерживаемые TypeSpec-типы
95
+
96
+ | TypeSpec | YAML | Go / TS / C++ |
97
+ |---|---|---|
98
+ | `string` | `{type: string}` | string / string / std::string |
99
+ | `boolean` | `{type: boolean}` | bool / boolean / bool |
100
+ | `integer` | `{type: integer}` | int32 / number / int |
101
+ | `bytes` | `{type: string, format: binary}` | []byte / Uint8Array / std::vector<uint8_t> |
102
+ | `enum X { A, B }` | `{type: string, enum: [A, B]}` | string-typedef с константами |
103
+ | `scalar X extends string` | `{type: string}` в `components.schemas.X` | именованный string-typedef |
104
+ | `model X { ... }` | `{type: object, properties, required, additionalProperties: false}` | сгенерированная структура |
105
+ | `T[]` | `{type: array, items: <T>}` | slice / array |
106
+ | `Record<T>` | `{type: object, additionalProperties: <T>}` | `map[string]T` / `Record<string, T>` |
107
+ | literal `"foo"` (на поле модели) | `{type: string, const: "foo"}` | const-значение |
108
+ | `T \| null` | `{type: [<base>, null]}` | pointer / nullable |
109
+ | `field?: T` | поле не в `required` | optional |
110
+
111
+ ## Запрещённые типы (ошибка компиляции)
112
+
113
+ Эмиттер намеренно **запрещает**:
114
+
115
+ | Тип | Почему |
116
+ |---|---|
117
+ | `int8`/`int16`/`int32`, `uint8`/`uint16`/`uint32` | Кодгенераторы (`modelina`, `openapi-generator-cli`) **игнорируют** ширину и signed/unsigned, генерируют `int32`/`number`. Размер-специфичные типы создают ложное ожидание сохранения семантики на границе между языками. |
118
+ | `int64`/`uint64` | 64-битные числа должны передаваться как `string` — JavaScript не умеет точно представлять 64-битные числа в JSON. Поясните формат в `@doc`. |
119
+ | `float32`/`float64`/`decimal`/`decimal128` | Числа с плавающей точкой передавайте через `string` во избежание потерь точности на границе между языками. |
120
+ | `safeint`/`numeric` | Неоднозначно для кодогенерации. Используйте `integer`. |
121
+ | `utcDateTime`/`plainDate`/`plainTime`/`duration` | Дата/время — это `string` в RFC-3339 с пояснением в `@doc`. TypeScript-кодген иначе подставляет `Date`, что ломает разбор в разных локалях. |
122
+ | `url` | URL — это `string`. |
123
+
124
+ Эмиттер также **запрещает анонимные inline-модели в полях**:
125
+
126
+ ```typespec
127
+ // ❌ Ошибка эмиттера
128
+ model M {
129
+ payload: { foo: string };
130
+ }
131
+
132
+ // ✅ Только через явно объявленную модель
133
+ model MPayload { foo: string; }
134
+ model M { payload: MPayload; }
135
+ ```
136
+
137
+ Обоснование: детерминированные имена в выводе важнее лаконичности на стороне источника. modelina требует осмысленных имён для генерации Go/TS-типов; авто-генерация ненадёжна.
138
+
139
+ ## Полный пример: AMQP-сервис
140
+
141
+ ```typespec
142
+ import "@tertiumorganum/typespec-amqp-ws";
143
+
144
+ using TspAsyncApi;
145
+ using TspAsyncApi.Amqp;
146
+
147
+ @service(#{ title: "Notifications" })
148
+ @info(#{
149
+ version: "1.0.0",
150
+ description: "Сервис рассылки уведомлений через RabbitMQ",
151
+ })
152
+ @server("rabbit", #{
153
+ host: "rabbit.example.com:5672",
154
+ pathname: "/notifications",
155
+ protocol: "amqp",
156
+ description: "RabbitMQ-сервер",
157
+ })
158
+ namespace Notifications;
159
+
160
+ @doc("Уведомление пользователю")
161
+ model Notification {
162
+ @doc("Идентификатор уведомления")
163
+ notificationId: string;
164
+
165
+ @doc("Текст уведомления")
166
+ text: string;
167
+ }
168
+
169
+ @publish(#{
170
+ routingKey: "notifications.created",
171
+ exchange: #{
172
+ name: "notifications-exchange",
173
+ type: "direct",
174
+ durable: true,
175
+ },
176
+ })
177
+ @summary("Опубликовать новое уведомление")
178
+ op sendNotification(): Notification;
179
+
180
+ @consume(#{
181
+ routingKey: "notifications.acknowledge",
182
+ queue: #{
183
+ name: "notifications-ack-queue",
184
+ durable: true,
185
+ autoDelete: false,
186
+ },
187
+ })
188
+ @summary("Обработать подтверждение доставки")
189
+ op handleAck(): Notification;
190
+ ```
191
+
192
+ ## Полный пример: WebSocket с дискриминатором и reply
193
+
194
+ ```typespec
195
+ import "@tertiumorganum/typespec-amqp-ws";
196
+
197
+ using TspAsyncApi;
198
+ using TspAsyncApi.WebSocket;
199
+
200
+ @service(#{ title: "Chat WS" })
201
+ @info(#{ version: "1.0.0" })
202
+ @server("public", #{
203
+ host: "localhost:{port}",
204
+ protocol: "ws",
205
+ pathname: "/chat",
206
+ variables: #{
207
+ port: #{ `default`: "8080", description: "Порт WS-сервера" },
208
+ },
209
+ })
210
+ namespace Chat;
211
+
212
+ // Дискриминатор сообщения — обычное поле literal-типа.
213
+ // Эмиттер выведет {type: "string", const: "userJoined"} в JSON Schema.
214
+ model UserJoined {
215
+ eventType: "userJoined";
216
+ msgUid: string;
217
+ userId: string;
218
+ nickname: string;
219
+ }
220
+
221
+ model SendMessage {
222
+ eventType: "sendMessage";
223
+ msgUid: string;
224
+ text: string;
225
+ }
226
+
227
+ model SendMessageResponse {
228
+ eventType: "sendMessageResponse";
229
+ msgUid: string;
230
+ ok: boolean;
231
+ }
232
+
233
+ // Receive
234
+ @consume
235
+ @summary("Пользователь подключился к чату")
236
+ op userJoined(): UserJoined;
237
+
238
+ // Send + reply (request/reply pattern)
239
+ @publish
240
+ @reply(SendMessageResponse)
241
+ @summary("Отправить сообщение в чат")
242
+ op sendMessage(): SendMessage;
243
+ ```
244
+
245
+ Все WS-операции автоматически складываются на единый канал `/`. Это типичный паттерн WebSocket-API, где дискриминация сообщений происходит через поле `eventType`.
246
+
247
+ ## Версионирование AsyncAPI
248
+
249
+ Эмиттер генерирует AsyncAPI 3.0.0. Версия 3.1 backward-совместима, но мы остаёмся на 3.0 ради консервативности и максимальной совместимости с `modelina`/`redocly`.
250
+
251
+ ## Дискриминация сообщений в WebSocket
252
+
253
+ В TypeSpec литерал-типы (`"localActions"`) превращаются в JSON Schema `const` — нативный механизм без специальных декораторов. **Любое** поле модели типа `: "значение"` становится `const` в схеме. Поле может называться как угодно — `eventType`, `kind`, `type`, и т.д. Модели без таких полей тоже валидны (например, AMQP-сообщения обычно без дискриминатора).
254
+
255
+ ## Диагностики
256
+
257
+ Эмиттер выдаёт следующие коды (все с префиксом `@tertiumorganum/typespec-amqp-ws/`):
258
+
259
+ - `unsupported-sized-int`, `unsupported-int64`, `unsupported-float`, `unsupported-fuzzy-numeric`, `unsupported-temporal`, `unsupported-url` — попытка использовать запрещённый тип.
260
+ - `anonymous-model`, `anonymous-return` — анонимная inline-модель в поле или return type.
261
+ - `non-string-enum`, `invalid-enum-value` — некорректный enum (numeric или не-идентификаторное значение).
262
+ - `unknown-exchange-type` — `topic` или `headers` exchange (вне scope).
263
+ - `unsupported-union` — union, не являющийся `T | null`.
264
+ - `missing-doc` (warning) — модель/enum без `@doc`.
265
+
266
+ ## Out of scope (v1)
267
+
268
+ Намеренно **не реализовано** в v1 — добавляется по запросу при реальной потребности:
269
+
270
+ - Транспорты Kafka, MQTT, HTTP/SSE, SNS/SQS.
271
+ - Exchange types `topic`, `headers`.
272
+ - AsyncAPI security schemes.
273
+ - `correlationId`.
274
+ - Traits (`channelTraits`, `operationTraits`, `messageTraits`).
275
+ - AsyncAPI extensions (`x-` properties).
276
+ - Polymorphism: пользовательские `oneOf`/`anyOf`/`allOf` (внутренний `allOf` для $ref+description — используется автоматически).
277
+ - TypeSpec `@versioned` интеграция.
278
+ - Numeric-валуированные enum.
279
+ - `@tag` на operations.
280
+ - AsyncAPI 3.1.
281
+ - JSON output (только YAML).
@@ -0,0 +1,26 @@
1
+ import "typespec-amqp-ws";
2
+
3
+ using TspAsyncApi;
4
+ using TspAsyncApi.Amqp;
5
+
6
+ @service(#{ title: "Notifications Consumer" })
7
+ @info(#{ version: "1.0.0" })
8
+ @server("rabbit", #{ host: "localhost:5672", protocol: "amqp" })
9
+ namespace NotificationsConsumer;
10
+
11
+ @doc("Уведомление пользователю")
12
+ model Notification {
13
+ notificationId: string;
14
+ text: string;
15
+ }
16
+
17
+ @consume(#{
18
+ routingKey: "notifications.created",
19
+ queue: #{
20
+ name: "notifications-consumer-queue",
21
+ durable: true,
22
+ autoDelete: false,
23
+ },
24
+ })
25
+ @summary("Обработать уведомление из очереди")
26
+ op handleNotification(): Notification;
@@ -0,0 +1,25 @@
1
+ import "typespec-amqp-ws";
2
+
3
+ using TspAsyncApi;
4
+ using TspAsyncApi.Amqp;
5
+
6
+ @service(#{ title: "Notifications Producer" })
7
+ @info(#{ version: "1.0.0" })
8
+ @server("rabbit", #{ host: "localhost:5672", protocol: "amqp" })
9
+ namespace NotificationsProducer;
10
+
11
+ @doc("Уведомление пользователю")
12
+ model Notification {
13
+ @doc("Уникальный идентификатор уведомления")
14
+ notificationId: string;
15
+
16
+ @doc("Текст уведомления")
17
+ text: string;
18
+ }
19
+
20
+ @publish(#{
21
+ routingKey: "notifications.created",
22
+ exchange: #{ name: "notifications-exchange", type: "direct", durable: true },
23
+ })
24
+ @summary("Опубликовать новое уведомление")
25
+ op sendNotification(): Notification;
@@ -0,0 +1,26 @@
1
+ import "typespec-amqp-ws";
2
+
3
+ using TspAsyncApi;
4
+ using TspAsyncApi.WebSocket;
5
+
6
+ @service(#{ title: "Chat WS" })
7
+ @info(#{ version: "1.0.0" })
8
+ @server("public", #{ host: "localhost:8080", protocol: "ws", pathname: "/ws" })
9
+ namespace Chat;
10
+
11
+ model UserJoinedPayload { userId: string; nickname: string; }
12
+ model MessagePayload { from: string; text: string; }
13
+
14
+ // Дискриминатор через literal-тип — никаких специальных декораторов.
15
+ model UserJoined {
16
+ eventType: "userJoined";
17
+ payload: UserJoinedPayload;
18
+ }
19
+
20
+ model NewMessage {
21
+ eventType: "newMessage";
22
+ payload: MessagePayload;
23
+ }
24
+
25
+ @consume op userJoined(): UserJoined;
26
+ @consume op newMessage(): NewMessage;
@@ -0,0 +1,29 @@
1
+ import "typespec-amqp-ws";
2
+
3
+ using TspAsyncApi;
4
+ using TspAsyncApi.WebSocket;
5
+
6
+ @service(#{ title: "Auth WS" })
7
+ @info(#{ version: "1.0.0" })
8
+ @server("public", #{ host: "localhost:8080", protocol: "ws", pathname: "/ws" })
9
+ namespace Auth;
10
+
11
+ model LoginRequestPayload { username: string; password: string; }
12
+ model LoginResponsePayload { ok: boolean; token?: string; }
13
+
14
+ model LoginRequest {
15
+ eventType: "loginRequest";
16
+ msgUid: string;
17
+ payload: LoginRequestPayload;
18
+ }
19
+
20
+ model LoginResponse {
21
+ eventType: "loginResponse";
22
+ msgUid: string;
23
+ payload: LoginResponsePayload;
24
+ }
25
+
26
+ @publish
27
+ @reply(LoginResponse)
28
+ @summary("Запрос на аутентификацию")
29
+ op login(): LoginRequest;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tertiumorganum/typespec-amqp-ws",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "TypeSpec emitter for AsyncAPI 3.0 covering AMQP (RabbitMQ) and WebSocket transports",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,6 +51,8 @@
51
51
  "files": [
52
52
  "dist/src/**",
53
53
  "lib/**",
54
+ "docs/**",
55
+ "examples/**",
54
56
  "README.md",
55
57
  "LICENSE"
56
58
  ]