366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
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;
|
||
}
|