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; }