@eventcatalog/language-server 0.4.1 → 0.5.0
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/dist/generated/ast.d.ts +4 -1
- package/dist/generated/ast.d.ts.map +1 -1
- package/dist/generated/ast.js +5 -1
- package/dist/generated/ast.js.map +1 -1
- package/dist/generated/grammar.d.ts.map +1 -1
- package/dist/generated/grammar.js +371 -326
- package/dist/generated/grammar.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/asyncapi.d.ts +65 -0
- package/dist/resolvers/asyncapi.d.ts.map +1 -0
- package/dist/resolvers/asyncapi.js +694 -0
- package/dist/resolvers/asyncapi.js.map +1 -0
- package/dist/resolvers/index.d.ts +3 -0
- package/dist/resolvers/index.d.ts.map +1 -0
- package/dist/resolvers/index.js +2 -0
- package/dist/resolvers/index.js.map +1 -0
- package/dist/resolvers/types.d.ts +43 -0
- package/dist/resolvers/types.d.ts.map +1 -0
- package/dist/resolvers/types.js +2 -0
- package/dist/resolvers/types.js.map +1 -0
- package/package.json +3 -1
- package/syntaxes/ec.tmLanguage.json +1 -1
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
// Matches: import events { OrderCreated } from "./spec.yml"
|
|
3
|
+
// Matches: import channels { orderEvents } from "./spec.yml"
|
|
4
|
+
// Matches: import events { OrderCreated } from "https://example.com/spec.yml"
|
|
5
|
+
const ASYNCAPI_IMPORT_RE = /import\s+(events|commands|queries|channels)\s*\{([^}]*)\}\s*from\s*"([^"]+\.ya?ml)"\s*\n?/g;
|
|
6
|
+
// Matches: import OrderService from "./spec.yml"
|
|
7
|
+
// A bare identifier (no braces, no resource type keyword) imports a full service
|
|
8
|
+
const SERVICE_IMPORT_RE = /import\s+([A-Z][a-zA-Z0-9_]*)\s+from\s*"([^"]+\.ya?ml)"\s*\n?/g;
|
|
9
|
+
const RESOURCE_TYPE_SINGULAR = {
|
|
10
|
+
events: "event",
|
|
11
|
+
commands: "command",
|
|
12
|
+
queries: "query",
|
|
13
|
+
channels: "channel",
|
|
14
|
+
};
|
|
15
|
+
function resolveRef(doc, ref) {
|
|
16
|
+
if (!ref.startsWith("#/"))
|
|
17
|
+
return null;
|
|
18
|
+
return ref
|
|
19
|
+
.slice(2)
|
|
20
|
+
.split("/")
|
|
21
|
+
.reduce((current, part) => current != null && typeof current === "object" ? current[part] : null, doc);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse an AsyncAPI v2 or v3 document and extract both messages and channels.
|
|
25
|
+
*/
|
|
26
|
+
export function parseSpec(content) {
|
|
27
|
+
let doc;
|
|
28
|
+
try {
|
|
29
|
+
doc = yaml.load(content);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return {
|
|
33
|
+
messages: new Map(),
|
|
34
|
+
channels: new Map(),
|
|
35
|
+
errors: [
|
|
36
|
+
{
|
|
37
|
+
message: `Failed to parse AsyncAPI YAML: ${String(e)}`,
|
|
38
|
+
line: 1,
|
|
39
|
+
column: 1,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!doc || typeof doc !== "object") {
|
|
45
|
+
return {
|
|
46
|
+
messages: new Map(),
|
|
47
|
+
channels: new Map(),
|
|
48
|
+
errors: [
|
|
49
|
+
{ message: "AsyncAPI file is empty or invalid", line: 1, column: 1 },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const asyncApiVersion = doc.asyncapi || "";
|
|
54
|
+
const [messages, channels] = asyncApiVersion.startsWith("3.")
|
|
55
|
+
? [extractV3Messages(doc), extractV3Channels(doc)]
|
|
56
|
+
: asyncApiVersion.startsWith("2.")
|
|
57
|
+
? [extractV2Messages(doc), extractV2Channels(doc)]
|
|
58
|
+
: [new Map(), new Map()];
|
|
59
|
+
// Apply spec-level version to resources that don't have their own
|
|
60
|
+
if (doc.info?.version) {
|
|
61
|
+
const specVersion = doc.info.version;
|
|
62
|
+
for (const msg of messages.values()) {
|
|
63
|
+
if (!msg.version)
|
|
64
|
+
msg.version = specVersion;
|
|
65
|
+
}
|
|
66
|
+
for (const ch of channels.values()) {
|
|
67
|
+
if (!ch.version)
|
|
68
|
+
ch.version = specVersion;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { messages, channels, errors: [] };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Extract a full service definition from an AsyncAPI spec.
|
|
75
|
+
* Infers the service name, channels, messages, and send/receive operations.
|
|
76
|
+
*/
|
|
77
|
+
export function extractService(content, serviceName) {
|
|
78
|
+
const emptyService = (name) => ({
|
|
79
|
+
name,
|
|
80
|
+
operations: [],
|
|
81
|
+
channels: [],
|
|
82
|
+
messages: [],
|
|
83
|
+
});
|
|
84
|
+
const parsed = parseSpec(content);
|
|
85
|
+
if (parsed.errors.length > 0) {
|
|
86
|
+
return {
|
|
87
|
+
service: emptyService(serviceName || "UnknownService"),
|
|
88
|
+
errors: parsed.errors,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// parseSpec returns empty maps for empty/invalid docs, so check that
|
|
92
|
+
const doc = yaml.load(content);
|
|
93
|
+
if (!doc || typeof doc !== "object") {
|
|
94
|
+
return {
|
|
95
|
+
service: emptyService(serviceName || "UnknownService"),
|
|
96
|
+
errors: [
|
|
97
|
+
{ message: "AsyncAPI file is empty or invalid", line: 1, column: 1 },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const name = serviceName || sanitizeServiceName(doc.info?.title) || "UnknownService";
|
|
102
|
+
const version = doc.info?.version;
|
|
103
|
+
const summary = doc.info?.description?.trim().split("\n")[0];
|
|
104
|
+
const asyncApiVersion = doc.asyncapi || "";
|
|
105
|
+
const operations = asyncApiVersion.startsWith("3.")
|
|
106
|
+
? extractV3Operations(doc)
|
|
107
|
+
: asyncApiVersion.startsWith("2.")
|
|
108
|
+
? extractV2Operations(doc)
|
|
109
|
+
: [];
|
|
110
|
+
// Collect channels and messages referenced by operations
|
|
111
|
+
const usedChannelNames = new Set(operations.map((op) => op.channelName));
|
|
112
|
+
const usedMessageNames = new Set(operations.map((op) => op.messageName));
|
|
113
|
+
const channels = [...parsed.channels.values()].filter((ch) => usedChannelNames.has(ch.name));
|
|
114
|
+
const messages = [...parsed.messages.values()].filter((msg) => usedMessageNames.has(msg.name));
|
|
115
|
+
return {
|
|
116
|
+
service: { name, version, summary, operations, channels, messages },
|
|
117
|
+
errors: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function sanitizeServiceName(title) {
|
|
121
|
+
if (!title)
|
|
122
|
+
return undefined;
|
|
123
|
+
// Remove trailing words like "API", "Service", "Events" if they'd be redundant
|
|
124
|
+
// Then PascalCase the result
|
|
125
|
+
return title
|
|
126
|
+
.replace(/[^a-zA-Z0-9\s]/g, "")
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
130
|
+
.join("");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Extract messages from an AsyncAPI document.
|
|
134
|
+
* Convenience wrapper around parseSpec for callers that only need messages.
|
|
135
|
+
*/
|
|
136
|
+
export function extractMessages(content) {
|
|
137
|
+
const { messages, errors } = parseSpec(content);
|
|
138
|
+
return { messages, errors };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Extract channels from an AsyncAPI document.
|
|
142
|
+
* Convenience wrapper around parseSpec for callers that only need channels.
|
|
143
|
+
*/
|
|
144
|
+
export function extractChannels(content) {
|
|
145
|
+
const { channels, errors } = parseSpec(content);
|
|
146
|
+
return { channels, errors };
|
|
147
|
+
}
|
|
148
|
+
// ─── V3 extractors ──────────────────────────────────────
|
|
149
|
+
function extractV3Messages(doc) {
|
|
150
|
+
const messages = new Map();
|
|
151
|
+
if (doc.components?.messages) {
|
|
152
|
+
for (const [name, msg] of Object.entries(doc.components.messages)) {
|
|
153
|
+
messages.set(name, {
|
|
154
|
+
name,
|
|
155
|
+
summary: msg.summary || msg.title,
|
|
156
|
+
description: msg.description,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (doc.channels) {
|
|
161
|
+
for (const channel of Object.values(doc.channels)) {
|
|
162
|
+
if (!channel.messages)
|
|
163
|
+
continue;
|
|
164
|
+
for (const [name, msg] of Object.entries(channel.messages)) {
|
|
165
|
+
if (messages.has(name))
|
|
166
|
+
continue;
|
|
167
|
+
const resolved = msg.$ref ? resolveRef(doc, msg.$ref) : msg;
|
|
168
|
+
if (resolved) {
|
|
169
|
+
messages.set(name, {
|
|
170
|
+
name,
|
|
171
|
+
summary: resolved.summary || resolved.title,
|
|
172
|
+
description: resolved.description,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return messages;
|
|
179
|
+
}
|
|
180
|
+
function extractV3Channels(doc) {
|
|
181
|
+
const channels = new Map();
|
|
182
|
+
if (!doc.channels)
|
|
183
|
+
return channels;
|
|
184
|
+
const defaultProtocol = guessProtocol(doc);
|
|
185
|
+
for (const [name, ch] of Object.entries(doc.channels)) {
|
|
186
|
+
channels.set(name, {
|
|
187
|
+
name,
|
|
188
|
+
address: ch.address,
|
|
189
|
+
protocol: getChannelProtocol(ch, doc) || defaultProtocol,
|
|
190
|
+
summary: ch.summary || ch.description || ch.title,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return channels;
|
|
194
|
+
}
|
|
195
|
+
function extractV3Operations(doc) {
|
|
196
|
+
if (!doc.operations)
|
|
197
|
+
return [];
|
|
198
|
+
const operations = [];
|
|
199
|
+
for (const [, op] of Object.entries(doc.operations)) {
|
|
200
|
+
const action = op.action;
|
|
201
|
+
if (!action)
|
|
202
|
+
continue;
|
|
203
|
+
// Resolve channel reference
|
|
204
|
+
const channelRef = op.channel?.$ref;
|
|
205
|
+
const channelName = channelRef
|
|
206
|
+
? channelRef.replace("#/channels/", "")
|
|
207
|
+
: undefined;
|
|
208
|
+
if (!channelName)
|
|
209
|
+
continue;
|
|
210
|
+
// If the operation specifies its own messages, use only those
|
|
211
|
+
const opMessages = resolveV3OperationMessages(op, doc);
|
|
212
|
+
if (opMessages.length > 0) {
|
|
213
|
+
for (const { name, resolved } of opMessages) {
|
|
214
|
+
operations.push({
|
|
215
|
+
action,
|
|
216
|
+
channelName,
|
|
217
|
+
messageName: name,
|
|
218
|
+
summary: resolved?.summary || resolved?.title || op.summary,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Fall back to all messages on the referenced channel
|
|
224
|
+
const channel = doc.channels?.[channelName];
|
|
225
|
+
if (!channel?.messages)
|
|
226
|
+
continue;
|
|
227
|
+
for (const [messageName, msg] of Object.entries(channel.messages)) {
|
|
228
|
+
const resolved = msg.$ref ? resolveRef(doc, msg.$ref) : msg;
|
|
229
|
+
operations.push({
|
|
230
|
+
action,
|
|
231
|
+
channelName,
|
|
232
|
+
messageName,
|
|
233
|
+
summary: resolved?.summary || resolved?.title || op.summary,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return deduplicateOperations(operations);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Resolve the `messages` array on a v3 operation.
|
|
241
|
+
* Each entry can be a `$ref` to a channel message or a component message.
|
|
242
|
+
*/
|
|
243
|
+
function resolveV3OperationMessages(op, doc) {
|
|
244
|
+
if (!Array.isArray(op.messages) || op.messages.length === 0)
|
|
245
|
+
return [];
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const msgEntry of op.messages) {
|
|
248
|
+
const ref = msgEntry.$ref;
|
|
249
|
+
if (!ref)
|
|
250
|
+
continue;
|
|
251
|
+
// Refs like "#/channels/orderCreated/messages/OrderCreated"
|
|
252
|
+
const channelMsgMatch = ref.match(/^#\/channels\/[^/]+\/messages\/([^/]+)$/);
|
|
253
|
+
if (channelMsgMatch) {
|
|
254
|
+
const resolved = resolveRef(doc, ref);
|
|
255
|
+
const finalResolved = resolved?.$ref
|
|
256
|
+
? resolveRef(doc, resolved.$ref)
|
|
257
|
+
: resolved;
|
|
258
|
+
results.push({ name: channelMsgMatch[1], resolved: finalResolved });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Refs like "#/components/messages/OrderCreated"
|
|
262
|
+
const componentMsgMatch = ref.match(/^#\/components\/messages\/([^/]+)$/);
|
|
263
|
+
if (componentMsgMatch) {
|
|
264
|
+
const resolved = resolveRef(doc, ref);
|
|
265
|
+
results.push({ name: componentMsgMatch[1], resolved });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return results;
|
|
269
|
+
}
|
|
270
|
+
// ─── V2 extractors ──────────────────────────────────────
|
|
271
|
+
function extractV2Messages(doc) {
|
|
272
|
+
const messages = new Map();
|
|
273
|
+
if (doc.components?.messages) {
|
|
274
|
+
for (const [name, msg] of Object.entries(doc.components.messages)) {
|
|
275
|
+
messages.set(name, {
|
|
276
|
+
name,
|
|
277
|
+
summary: msg.summary || msg.title,
|
|
278
|
+
description: msg.description,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (doc.channels) {
|
|
283
|
+
for (const channel of Object.values(doc.channels)) {
|
|
284
|
+
for (const op of [channel.publish, channel.subscribe]) {
|
|
285
|
+
const msg = op?.message;
|
|
286
|
+
if (!msg)
|
|
287
|
+
continue;
|
|
288
|
+
for (const resolved of resolveV2MessageEntries(msg, doc)) {
|
|
289
|
+
if (!messages.has(resolved.name)) {
|
|
290
|
+
messages.set(resolved.name, resolved);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return messages;
|
|
297
|
+
}
|
|
298
|
+
function extractV2Channels(doc) {
|
|
299
|
+
const channels = new Map();
|
|
300
|
+
if (!doc.channels)
|
|
301
|
+
return channels;
|
|
302
|
+
const defaultProtocol = guessProtocol(doc);
|
|
303
|
+
for (const [address, ch] of Object.entries(doc.channels)) {
|
|
304
|
+
const name = address.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
|
|
305
|
+
channels.set(name, {
|
|
306
|
+
name,
|
|
307
|
+
address,
|
|
308
|
+
protocol: ch.bindings ? Object.keys(ch.bindings)[0] : defaultProtocol,
|
|
309
|
+
summary: ch.description || ch.summary,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return channels;
|
|
313
|
+
}
|
|
314
|
+
function extractV2Operations(doc) {
|
|
315
|
+
if (!doc.channels)
|
|
316
|
+
return [];
|
|
317
|
+
const operations = [];
|
|
318
|
+
for (const [address, ch] of Object.entries(doc.channels)) {
|
|
319
|
+
const channelName = address
|
|
320
|
+
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
321
|
+
.replace(/-+/g, "-");
|
|
322
|
+
if (ch.publish?.message) {
|
|
323
|
+
for (const { name, summary } of resolveV2Message(ch.publish.message, doc)) {
|
|
324
|
+
operations.push({
|
|
325
|
+
action: "send",
|
|
326
|
+
channelName,
|
|
327
|
+
messageName: name,
|
|
328
|
+
summary,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (ch.subscribe?.message) {
|
|
333
|
+
for (const { name, summary } of resolveV2Message(ch.subscribe.message, doc)) {
|
|
334
|
+
operations.push({
|
|
335
|
+
action: "receive",
|
|
336
|
+
channelName,
|
|
337
|
+
messageName: name,
|
|
338
|
+
summary,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return operations;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Resolve a v2 message object, handling $ref and oneOf.
|
|
347
|
+
* Returns an array because oneOf can expand to multiple messages.
|
|
348
|
+
*/
|
|
349
|
+
function resolveV2Message(msg, doc) {
|
|
350
|
+
return resolveV2MessageEntries(msg, doc).map(({ name, summary }) => ({
|
|
351
|
+
name,
|
|
352
|
+
summary,
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Resolve a v2 message to full SpecMessage entries (with description).
|
|
357
|
+
* Handles $ref, oneOf, and name extraction from $ref paths.
|
|
358
|
+
*/
|
|
359
|
+
function resolveV2MessageEntries(msg, doc) {
|
|
360
|
+
if (!msg)
|
|
361
|
+
return [];
|
|
362
|
+
// Resolve $ref first
|
|
363
|
+
const resolved = msg.$ref ? resolveRef(doc, msg.$ref) : msg;
|
|
364
|
+
if (!resolved)
|
|
365
|
+
return [];
|
|
366
|
+
// Handle oneOf: each entry is a separate message
|
|
367
|
+
if (Array.isArray(resolved.oneOf)) {
|
|
368
|
+
return resolved.oneOf.flatMap((entry) => resolveV2MessageEntries(entry, doc));
|
|
369
|
+
}
|
|
370
|
+
let name = resolved.name || resolved.messageId;
|
|
371
|
+
if (!name && msg.$ref) {
|
|
372
|
+
// Extract name from $ref path (e.g. "#/components/messages/OrderCreated")
|
|
373
|
+
const refMatch = msg.$ref.match(/\/([^/]+)$/);
|
|
374
|
+
if (refMatch)
|
|
375
|
+
name = refMatch[1];
|
|
376
|
+
}
|
|
377
|
+
if (!name)
|
|
378
|
+
return [];
|
|
379
|
+
return [
|
|
380
|
+
{
|
|
381
|
+
name,
|
|
382
|
+
summary: resolved.summary || resolved.title,
|
|
383
|
+
description: resolved.description,
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Deduplicate operations that reference the same message on the same channel.
|
|
389
|
+
* Can happen when multiple v3 operations point to the same channel+message.
|
|
390
|
+
*/
|
|
391
|
+
function deduplicateOperations(ops) {
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
return ops.filter((op) => {
|
|
394
|
+
const key = `${op.action}:${op.channelName}:${op.messageName}`;
|
|
395
|
+
if (seen.has(key))
|
|
396
|
+
return false;
|
|
397
|
+
seen.add(key);
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// ─── Protocol helpers ───────────────────────────────────
|
|
402
|
+
function guessProtocol(doc) {
|
|
403
|
+
const servers = doc.servers ? Object.values(doc.servers) : [];
|
|
404
|
+
return servers[0]?.protocol;
|
|
405
|
+
}
|
|
406
|
+
function getChannelProtocol(channel, doc) {
|
|
407
|
+
if (channel.bindings) {
|
|
408
|
+
const protocols = Object.keys(channel.bindings);
|
|
409
|
+
if (protocols.length > 0)
|
|
410
|
+
return protocols[0];
|
|
411
|
+
}
|
|
412
|
+
if (Array.isArray(channel.servers) && channel.servers.length > 0) {
|
|
413
|
+
const serverRef = channel.servers[0];
|
|
414
|
+
const server = serverRef.$ref ? resolveRef(doc, serverRef.$ref) : serverRef;
|
|
415
|
+
if (server?.protocol)
|
|
416
|
+
return server.protocol;
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
// ─── EC code generation ─────────────────────────────────
|
|
421
|
+
/**
|
|
422
|
+
* Convert a SpecMessage to an .ec definition string.
|
|
423
|
+
*/
|
|
424
|
+
export function messageToEc(msg, resourceType) {
|
|
425
|
+
return ecBlock(RESOURCE_TYPE_SINGULAR[resourceType], msg.name, [
|
|
426
|
+
msg.version && `version ${msg.version}`,
|
|
427
|
+
msg.summary && `summary "${escapeEc(msg.summary)}"`,
|
|
428
|
+
]);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Convert a SpecChannel to an .ec channel definition string.
|
|
432
|
+
*/
|
|
433
|
+
export function channelToEc(ch) {
|
|
434
|
+
return ecBlock("channel", ch.name, [
|
|
435
|
+
ch.version && `version ${ch.version}`,
|
|
436
|
+
ch.address && `address "${escapeEc(ch.address)}"`,
|
|
437
|
+
ch.protocol && `protocol "${escapeEc(ch.protocol)}"`,
|
|
438
|
+
ch.summary && `summary "${escapeEc(ch.summary)}"`,
|
|
439
|
+
]);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Convert a SpecService to a full .ec definition string.
|
|
443
|
+
* Generates channel definitions, event definitions, and the service block
|
|
444
|
+
* with sends/receives routed through channels.
|
|
445
|
+
*/
|
|
446
|
+
export function serviceToEc(service) {
|
|
447
|
+
const parts = [];
|
|
448
|
+
// Generate channel definitions
|
|
449
|
+
for (const ch of service.channels) {
|
|
450
|
+
parts.push(channelToEc(ch));
|
|
451
|
+
}
|
|
452
|
+
// Build the service block
|
|
453
|
+
const serviceProps = [
|
|
454
|
+
service.version && `version ${service.version}`,
|
|
455
|
+
service.summary && `summary "${escapeEc(service.summary)}"`,
|
|
456
|
+
];
|
|
457
|
+
// Group operations by action for readability
|
|
458
|
+
const sends = service.operations.filter((op) => op.action === "send");
|
|
459
|
+
const receives = service.operations.filter((op) => op.action === "receive");
|
|
460
|
+
// Add blank line before sends/receives if we have version/summary
|
|
461
|
+
if (serviceProps.some(Boolean) && (sends.length > 0 || receives.length > 0)) {
|
|
462
|
+
serviceProps.push("");
|
|
463
|
+
}
|
|
464
|
+
const messageVersions = new Map(service.messages.map((m) => [m.name, m.version]));
|
|
465
|
+
for (const op of sends) {
|
|
466
|
+
const version = messageVersions.get(op.messageName);
|
|
467
|
+
const versionSuffix = version ? `@${version}` : "";
|
|
468
|
+
serviceProps.push(`sends event ${op.messageName}${versionSuffix} to ${op.channelName}`);
|
|
469
|
+
}
|
|
470
|
+
for (const op of receives) {
|
|
471
|
+
const version = messageVersions.get(op.messageName);
|
|
472
|
+
const versionSuffix = version ? `@${version}` : "";
|
|
473
|
+
serviceProps.push(`receives event ${op.messageName}${versionSuffix} from ${op.channelName}`);
|
|
474
|
+
}
|
|
475
|
+
parts.push(ecBlock("service", service.name, serviceProps));
|
|
476
|
+
return parts.join("\n\n");
|
|
477
|
+
}
|
|
478
|
+
function ecBlock(keyword, name, props) {
|
|
479
|
+
const body = props.filter((p) => p !== false && p !== undefined && p !== null);
|
|
480
|
+
const indented = body.map((line) => (line === "" ? "" : ` ${line}`));
|
|
481
|
+
return [`${keyword} ${name} {`, ...indented, "}"].join("\n");
|
|
482
|
+
}
|
|
483
|
+
function escapeEc(value) {
|
|
484
|
+
return value.replace(/"/g, '\\"');
|
|
485
|
+
}
|
|
486
|
+
// ─── Import resolution ──────────────────────────────────
|
|
487
|
+
function isUrl(path) {
|
|
488
|
+
return path.startsWith("https://") || path.startsWith("http://");
|
|
489
|
+
}
|
|
490
|
+
function findImports(source) {
|
|
491
|
+
const imports = [];
|
|
492
|
+
ASYNCAPI_IMPORT_RE.lastIndex = 0;
|
|
493
|
+
let match;
|
|
494
|
+
while ((match = ASYNCAPI_IMPORT_RE.exec(source)) !== null) {
|
|
495
|
+
imports.push({
|
|
496
|
+
full: match[0],
|
|
497
|
+
resourceType: match[1],
|
|
498
|
+
importNames: match[2]
|
|
499
|
+
.split(",")
|
|
500
|
+
.map((s) => s.trim())
|
|
501
|
+
.filter(Boolean),
|
|
502
|
+
specPath: match[3],
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return imports;
|
|
506
|
+
}
|
|
507
|
+
function findServiceImports(source) {
|
|
508
|
+
const imports = [];
|
|
509
|
+
SERVICE_IMPORT_RE.lastIndex = 0;
|
|
510
|
+
let match;
|
|
511
|
+
while ((match = SERVICE_IMPORT_RE.exec(source)) !== null) {
|
|
512
|
+
imports.push({
|
|
513
|
+
full: match[0],
|
|
514
|
+
serviceName: match[1],
|
|
515
|
+
specPath: match[2],
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return imports;
|
|
519
|
+
}
|
|
520
|
+
function resolveImportToEc(imp, parsed) {
|
|
521
|
+
const errors = [];
|
|
522
|
+
const isChannelImport = imp.resourceType === "channels";
|
|
523
|
+
const catalog = isChannelImport ? parsed.channels : parsed.messages;
|
|
524
|
+
const typeName = isChannelImport ? "Channel" : "Message";
|
|
525
|
+
const ecDefs = imp.importNames.map((name) => {
|
|
526
|
+
const resource = catalog.get(name);
|
|
527
|
+
if (!resource) {
|
|
528
|
+
errors.push({
|
|
529
|
+
message: `${typeName} "${name}" not found in AsyncAPI spec "${imp.specPath}". Available: ${[...catalog.keys()].join(", ") || "(none)"}`,
|
|
530
|
+
line: 1,
|
|
531
|
+
column: 1,
|
|
532
|
+
});
|
|
533
|
+
return `// ERROR: "${name}" not found in ${imp.specPath}`;
|
|
534
|
+
}
|
|
535
|
+
return isChannelImport
|
|
536
|
+
? channelToEc(resource)
|
|
537
|
+
: messageToEc(resource, imp.resourceType);
|
|
538
|
+
});
|
|
539
|
+
return { ec: ecDefs.join("\n\n"), errors };
|
|
540
|
+
}
|
|
541
|
+
function resolveServiceImportToEc(imp, specContent) {
|
|
542
|
+
const { service, errors } = extractService(specContent, imp.serviceName);
|
|
543
|
+
return { ec: serviceToEc(service), errors };
|
|
544
|
+
}
|
|
545
|
+
function lookupLocalSpec(specPath, files) {
|
|
546
|
+
const normalizedPath = specPath.replace(/^\.\//, "");
|
|
547
|
+
return (files[specPath] ?? files[normalizedPath] ?? files[`./${normalizedPath}`]);
|
|
548
|
+
}
|
|
549
|
+
function isYamlFile(filename) {
|
|
550
|
+
return filename.endsWith(".yml") || filename.endsWith(".yaml");
|
|
551
|
+
}
|
|
552
|
+
const SKIP = Symbol("skip");
|
|
553
|
+
/**
|
|
554
|
+
* Core resolution logic shared by sync and async resolvers.
|
|
555
|
+
* Given a spec content lookup function, resolves all imports in .ec files.
|
|
556
|
+
* Return `SKIP` from the callback to leave an import untouched;
|
|
557
|
+
* return `undefined` to replace it with an error comment.
|
|
558
|
+
*/
|
|
559
|
+
function resolveFileImports(files, getSpecContent) {
|
|
560
|
+
const errors = [];
|
|
561
|
+
const newFiles = {};
|
|
562
|
+
const parsedSpecs = new Map();
|
|
563
|
+
for (const [filename, source] of Object.entries(files)) {
|
|
564
|
+
if (isYamlFile(filename))
|
|
565
|
+
continue;
|
|
566
|
+
const imports = findImports(source);
|
|
567
|
+
const serviceImports = findServiceImports(source);
|
|
568
|
+
if (imports.length === 0 && serviceImports.length === 0) {
|
|
569
|
+
newFiles[filename] = source;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
let result = source;
|
|
573
|
+
// Resolve resource imports (import events { ... } from "spec.yml")
|
|
574
|
+
for (const imp of imports) {
|
|
575
|
+
const specContent = getSpecContent(imp.specPath);
|
|
576
|
+
if (specContent === SKIP)
|
|
577
|
+
continue;
|
|
578
|
+
if (!specContent) {
|
|
579
|
+
result = result.replace(imp.full, `// ERROR: AsyncAPI spec not available: ${imp.specPath}`);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const cacheKey = isUrl(imp.specPath)
|
|
583
|
+
? imp.specPath
|
|
584
|
+
: imp.specPath.replace(/^\.\//, "");
|
|
585
|
+
if (!parsedSpecs.has(cacheKey)) {
|
|
586
|
+
const parsed = parseSpec(specContent);
|
|
587
|
+
parsedSpecs.set(cacheKey, parsed);
|
|
588
|
+
errors.push(...parsed.errors);
|
|
589
|
+
}
|
|
590
|
+
const parsed = parsedSpecs.get(cacheKey);
|
|
591
|
+
const resolution = resolveImportToEc(imp, parsed);
|
|
592
|
+
errors.push(...resolution.errors);
|
|
593
|
+
result = result.replace(imp.full, resolution.ec);
|
|
594
|
+
}
|
|
595
|
+
// Resolve service imports (import ServiceName from "spec.yml")
|
|
596
|
+
for (const imp of serviceImports) {
|
|
597
|
+
const specContent = getSpecContent(imp.specPath);
|
|
598
|
+
if (specContent === SKIP)
|
|
599
|
+
continue;
|
|
600
|
+
if (!specContent) {
|
|
601
|
+
result = result.replace(imp.full, `// ERROR: AsyncAPI spec not available: ${imp.specPath}`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const resolution = resolveServiceImportToEc(imp, specContent);
|
|
605
|
+
errors.push(...resolution.errors);
|
|
606
|
+
result = result.replace(imp.full, resolution.ec);
|
|
607
|
+
}
|
|
608
|
+
newFiles[filename] = result;
|
|
609
|
+
}
|
|
610
|
+
return { files: newFiles, errors };
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Resolve all AsyncAPI imports in a set of files (sync, local files only).
|
|
614
|
+
* Scans .ec files for `import <type> { ... } from "*.yml"` and
|
|
615
|
+
* `import ServiceName from "*.yml"` statements, parses the referenced
|
|
616
|
+
* YAML files, and replaces the imports with synthesized .ec definitions.
|
|
617
|
+
* YAML files are excluded from the output.
|
|
618
|
+
* URL imports are left untouched - use resolveImportsAsync for those.
|
|
619
|
+
*/
|
|
620
|
+
export function resolveImports(files) {
|
|
621
|
+
const notFoundErrors = [];
|
|
622
|
+
const result = resolveFileImports(files, (specPath) => {
|
|
623
|
+
if (isUrl(specPath))
|
|
624
|
+
return SKIP;
|
|
625
|
+
const content = lookupLocalSpec(specPath, files);
|
|
626
|
+
if (!content) {
|
|
627
|
+
notFoundErrors.push({
|
|
628
|
+
message: `AsyncAPI file not found: "${specPath}"`,
|
|
629
|
+
line: 1,
|
|
630
|
+
column: 1,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return content;
|
|
634
|
+
});
|
|
635
|
+
return {
|
|
636
|
+
files: result.files,
|
|
637
|
+
errors: [...notFoundErrors, ...result.errors],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Resolve all AsyncAPI imports including remote URLs (async).
|
|
642
|
+
* Fetches remote specs via the provided fetchFn, then resolves all imports.
|
|
643
|
+
* For local file imports, looks them up in the files map.
|
|
644
|
+
*/
|
|
645
|
+
export async function resolveImportsAsync(files, fetchFn) {
|
|
646
|
+
// Collect all unique remote URLs that need fetching
|
|
647
|
+
const urlsToFetch = new Set();
|
|
648
|
+
for (const [filename, source] of Object.entries(files)) {
|
|
649
|
+
if (isYamlFile(filename))
|
|
650
|
+
continue;
|
|
651
|
+
for (const imp of findImports(source)) {
|
|
652
|
+
if (isUrl(imp.specPath))
|
|
653
|
+
urlsToFetch.add(imp.specPath);
|
|
654
|
+
}
|
|
655
|
+
for (const imp of findServiceImports(source)) {
|
|
656
|
+
if (isUrl(imp.specPath))
|
|
657
|
+
urlsToFetch.add(imp.specPath);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Fetch all remote specs in parallel
|
|
661
|
+
const fetchErrors = [];
|
|
662
|
+
const fetchedSpecs = new Map();
|
|
663
|
+
await Promise.all([...urlsToFetch].map(async (url) => {
|
|
664
|
+
try {
|
|
665
|
+
fetchedSpecs.set(url, await fetchFn(url));
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
fetchErrors.push({
|
|
669
|
+
message: `Failed to fetch AsyncAPI spec "${url}": ${String(err)}`,
|
|
670
|
+
line: 1,
|
|
671
|
+
column: 1,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}));
|
|
675
|
+
const notFoundErrors = [];
|
|
676
|
+
const result = resolveFileImports(files, (specPath) => {
|
|
677
|
+
if (isUrl(specPath))
|
|
678
|
+
return fetchedSpecs.get(specPath);
|
|
679
|
+
const content = lookupLocalSpec(specPath, files);
|
|
680
|
+
if (!content) {
|
|
681
|
+
notFoundErrors.push({
|
|
682
|
+
message: `AsyncAPI file not found: "${specPath}"`,
|
|
683
|
+
line: 1,
|
|
684
|
+
column: 1,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return content;
|
|
688
|
+
});
|
|
689
|
+
return {
|
|
690
|
+
files: result.files,
|
|
691
|
+
errors: [...fetchErrors, ...notFoundErrors, ...result.errors],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
//# sourceMappingURL=asyncapi.js.map
|