@@ -0,0 +1,365 @@
|
||||
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.0–2.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;
|
||||
}
|
||||
Reference in New Issue
Block a user