@grest-ts/asyncapi 0.0.24

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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Minimal AsyncAPI 3.0 type definitions.
3
+ * Only the subset used by @grest-ts/asyncapi — enough for correct spec generation.
4
+ */
5
+
6
+ export interface AsyncAPIDocument {
7
+ asyncapi: "3.0.0";
8
+ info: InfoObject;
9
+ servers?: Record<string, ServerObject>;
10
+ channels: Record<string, ChannelObject>;
11
+ operations: Record<string, OperationObject>;
12
+ components?: ComponentsObject;
13
+ }
14
+
15
+ export interface InfoObject {
16
+ title: string;
17
+ version: string;
18
+ description?: string;
19
+ }
20
+
21
+ export interface ServerObject {
22
+ host: string;
23
+ protocol: "ws" | "wss" | "http" | "https";
24
+ description?: string;
25
+ }
26
+
27
+ export interface ChannelObject {
28
+ address: string;
29
+ description?: string;
30
+ title?: string;
31
+ messages?: Record<string, MessageObject | ReferenceObject>;
32
+ bindings?: ChannelBindingsObject;
33
+ }
34
+
35
+ export interface ChannelBindingsObject {
36
+ ws?: WsChannelBinding;
37
+ }
38
+
39
+ export interface WsChannelBinding {
40
+ method?: "GET" | "POST";
41
+ headers?: SchemaObject;
42
+ bindingVersion?: string;
43
+ }
44
+
45
+ export interface OperationObject {
46
+ action: "send" | "receive";
47
+ channel: ReferenceObject;
48
+ title?: string;
49
+ summary?: string;
50
+ description?: string;
51
+ messages?: ReferenceObject[];
52
+ reply?: OperationReplyObject;
53
+ security?: SecurityRequirementObject[];
54
+ }
55
+
56
+ export interface OperationReplyObject {
57
+ channel?: ReferenceObject;
58
+ messages?: ReferenceObject[];
59
+ }
60
+
61
+ export interface MessageObject {
62
+ name?: string;
63
+ title?: string;
64
+ summary?: string;
65
+ description?: string;
66
+ payload?: SchemaObject | ReferenceObject;
67
+ headers?: SchemaObject;
68
+ contentType?: string;
69
+ }
70
+
71
+ export interface ComponentsObject {
72
+ schemas?: Record<string, SchemaObject>;
73
+ messages?: Record<string, MessageObject>;
74
+ securitySchemes?: Record<string, SecuritySchemeObject>;
75
+ }
76
+
77
+ export interface SchemaObject {
78
+ type?: string | string[];
79
+ properties?: Record<string, SchemaObject | ReferenceObject>;
80
+ required?: string[];
81
+ additionalProperties?: boolean | SchemaObject;
82
+ items?: SchemaObject | ReferenceObject;
83
+ prefixItems?: (SchemaObject | ReferenceObject)[];
84
+ oneOf?: (SchemaObject | ReferenceObject)[];
85
+ anyOf?: (SchemaObject | ReferenceObject)[];
86
+ allOf?: (SchemaObject | ReferenceObject)[];
87
+ enum?: unknown[];
88
+ const?: unknown;
89
+ format?: string;
90
+ title?: string;
91
+ description?: string;
92
+ example?: unknown;
93
+ examples?: unknown[];
94
+ deprecated?: boolean;
95
+ default?: unknown;
96
+ minimum?: number;
97
+ maximum?: number;
98
+ minLength?: number;
99
+ maxLength?: number;
100
+ pattern?: string;
101
+ minItems?: number;
102
+ maxItems?: number;
103
+ discriminator?: {propertyName: string};
104
+ [key: string]: unknown;
105
+ }
106
+
107
+ export interface ReferenceObject {
108
+ $ref: string;
109
+ }
110
+
111
+ export interface SecuritySchemeObject {
112
+ type: "http" | "apiKey" | "userPassword" | "X509" | "symmetricEncryption" | "asymmetricEncryption" | "plain" | "scramSha256" | "scramSha512" | "gssapi" | "oauth2" | "openIdConnect";
113
+ scheme?: string;
114
+ description?: string;
115
+ name?: string;
116
+ in?: "user" | "password" | "query" | "header" | "cookie";
117
+ }
118
+
119
+ /** AsyncAPI 3.0 security: [{$ref: "#/components/securitySchemes/Name"}] */
120
+ export type SecurityRequirementObject = ReferenceObject | {[name: string]: string[]};
@@ -0,0 +1,167 @@
1
+ import type {GGWebSocketSchema} from "@grest-ts/websocket";
2
+ import {GGHttpServer, GG_HTTP_SERVER} from "@grest-ts/http";
3
+ import {GGLocator} from "@grest-ts/locator";
4
+ import type {AsyncAPIDocument} from "./AsyncApiTypes";
5
+ import {toAsyncApi, ToAsyncApiOptions} from "./toAsyncApi";
6
+
7
+ export interface GGAsyncApiDocsOptions extends ToAsyncApiOptions {
8
+ /**
9
+ * Path where the JSON spec is served.
10
+ * e.g. "/asyncapi.json"
11
+ */
12
+ specPath: string;
13
+
14
+ /**
15
+ * Path where the AsyncAPI Studio UI is served.
16
+ * e.g. "/asyncapi-docs"
17
+ */
18
+ docsPath: string;
19
+
20
+ /**
21
+ * If true, build spec immediately on construction.
22
+ * @default false (lazy on first request)
23
+ */
24
+ eager?: boolean;
25
+
26
+ /**
27
+ * Explicit schema list. When provided, overrides server.registeredWebSocketSchemas.
28
+ */
29
+ schemas?: GGWebSocketSchema<any, any, any, any, any>[];
30
+
31
+ /**
32
+ * The HTTP server to register the docs routes on.
33
+ * When omitted, uses the default GGHttpServer from the locator — the same
34
+ * fallback as MyApi.register().
35
+ */
36
+ http?: GGHttpServer;
37
+ }
38
+
39
+ /**
40
+ * Serves GET /asyncapi.json and GET /asyncapi-docs (AsyncAPI Studio UI)
41
+ * for all WebSocket schemas registered on a GGHttpServer.
42
+ *
43
+ * Schemas are collected automatically from server.registeredWebSocketSchemas,
44
+ * or can be provided explicitly via options.schemas.
45
+ *
46
+ * @example
47
+ * // Mirrors MyApi.register() exactly — uses locator default when http is omitted:
48
+ * GGAsyncApiDocs.register({
49
+ * title: "My Service Events",
50
+ * version: "1.0.0",
51
+ * specPath: "/asyncapi.json",
52
+ * docsPath: "/asyncapi-docs"
53
+ * });
54
+ *
55
+ * @example
56
+ * // With explicit server (same as MyApi.register(impl, {http: server})):
57
+ * GGAsyncApiDocs.register({
58
+ * title: "My Service Events",
59
+ * specPath: "/asyncapi.json",
60
+ * docsPath: "/asyncapi-docs",
61
+ * http: server
62
+ * });
63
+ */
64
+ export class GGAsyncApiDocs {
65
+ private readonly server: GGHttpServer;
66
+ private readonly options: GGAsyncApiDocsOptions;
67
+ private _spec: AsyncAPIDocument | undefined;
68
+
69
+ /**
70
+ * Register AsyncAPI docs routes on an HTTP server.
71
+ * Mirrors the MyApi.register() pattern exactly:
72
+ * - options.http — explicit server (optional)
73
+ * - when absent, uses the default GGHttpServer from the locator
74
+ */
75
+ static register(options: GGAsyncApiDocsOptions): void {
76
+ const server = options.http ?? GGLocator.getScope().get(GG_HTTP_SERVER);
77
+ if (!server) throw new Error("GGAsyncApiDocs.register: no HTTP server found. Pass options.http or create a GGHttpServer first.");
78
+ new GGAsyncApiDocs(server, options);
79
+ }
80
+
81
+ constructor(server: GGHttpServer, options: GGAsyncApiDocsOptions) {
82
+ this.server = server;
83
+ this.options = options;
84
+ if (options.eager) {
85
+ this._spec = this.buildSpec();
86
+ }
87
+ this.registerWith(server);
88
+ }
89
+
90
+ private buildSpec(): AsyncAPIDocument {
91
+ const schemas = this.options.schemas
92
+ ?? (this.server.registeredWebSocketSchemas as GGWebSocketSchema<any, any, any, any, any>[]);
93
+ return toAsyncApi(schemas, this.options);
94
+ }
95
+
96
+ public getSpec(): AsyncAPIDocument {
97
+ return this._spec ??= this.buildSpec();
98
+ }
99
+
100
+ public registerWith(server: GGHttpServer): this {
101
+ const specPath = this.options.specPath;
102
+ const docsPath = this.options.docsPath;
103
+
104
+ server.registerRoute("GET", specPath, async (_req, res) => {
105
+ const spec = this.getSpec();
106
+ const body = JSON.stringify(spec, null, 2);
107
+ res.writeHead(200, {
108
+ "Content-Type": "application/json",
109
+ "Content-Length": Buffer.byteLength(body)
110
+ });
111
+ res.end(body);
112
+ });
113
+
114
+ // AsyncAPI Studio HTML — served for docsPath and all sub-paths so that
115
+ // sidebar deep-links (e.g. /asyncapi-docs/ChatApi_send_foo) stay on the
116
+ // same page instead of opening a new tab or returning 404.
117
+ const serveStudio = async (_req: any, res: any) => {
118
+ const spec = this.getSpec();
119
+ const html = buildAsyncApiStudioHtml(spec);
120
+ res.writeHead(200, {
121
+ "Content-Type": "text/html; charset=utf-8",
122
+ "Content-Length": Buffer.byteLength(html)
123
+ });
124
+ res.end(html);
125
+ };
126
+ server.registerRoute("GET", docsPath, serveStudio);
127
+ // Wildcard for deep-link navigation within the studio
128
+ server.registerRoute("GET", docsPath + "/*", serveStudio);
129
+
130
+ return this;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Build the AsyncAPI Studio HTML page with the spec embedded as inline JSON.
136
+ *
137
+ * The spec is passed directly as the schema object (not wrapped in {url:...} or
138
+ * {source:...}) — this is the correct API for inline document rendering and
139
+ * avoids both external fetches and internal $ref resolution failures.
140
+ *
141
+ * The wildcard route (docsPath/*) is registered alongside docsPath so that
142
+ * sidebar deep-links stay on the same page instead of opening a new tab.
143
+ */
144
+ function buildAsyncApiStudioHtml(spec: AsyncAPIDocument): string {
145
+ // Pass the document directly as schema — AsyncApiStandalone accepts the plain object
146
+ const specJson = JSON.stringify(spec);
147
+ return `<!DOCTYPE html>
148
+ <html lang="en">
149
+ <head>
150
+ <meta charset="UTF-8" />
151
+ <title>AsyncAPI Docs</title>
152
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
153
+ <link rel="icon" href="https://www.asyncapi.com/favicon.ico" />
154
+ <link rel="stylesheet" href="https://unpkg.com/@asyncapi/react-component@latest/styles/default.min.css">
155
+ </head>
156
+ <body>
157
+ <div id="asyncapi"></div>
158
+ <script src="https://unpkg.com/@asyncapi/react-component@latest/browser/standalone/index.js"></script>
159
+ <script>
160
+ AsyncApiStandalone.render(
161
+ {schema: ${specJson}, config: {show: {sidebar: true}}},
162
+ document.getElementById('asyncapi')
163
+ );
164
+ </script>
165
+ </body>
166
+ </html>`;
167
+ }
@@ -0,0 +1,7 @@
1
+ export {toAsyncApi} from "./toAsyncApi";
2
+ export type {ToAsyncApiOptions} from "./toAsyncApi";
3
+ export {GGAsyncApiDocs} from "./GGAsyncApiDocs";
4
+ export type {GGAsyncApiDocsOptions} from "./GGAsyncApiDocs";
5
+ // Backward-compatible aliases
6
+ export {GGAsyncApiDocs as GGAsyncApiServer} from "./GGAsyncApiDocs";
7
+ export type {GGAsyncApiDocsOptions as GGAsyncApiServerOptions} from "./GGAsyncApiDocs";
@@ -0,0 +1,380 @@
1
+ import type {GGWebSocketSchema} from "@grest-ts/websocket";
2
+ import type {ANY_ERROR_CLS, GGSchema} from "@grest-ts/schema";
3
+ import {schemaDescriptionToOpenApi, SchemaRegistry} from "@grest-ts/openapi";
4
+ import type {
5
+ AsyncAPIDocument, ChannelObject, MessageObject,
6
+ OperationObject, ReferenceObject, SchemaObject, SecurityRequirementObject,
7
+ SecuritySchemeObject
8
+ } from "./AsyncApiTypes";
9
+
10
+ export interface ToAsyncApiOptions {
11
+ title?: string;
12
+ version?: string;
13
+ description?: string;
14
+ servers?: Record<string, {host: string; protocol: "ws" | "wss"; description?: string}>;
15
+ }
16
+
17
+ /**
18
+ * Convert a list of GGWebSocketSchema instances to an AsyncAPI 3.0 document.
19
+ *
20
+ * Message patterns:
21
+ * - clientToServer with success/errors: request/response (action:send + reply)
22
+ * - clientToServer without success/errors: fire-and-forget (action:send, no reply)
23
+ * - serverToClient with input: server push (action:receive, no reply)
24
+ * - serverToClient with success/errors: server-initiated request (action:receive + reply)
25
+ *
26
+ * All patterns are symmetric: reply.messages lists ALL possible response messages
27
+ * (success + each error type), correctly modelling that both parties can receive
28
+ * any of them.
29
+ */
30
+ export function toAsyncApi(
31
+ schemas: GGWebSocketSchema<any, any, any, any, any>[],
32
+ options: ToAsyncApiOptions = {},
33
+ serverPort?: number
34
+ ): AsyncAPIDocument {
35
+ const registry = new SchemaRegistry();
36
+ const channels: Record<string, ChannelObject> = {};
37
+ const operations: Record<string, OperationObject> = {};
38
+ const securitySchemes = new Map<string, SecuritySchemeObject>();
39
+
40
+ for (const wsSchema of schemas) {
41
+ const contract = wsSchema.contract;
42
+ const channelId = sanitizeId(wsSchema.name);
43
+ const path = wsSchema.path.startsWith('/') ? wsSchema.path : '/' + wsSchema.path;
44
+ const channelRef: ReferenceObject = {$ref: `#/channels/${channelId}`};
45
+
46
+ const {handshakeHeaders, channelSecurity} = buildHandshakeOpenApi(
47
+ wsSchema.middlewares as any[], securitySchemes
48
+ );
49
+
50
+ const messages: Record<string, MessageObject | ReferenceObject> = {};
51
+
52
+ // ── clientToServer ──────────────────────────────────────────────────
53
+ const c2s = contract.clientToServer;
54
+ for (const methodName of Object.keys(c2s.methods)) {
55
+ const method = c2s.methods[methodName];
56
+ const msgId = `${channelId}_${methodName}`;
57
+ const hasReply = ('success' in method && method.success != null)
58
+ || (method.errors && method.errors.length > 0);
59
+ const requestMsgId = hasReply ? `${msgId}_request` : msgId;
60
+
61
+ // Request (or fire-and-forget) message
62
+ messages[requestMsgId] = buildMessage(
63
+ hasReply ? `${methodName} request` : methodName,
64
+ method.input ?? undefined,
65
+ registry,
66
+ !hasReply ? buildFireAndForgetDescription(method) : undefined
67
+ );
68
+
69
+ // Response + error messages
70
+ const replyMsgRefs: ReferenceObject[] = [];
71
+ if (hasReply) {
72
+ const successMsgId = `${msgId}_response`;
73
+ messages[successMsgId] = buildResponseMessage(methodName, method, registry);
74
+ replyMsgRefs.push({$ref: `#/channels/${channelId}/messages/${successMsgId}`});
75
+
76
+ for (const errCls of (method.errors ?? []) as ANY_ERROR_CLS[]) {
77
+ const errMsgId = `${msgId}_error_${errCls.TYPE}`;
78
+ messages[errMsgId] = buildErrorMessage(methodName, errCls, registry);
79
+ replyMsgRefs.push({$ref: `#/channels/${channelId}/messages/${errMsgId}`});
80
+ }
81
+ }
82
+
83
+ const operationId = `${wsSchema.name}_send_${methodName}`;
84
+ const operation: OperationObject = {
85
+ action: 'send',
86
+ channel: channelRef,
87
+ title: camelToTitle(methodName),
88
+ ...(hasReply
89
+ ? {description: `${camelToTitle(methodName)} — request/response`}
90
+ : {description: `${camelToTitle(methodName)} — fire-and-forget`}),
91
+ messages: [{$ref: `#/channels/${channelId}/messages/${requestMsgId}`}],
92
+ };
93
+ if (channelSecurity.length) operation.security = channelSecurity;
94
+ if (hasReply && replyMsgRefs.length) {
95
+ operation.reply = {channel: channelRef, messages: replyMsgRefs};
96
+ }
97
+ operations[operationId] = operation;
98
+ }
99
+
100
+ // ── serverToClient ──────────────────────────────────────────────────
101
+ const s2c = contract.serverToClient;
102
+ for (const methodName of Object.keys(s2c.methods)) {
103
+ const method = s2c.methods[methodName];
104
+ const msgId = `${channelId}_${methodName}`;
105
+
106
+ // Server-initiated request (has success — server sends, client responds)
107
+ const hasReply = ('success' in method && method.success != null)
108
+ || (method.errors && method.errors.length > 0);
109
+
110
+ if (hasReply) {
111
+ // Server sends the trigger message
112
+ const triggerMsgId = `${msgId}_trigger`;
113
+ messages[triggerMsgId] = buildMessage(
114
+ `${methodName} trigger`,
115
+ method.input ?? undefined,
116
+ registry,
117
+ `${camelToTitle(methodName)} — server-initiated request`
118
+ );
119
+
120
+ // Client sends back the response
121
+ const responseMsgId = `${msgId}_response`;
122
+ messages[responseMsgId] = buildResponseMessage(methodName, method, registry);
123
+
124
+ const replyMsgRefs: ReferenceObject[] = [
125
+ {$ref: `#/channels/${channelId}/messages/${responseMsgId}`}
126
+ ];
127
+ for (const errCls of (method.errors ?? []) as ANY_ERROR_CLS[]) {
128
+ const errMsgId = `${msgId}_error_${errCls.TYPE}`;
129
+ messages[errMsgId] = buildErrorMessage(methodName, errCls, registry);
130
+ replyMsgRefs.push({$ref: `#/channels/${channelId}/messages/${errMsgId}`});
131
+ }
132
+
133
+ operations[`${wsSchema.name}_receive_${methodName}`] = {
134
+ action: 'receive',
135
+ channel: channelRef,
136
+ title: camelToTitle(methodName),
137
+ description: `${camelToTitle(methodName)} — server-initiated request/response`,
138
+ messages: [{$ref: `#/channels/${channelId}/messages/${triggerMsgId}`}],
139
+ ...(channelSecurity.length ? {security: channelSecurity} : {}),
140
+ reply: {channel: channelRef, messages: replyMsgRefs},
141
+ };
142
+ } else {
143
+ // Pure server push — no reply
144
+ messages[msgId] = buildMessage(
145
+ methodName,
146
+ method.input ?? undefined,
147
+ registry
148
+ );
149
+ operations[`${wsSchema.name}_receive_${methodName}`] = {
150
+ action: 'receive',
151
+ channel: channelRef,
152
+ title: camelToTitle(methodName),
153
+ description: `${camelToTitle(methodName)} — server push`,
154
+ messages: [{$ref: `#/channels/${channelId}/messages/${msgId}`}],
155
+ ...(channelSecurity.length ? {security: channelSecurity} : {}),
156
+ };
157
+ }
158
+ }
159
+
160
+ channels[channelId] = {
161
+ address: path,
162
+ title: camelToTitle(wsSchema.name),
163
+ messages,
164
+ ...(handshakeHeaders ? {bindings: {ws: {method: 'GET', headers: handshakeHeaders}}} : {}),
165
+ };
166
+ }
167
+
168
+ const doc: AsyncAPIDocument = {
169
+ asyncapi: "3.0.0",
170
+ info: {
171
+ title: options.title ?? "API",
172
+ version: options.version ?? "1.0.0",
173
+ ...(options.description ? {description: options.description} : {}),
174
+ },
175
+ channels,
176
+ operations,
177
+ };
178
+
179
+ // Auto-add server entry from port if available
180
+ const servers = options.servers
181
+ ?? (serverPort ? {default: {host: `localhost:${serverPort}`, protocol: 'ws'}} : undefined);
182
+ if (servers) doc.servers = servers;
183
+
184
+ const rawSchemaComponents = registry.getComponents();
185
+ const schemaComponents = rawSchemaComponents
186
+ ? Object.fromEntries(
187
+ Object.entries(rawSchemaComponents).map(([k, v]) => [k, fixSchemaForAsyncApi(v)])
188
+ ) as Record<string, SchemaObject>
189
+ : undefined;
190
+ const securityComponents = securitySchemes.size > 0
191
+ ? Object.fromEntries(securitySchemes)
192
+ : undefined;
193
+
194
+ if (schemaComponents || securityComponents) {
195
+ doc.components = {
196
+ ...(schemaComponents ? {schemas: schemaComponents} : {}),
197
+ ...(securityComponents ? {securitySchemes: securityComponents} : {}),
198
+ };
199
+ }
200
+
201
+ return doc;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Message builders
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function buildMessage(
209
+ name: string,
210
+ inputSchema: GGSchema<any> | undefined,
211
+ registry: SchemaRegistry,
212
+ description?: string
213
+ ): MessageObject {
214
+ const msg: MessageObject = {
215
+ name,
216
+ title: camelToTitle(name),
217
+ ...(description ? {description} : {}),
218
+ };
219
+ if (inputSchema) {
220
+ const schema = registry.descOrRef(inputSchema.toSchemaDescription());
221
+ msg.payload = fixSchemaForAsyncApi(schema) as SchemaObject;
222
+ }
223
+ return msg;
224
+ }
225
+
226
+ function buildResponseMessage(
227
+ methodName: string,
228
+ method: {success?: GGSchema<any>},
229
+ registry: SchemaRegistry
230
+ ): MessageObject {
231
+ const msg: MessageObject = {
232
+ name: `${methodName} response`,
233
+ title: `${camelToTitle(methodName)} response`,
234
+ };
235
+ if (method.success) {
236
+ const dataSchema = registry.descOrRef(method.success.toSchemaDescription());
237
+ msg.payload = fixSchemaForAsyncApi({
238
+ type: 'object',
239
+ properties: {
240
+ success: {type: 'boolean', enum: [true]},
241
+ type: {type: 'string', enum: ['OK']},
242
+ data: dataSchema as SchemaObject
243
+ },
244
+ required: ['success', 'type', 'data']
245
+ }) as SchemaObject;
246
+ }
247
+ return msg;
248
+ }
249
+
250
+ function buildErrorMessage(
251
+ methodName: string,
252
+ errCls: ANY_ERROR_CLS,
253
+ registry: SchemaRegistry
254
+ ): MessageObject {
255
+ const dataSchema = errCls.schema != null
256
+ ? registry.descOrRef((errCls.schema as GGSchema<any>).toSchemaDescription())
257
+ : undefined;
258
+
259
+ const props: SchemaObject['properties'] = {
260
+ success: {type: 'boolean', enum: [false]},
261
+ type: {type: 'string', enum: [errCls.TYPE]},
262
+ };
263
+ if (dataSchema) props!.data = dataSchema as SchemaObject;
264
+
265
+ return {
266
+ name: `${methodName} error ${errCls.TYPE}`,
267
+ title: `${camelToTitle(methodName)} error — ${errCls.TYPE}`,
268
+ description: `HTTP equivalent status: ${errCls.STATUS_CODE}`,
269
+ payload: fixSchemaForAsyncApi({
270
+ type: 'object',
271
+ properties: props,
272
+ required: ['success', 'type', ...(dataSchema ? ['data'] : [])]
273
+ }) as SchemaObject,
274
+ };
275
+ }
276
+
277
+ /** When a method has no input and no reply, describe what it means. */
278
+ function buildFireAndForgetDescription(method: {input?: unknown; success?: unknown}): string | undefined {
279
+ if (!method.input && !method.success) return 'Keep-alive / no payload';
280
+ return undefined;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Handshake / security
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function buildHandshakeOpenApi(
288
+ middlewares: Array<{headers?: Record<string, GGSchema<string | undefined>>}>,
289
+ securitySchemes: Map<string, SecuritySchemeObject>
290
+ ): {handshakeHeaders: SchemaObject | undefined; channelSecurity: SecurityRequirementObject[]} {
291
+ const properties: Record<string, SchemaObject> = {};
292
+ const channelSecurity: SecurityRequirementObject[] = [];
293
+
294
+ for (const mw of middlewares) {
295
+ if (!mw.headers) continue;
296
+ for (const [name, schema] of Object.entries(mw.headers)) {
297
+ const desc = schema.toSchemaDescription();
298
+ const format = desc.docs?.format;
299
+
300
+ if (format === 'bearer') {
301
+ const schemeName = desc.docs?.title ? toPascalCase(desc.docs.title) : 'BearerAuth';
302
+ if (!securitySchemes.has(schemeName)) {
303
+ securitySchemes.set(schemeName, {
304
+ type: 'http',
305
+ scheme: 'bearer',
306
+ ...(desc.docs?.description ? {description: desc.docs.description} : {}),
307
+ });
308
+ }
309
+ channelSecurity.push({$ref: `#/components/securitySchemes/${schemeName}`} as any);
310
+ } else {
311
+ const s = schemaDescriptionToOpenApi(desc) as SchemaObject;
312
+ const {description, ...rest} = s as any;
313
+ properties[name] = {...rest, ...(description ? {description} : {})};
314
+ }
315
+ }
316
+ }
317
+
318
+ // Only emit handshakeHeaders if there are plain (non-bearer) headers
319
+ const handshakeHeaders = Object.keys(properties).length > 0
320
+ ? {type: 'object', properties} as SchemaObject
321
+ : undefined;
322
+
323
+ return {handshakeHeaders, channelSecurity};
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Schema post-processing for AsyncAPI 3.0 format differences
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * Convert OpenAPI-format schema properties to AsyncAPI 3.0 format.
332
+ * - discriminator: {propertyName: "x"} → "x" (plain string in AsyncAPI)
333
+ * - additionalProperties: false → removed (not needed in message payload docs)
334
+ */
335
+ function fixSchemaForAsyncApi(schema: unknown): unknown {
336
+ if (!schema || typeof schema !== 'object') return schema;
337
+ const s = schema as Record<string, unknown>;
338
+ if ('$ref' in s) return s;
339
+
340
+ const result: Record<string, unknown> = {};
341
+ for (const [k, v] of Object.entries(s)) {
342
+ if (k === 'additionalProperties' && v === false) {
343
+ // Omit — adds noise without value in message payload documentation
344
+ continue;
345
+ } else if (k === 'discriminator' && v && typeof v === 'object' && 'propertyName' in (v as object)) {
346
+ result[k] = (v as {propertyName: string}).propertyName;
347
+ } else if (Array.isArray(v)) {
348
+ result[k] = v.map(fixSchemaForAsyncApi);
349
+ } else if (v && typeof v === 'object' && !('$ref' in v)) {
350
+ result[k] = fixSchemaForAsyncApi(v);
351
+ } else {
352
+ result[k] = v;
353
+ }
354
+ }
355
+ return result;
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Utilities
360
+ // ---------------------------------------------------------------------------
361
+
362
+ function sanitizeId(name: string): string {
363
+ return name.replace(/[^a-zA-Z0-9_]/g, '_');
364
+ }
365
+
366
+ function camelToTitle(name: string): string {
367
+ return name
368
+ .replace(/([A-Z])/g, ' $1')
369
+ .replace(/^./, s => s.toUpperCase())
370
+ .trim();
371
+ }
372
+
373
+ function toPascalCase(title: string): string {
374
+ return title
375
+ .replace(/[^a-zA-Z0-9\s]/g, '')
376
+ .split(/\s+/)
377
+ .filter(Boolean)
378
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
379
+ .join('');
380
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "//": "THIS FILE IS GENERATED - DO NOT EDIT",
3
+ "extends": "../../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "rootDir": ".",
6
+ "lib": [
7
+ "ES2022"
8
+ ],
9
+ "types": [
10
+ "node",
11
+ "vitest/globals"
12
+ ]
13
+ },
14
+ "include": [
15
+ "**/*"
16
+ ]
17
+ }