Release API Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:18:45 +02:00
parent fbcd0e56d6
commit 98d1ff4afc
35 changed files with 2483 additions and 147 deletions
+365
View File
@@ -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.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;
}