Files
api-tools/src/core/asyncapi.ts
T
zemion 98d1ff4afc
Verify / verify (push) Canceled after 0s
Release API Tools 0.2.0
2026-09-02 10:18:45 +02:00

366 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { generateSchemaSample, resolveLocalReference } from "./refs";
import type {
ApiDocument,
JsonObject,
JsonValue,
LocalWorkspace,
ValidationProblem,
} from "./types";
export interface AsyncMessageExample {
id: string;
name?: string;
contentType?: string;
payload?: JsonValue;
headers?: JsonValue;
notices: string[];
}
export interface AsyncOperation {
key: string;
operationId: string;
action: "send" | "receive" | "publish" | "subscribe";
channelId: string;
address: string;
title?: string;
summary?: string;
protocols: string[];
messages: AsyncMessageExample[];
}
interface ResolvedObject {
value: JsonObject;
filename: string;
key?: string;
}
function asObject(value: JsonValue | undefined): JsonObject | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? value
: undefined;
}
function resolveObject(
workspace: LocalWorkspace,
raw: JsonValue | undefined,
filename = workspace.entry,
depth = 0,
): ResolvedObject | undefined {
const value = asObject(raw);
if (!value) return undefined;
if (typeof value.$ref !== "string") return { value, filename };
if (depth > 20)
throw new RangeError("AsyncAPI reference chain exceeds 20 links.");
const resolved = resolveLocalReference(workspace, value.$ref, filename);
const object = resolveObject(
workspace,
resolved.value,
resolved.document.filename,
depth + 1,
);
return object ? { ...object, key: resolved.key } : undefined;
}
function scanReferences(root: JsonValue): string[] {
const references: string[] = [];
const stack: Array<{ value: JsonValue; depth: number }> = [
{ value: root, depth: 0 },
];
let nodes = 0;
while (stack.length) {
const current = stack.pop()!;
nodes += 1;
if (nodes > 100_000 || current.depth > 64) break;
if (Array.isArray(current.value))
for (const child of current.value)
stack.push({ value: child, depth: current.depth + 1 });
else {
const object = asObject(current.value);
if (!object) continue;
for (const [key, child] of Object.entries(object))
if (key === "$ref" && typeof child === "string") references.push(child);
else stack.push({ value: child, depth: current.depth + 1 });
}
}
return references;
}
export function validateAsyncApi(document: ApiDocument): ValidationProblem[] {
const root = document.value;
const problems: ValidationProblem[] = [];
const version = typeof root.asyncapi === "string" ? root.asyncapi : undefined;
if (!version)
problems.push({
level: "error",
path: "/asyncapi",
message: "Missing AsyncAPI version string.",
});
else if (!/^(?:2\.[0-6]\.\d+|3\.[01]\.\d+)$/u.test(version))
problems.push({
level: "error",
path: "/asyncapi",
message: `Version “${version}” is outside the focused AsyncAPI 2.02.6 and 3.0/3.1 families.`,
});
const info = asObject(root.info);
if (
!info ||
typeof info.title !== "string" ||
!info.title.trim() ||
typeof info.version !== "string" ||
!info.version.trim()
)
problems.push({
level: "error",
path: "/info",
message: "AsyncAPI info needs non-empty title and version strings.",
});
const channels = asObject(root.channels);
if (!channels)
problems.push({
level: "error",
path: "/channels",
message: "Missing channels object.",
});
else if (!Object.keys(channels).length)
problems.push({
level: "warning",
path: "/channels",
message: "No channels are declared.",
});
if (/^3\./u.test(version ?? "") && !asObject(root.operations))
problems.push({
level: "warning",
path: "/operations",
message: "No root operations are declared.",
});
for (const reference of scanReferences(root))
if (
/^[a-z][a-z0-9+.-]*:/iu.test(reference) ||
reference.startsWith("//") ||
reference.startsWith("/")
)
problems.push({
level: "error",
path: "/$ref",
message: `Remote or absolute reference is prohibited: ${reference}`,
});
if (!problems.some((item) => item.level === "error"))
problems.unshift({
level: "info",
path: "/",
message: `Focused structural checks passed for AsyncAPI ${version}.`,
});
return problems.slice(0, 1_000);
}
function protocolInventory(root: JsonObject): string[] {
const servers = asObject(root.servers) ?? {};
return [
...new Set(
Object.values(servers).flatMap((raw) => {
const server = asObject(raw);
return typeof server?.protocol === "string" ? [server.protocol] : [];
}),
),
].slice(0, 100);
}
function messageExample(
workspace: LocalWorkspace,
id: string,
raw: JsonValue,
filename: string,
defaultContentType?: string,
): AsyncMessageExample {
const notices: string[] = [];
try {
const resolved = resolveObject(workspace, raw, filename);
if (!resolved)
return { id, notices: ["Message is not an object or local reference."] };
const message = resolved.value;
const examples = Array.isArray(message.examples) ? message.examples : [];
const firstExample = asObject(examples[0]);
const derive = (schema: JsonValue | undefined, label: string) => {
if (schema === undefined) return undefined;
try {
const generated = generateSchemaSample(
workspace,
schema,
resolved.filename,
);
notices.push(
...generated.notices.map((notice) => `${label}: ${notice}`),
);
return generated.value;
} catch (reason) {
notices.push(
`${label}: ${reason instanceof Error ? reason.message : "sample generation failed"}.`,
);
return undefined;
}
};
return {
id,
name: typeof message.name === "string" ? message.name : undefined,
contentType:
typeof message.contentType === "string"
? message.contentType
: defaultContentType,
payload:
firstExample?.payload ?? derive(message.payload, "Payload sample"),
headers:
firstExample?.headers ?? derive(message.headers, "Header sample"),
notices: [...new Set(notices)],
};
} catch (reason) {
return {
id,
notices: [
reason instanceof Error ? reason.message : "Message reference failed.",
],
};
}
}
function channelIdFromReference(reference: string): string {
const match = /#\/channels\/([^/]+)$/u.exec(reference);
if (!match) return reference;
try {
return decodeURIComponent(
match[1]!.replaceAll("~1", "/").replaceAll("~0", "~"),
);
} catch {
return match[1]!;
}
}
export function collectAsyncOperations(
workspace: LocalWorkspace,
): AsyncOperation[] {
const root = workspace.documents.get(workspace.entry)?.value;
if (!root) throw new ReferenceError("AsyncAPI entry document is missing.");
const version = String(root.asyncapi ?? "");
const channels = asObject(root.channels) ?? {};
const defaultContentType =
typeof root.defaultContentType === "string"
? root.defaultContentType
: undefined;
const protocols = protocolInventory(root);
const output: AsyncOperation[] = [];
if (/^3\./u.test(version)) {
const operations = asObject(root.operations) ?? {};
for (const [operationId, rawOperation] of Object.entries(operations)) {
if (output.length >= 2_000)
throw new RangeError("AsyncAPI operation count exceeds 2,000.");
try {
const resolvedOperation = resolveObject(workspace, rawOperation);
const operation = resolvedOperation?.value;
const action = operation?.action;
const channelReference = asObject(operation?.channel)?.$ref;
if (
(action !== "send" && action !== "receive") ||
typeof channelReference !== "string"
)
continue;
const resolvedChannel = resolveObject(
workspace,
operation!.channel,
resolvedOperation?.filename,
);
const channel = resolvedChannel?.value;
if (!channel) continue;
const channelId = channelIdFromReference(channelReference);
const selectedMessages = Array.isArray(operation!.messages)
? operation!.messages.map(
(raw, index) => [`message-${index + 1}`, raw] as const,
)
: Object.entries(asObject(channel.messages) ?? {});
const messages = selectedMessages
.slice(0, 100)
.map(([id, raw]) =>
messageExample(
workspace,
id,
raw,
resolvedChannel.filename,
defaultContentType,
),
);
const address =
typeof channel.address === "string"
? channel.address
: "(dynamic/unknown)";
output.push({
key: `${action.toUpperCase()} ${address} · ${operationId}`,
operationId,
action,
channelId,
address,
title:
typeof operation!.title === "string" ? operation!.title : undefined,
summary:
typeof operation!.summary === "string"
? operation!.summary
: typeof channel.summary === "string"
? channel.summary
: undefined,
protocols,
messages,
});
} catch {
// The validation report inventories blocked/unresolved references. A
// single invalid operation must not hide other usable operations.
}
}
} else {
for (const [channelId, rawChannel] of Object.entries(channels)) {
const resolvedChannel = resolveObject(workspace, rawChannel);
const channel = resolvedChannel?.value;
if (!channel) continue;
for (const action of ["publish", "subscribe"] as const) {
const operation = asObject(channel[action]);
if (!operation) continue;
if (output.length >= 2_000)
throw new RangeError("AsyncAPI operation count exceeds 2,000.");
const rawMessages = Array.isArray(operation.message)
? operation.message
: asObject(operation.message)?.oneOf &&
Array.isArray(asObject(operation.message)?.oneOf)
? (asObject(operation.message)!.oneOf as JsonValue[])
: operation.message !== undefined
? [operation.message]
: [];
const messages = rawMessages
.slice(0, 100)
.map((raw, index) =>
messageExample(
workspace,
`message-${index + 1}`,
raw,
resolvedChannel.filename,
defaultContentType,
),
);
const address = channelId;
output.push({
key: `${action.toUpperCase()} ${address}`,
operationId:
typeof operation.operationId === "string"
? operation.operationId
: `${action}-${channelId}`,
action,
channelId,
address,
summary:
typeof operation.summary === "string"
? operation.summary
: undefined,
protocols,
messages,
});
}
}
}
return output;
}