@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/AsyncApiTypes.d.ts +111 -0
- package/dist/src/AsyncApiTypes.d.ts.map +1 -0
- package/dist/src/AsyncApiTypes.js +6 -0
- package/dist/src/AsyncApiTypes.js.map +1 -0
- package/dist/src/GGAsyncApiDocs.d.ts +73 -0
- package/dist/src/GGAsyncApiDocs.d.ts.map +1 -0
- package/dist/src/GGAsyncApiDocs.js +125 -0
- package/dist/src/GGAsyncApiDocs.js.map +1 -0
- package/dist/src/index-node.d.ts +7 -0
- package/dist/src/index-node.d.ts.map +1 -0
- package/dist/src/index-node.js +5 -0
- package/dist/src/index-node.js.map +1 -0
- package/dist/src/toAsyncApi.d.ts +27 -0
- package/dist/src/toAsyncApi.d.ts.map +1 -0
- package/dist/src/toAsyncApi.js +301 -0
- package/dist/src/toAsyncApi.js.map +1 -0
- package/dist/src/tsconfig.json +17 -0
- package/dist/tsconfig.publish.tsbuildinfo +1 -0
- package/package.json +57 -0
- package/src/AsyncApiTypes.ts +120 -0
- package/src/GGAsyncApiDocs.ts +167 -0
- package/src/index-node.ts +7 -0
- package/src/toAsyncApi.ts +380 -0
- package/src/tsconfig.json +17 -0
|
@@ -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
|
+
}
|