-
Exchange inventory
+
+
Exchange inventory
+ {contractReport ? (
+
+ {contractReport.coveredOperations}/
+ {contractReport.operations} operations covered ·{" "}
+ {contractReport.errors} errors · {contractReport.warnings}{" "}
+ warnings
+
+ ) : (
+
Raw exchanges receive inventory only.
+ )}
+
{exchanges.length}
@@ -592,6 +777,7 @@ export function Workbench() {
| Method |
URL |
+ Operation |
Status |
Time |
Size |
@@ -602,6 +788,7 @@ export function Workbench() {
| {exchange.method} |
{exchange.url} |
+ {exchange.operation ?? "unmatched"} |
{exchange.status ?? "—"} |
{exchange.duration === undefined
@@ -609,15 +796,47 @@ export function Workbench() {
: `${exchange.duration} ms`}
|
- {exchange.responseBytes === undefined
- ? "—"
- : `${exchange.responseBytes} B`}
+
+ {exchange.responseBytes === undefined
+ ? "—"
+ : `${exchange.responseBytes} B`}
+
+ {exchange.errors || exchange.warnings ? (
+
+ {exchange.errors ?? 0} errors ·{" "}
+ {exchange.warnings ?? 0} warnings
+
+ ) : null}
|
))}
+ {contractReport?.diagnostics.length ? (
+
+ {contractReport.diagnostics.map((item, index) => (
+ -
+
+ #{item.exchange + 1} · {item.location}
+
+ {item.message}
+ {item.operation ? {item.operation} : null}
+
+ ))}
+
+ ) : contractReport ? (
+
+ Captured methods, paths, parameters, status codes, media types,
+ and available JSON bodies satisfy the focused contract checks.
+
+ ) : null}
Authorization and cookie values are not rendered by this summary,
but the pasted source can still contain secrets. Clear it before
diff --git a/src/core/asyncapi.ts b/src/core/asyncapi.ts
new file mode 100644
index 0000000..e26da10
--- /dev/null
+++ b/src/core/asyncapi.ts
@@ -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;
+}
diff --git a/src/core/compare.ts b/src/core/compare.ts
index 705317c..fb37b8c 100644
--- a/src/core/compare.ts
+++ b/src/core/compare.ts
@@ -1,4 +1,7 @@
import { collectOperations } from "./operations";
+import { collectAsyncOperations } from "./asyncapi";
+import { apiDescriptionKind } from "./parse";
+import { createWorkspace } from "./refs";
import type { ApiDocument, ApiOperation, JsonObject, JsonValue } from "./types";
export interface ApiChange {
@@ -31,6 +34,247 @@ function responseCodes(operation: ApiOperation): Set {
return new Set(Object.keys(asObject(operation.value.responses) ?? {}));
}
+type SchemaDirection = "request" | "response";
+
+function stringSet(value: JsonValue | undefined): Set {
+ return new Set(
+ Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : [],
+ );
+}
+
+function schemaTypes(schema: JsonObject): Set {
+ return new Set(
+ Array.isArray(schema.type)
+ ? schema.type.filter((item): item is string => typeof item === "string")
+ : typeof schema.type === "string"
+ ? [schema.type]
+ : [],
+ );
+}
+
+function schemaChanges(
+ before: JsonValue | undefined,
+ after: JsonValue | undefined,
+ path: string,
+ direction: SchemaDirection,
+ depth = 0,
+ budget = { nodes: 0 },
+): ApiChange[] {
+ budget.nodes += 1;
+ if (budget.nodes > 10_000 || depth > 24)
+ return [
+ {
+ level: "review",
+ path,
+ message: "Schema comparison reached its bounded depth/node limit.",
+ },
+ ];
+ const oldSchema = asObject(before),
+ newSchema = asObject(after);
+ if (!oldSchema || !newSchema)
+ return JSON.stringify(before) === JSON.stringify(after)
+ ? []
+ : [
+ {
+ level: "review",
+ path,
+ message: "Boolean, reference, or non-object schema changed.",
+ },
+ ];
+ if (oldSchema.$ref !== undefined || newSchema.$ref !== undefined)
+ return oldSchema.$ref === newSchema.$ref
+ ? []
+ : [
+ {
+ level: "review",
+ path,
+ message:
+ "Schema reference changed; dereferenced compatibility requires review.",
+ },
+ ];
+ const changes: ApiChange[] = [];
+ const oldTypes = schemaTypes(oldSchema),
+ newTypes = schemaTypes(newSchema);
+ if (
+ oldTypes.size &&
+ newTypes.size &&
+ [...oldTypes].some((type) => !newTypes.has(type))
+ )
+ changes.push({
+ level: "breaking",
+ path: `${path}/type`,
+ message:
+ direction === "request"
+ ? "Request schema no longer accepts every previously accepted type."
+ : "Response schema no longer promises every previously declared type.",
+ });
+ const oldEnum = Array.isArray(oldSchema.enum)
+ ? new Set(oldSchema.enum.map((item) => JSON.stringify(item)))
+ : undefined;
+ const newEnum = Array.isArray(newSchema.enum)
+ ? new Set(newSchema.enum.map((item) => JSON.stringify(item)))
+ : undefined;
+ if (oldEnum && newEnum) {
+ const removed = [...oldEnum].some((item) => !newEnum.has(item));
+ const added = [...newEnum].some((item) => !oldEnum.has(item));
+ if (
+ (direction === "request" && removed) ||
+ (direction === "response" && added)
+ )
+ changes.push({
+ level: "breaking",
+ path: `${path}/enum`,
+ message:
+ direction === "request"
+ ? "Request enum removed a previously accepted value."
+ : "Response enum added a value clients may not handle.",
+ });
+ if (
+ (direction === "request" && added) ||
+ (direction === "response" && removed)
+ )
+ changes.push({
+ level: "non-breaking",
+ path: `${path}/enum`,
+ message:
+ direction === "request"
+ ? "Request enum accepts an additional value."
+ : "Response enum was narrowed.",
+ });
+ }
+ const oldRequired = stringSet(oldSchema.required),
+ newRequired = stringSet(newSchema.required);
+ const requiredRegression =
+ direction === "request"
+ ? [...newRequired].filter((name) => !oldRequired.has(name))
+ : [...oldRequired].filter((name) => !newRequired.has(name));
+ for (const name of requiredRegression)
+ changes.push({
+ level: "breaking",
+ path: `${path}/required/${name}`,
+ message:
+ direction === "request"
+ ? `Request property ${name} became required.`
+ : `Required response property ${name} is no longer promised.`,
+ });
+ const oldProperties = asObject(oldSchema.properties) ?? {};
+ const newProperties = asObject(newSchema.properties) ?? {};
+ for (const [name, oldProperty] of Object.entries(oldProperties)) {
+ if (!Object.hasOwn(newProperties, name)) {
+ if (direction === "response")
+ changes.push({
+ level: oldRequired.has(name) ? "breaking" : "review",
+ path: `${path}/properties/${name}`,
+ message: "Response property declaration was removed.",
+ });
+ continue;
+ }
+ changes.push(
+ ...schemaChanges(
+ oldProperty,
+ newProperties[name],
+ `${path}/properties/${name}`,
+ direction,
+ depth + 1,
+ budget,
+ ),
+ );
+ }
+ if (
+ direction === "request" &&
+ oldSchema.additionalProperties !== false &&
+ newSchema.additionalProperties === false
+ )
+ changes.push({
+ level: "breaking",
+ path: `${path}/additionalProperties`,
+ message: "Request schema now rejects undeclared properties.",
+ });
+ const lowerBounds = ["minimum", "minLength", "minItems"] as const;
+ const upperBounds = ["maximum", "maxLength", "maxItems"] as const;
+ for (const keyword of lowerBounds) {
+ const oldValue = oldSchema[keyword],
+ newValue = newSchema[keyword];
+ if (
+ typeof oldValue === "number" &&
+ typeof newValue === "number" &&
+ ((direction === "request" && newValue > oldValue) ||
+ (direction === "response" && newValue < oldValue))
+ )
+ changes.push({
+ level: "breaking",
+ path: `${path}/${keyword}`,
+ message: `${direction === "request" ? "Request acceptance" : "Response guarantee"} narrowed from ${oldValue} to ${newValue}.`,
+ });
+ }
+ for (const keyword of upperBounds) {
+ const oldValue = oldSchema[keyword],
+ newValue = newSchema[keyword];
+ if (
+ typeof oldValue === "number" &&
+ typeof newValue === "number" &&
+ ((direction === "request" && newValue < oldValue) ||
+ (direction === "response" && newValue > oldValue))
+ )
+ changes.push({
+ level: "breaking",
+ path: `${path}/${keyword}`,
+ message: `${direction === "request" ? "Request acceptance" : "Response guarantee"} narrowed from ${oldValue} to ${newValue}.`,
+ });
+ }
+ return changes;
+}
+
+function contentSchemas(
+ value: JsonValue | undefined,
+): Map {
+ const content = asObject(asObject(value)?.content) ?? {};
+ return new Map(
+ Object.entries(content).map(([mediaType, raw]) => [
+ mediaType,
+ asObject(raw)?.schema,
+ ]),
+ );
+}
+
+function compareContent(
+ before: JsonValue | undefined,
+ after: JsonValue | undefined,
+ path: string,
+ direction: SchemaDirection,
+): ApiChange[] {
+ const prior = contentSchemas(before),
+ next = contentSchemas(after),
+ changes: ApiChange[] = [];
+ for (const [mediaType, schema] of prior) {
+ if (!next.has(mediaType))
+ changes.push({
+ level: "breaking",
+ path: `${path}/content/${mediaType}`,
+ message: `${direction === "request" ? "Accepted request" : "Declared response"} media type was removed.`,
+ });
+ else
+ changes.push(
+ ...schemaChanges(
+ schema,
+ next.get(mediaType),
+ `${path}/content/${mediaType}/schema`,
+ direction,
+ ),
+ );
+ }
+ for (const mediaType of next.keys())
+ if (!prior.has(mediaType))
+ changes.push({
+ level: direction === "request" ? "non-breaking" : "review",
+ path: `${path}/content/${mediaType}`,
+ message: `${direction === "request" ? "Accepted request" : "Possible response"} media type was added.`,
+ });
+ return changes;
+}
+
export function compareApis(
before: ApiDocument,
after: ApiDocument,
@@ -73,6 +317,14 @@ export function compareApis(
path: key,
message: "Request body became required.",
});
+ changes.push(
+ ...compareContent(
+ operation.value.requestBody,
+ current.value.requestBody,
+ `${key}/requestBody`,
+ "request",
+ ),
+ );
for (const code of responseCodes(operation))
if (!responseCodes(current).has(code))
changes.push({
@@ -80,9 +332,36 @@ export function compareApis(
path: key,
message: `Response ${code} was removed.`,
});
+ else {
+ const oldResponse = asObject(operation.value.responses)?.[code],
+ newResponse = asObject(current.value.responses)?.[code];
+ changes.push(
+ ...compareContent(
+ oldResponse,
+ newResponse,
+ `${key}/responses/${code}`,
+ "response",
+ ),
+ );
+ }
+ const oldSecurity = operation.value.security,
+ newSecurity = current.value.security;
+ if (
+ (oldSecurity === undefined ||
+ (Array.isArray(oldSecurity) && oldSecurity.length === 0)) &&
+ Array.isArray(newSecurity) &&
+ newSecurity.length > 0
+ )
+ changes.push({
+ level: "breaking",
+ path: `${key}/security`,
+ message: "Operation now requires an explicit security alternative.",
+ });
if (
JSON.stringify(operation.value) !== JSON.stringify(current.value) &&
- !changes.some((change) => change.path === key)
+ !changes.some(
+ (change) => change.path === key || change.path.startsWith(`${key}/`),
+ )
)
changes.push({
level: "review",
@@ -99,3 +378,56 @@ export function compareApis(
});
return changes.slice(0, 5_000);
}
+
+export function compareApiDescriptions(
+ before: ApiDocument,
+ after: ApiDocument,
+): ApiChange[] {
+ const beforeKind = apiDescriptionKind(before);
+ const afterKind = apiDescriptionKind(after);
+ if (beforeKind !== afterKind)
+ return [
+ {
+ level: "breaking",
+ path: "/",
+ message: `Description family changed from ${beforeKind} to ${afterKind}.`,
+ },
+ ];
+ if (beforeKind !== "asyncapi") return compareApis(before, after);
+ const prior = new Map(
+ collectAsyncOperations(createWorkspace(before)).map((operation) => [
+ operation.key,
+ operation,
+ ]),
+ );
+ const next = new Map(
+ collectAsyncOperations(createWorkspace(after)).map((operation) => [
+ operation.key,
+ operation,
+ ]),
+ );
+ const changes: ApiChange[] = [];
+ for (const [key, operation] of prior) {
+ const current = next.get(key);
+ if (!current)
+ changes.push({
+ level: "breaking",
+ path: key,
+ message: "Operation was removed.",
+ });
+ else if (JSON.stringify(operation) !== JSON.stringify(current))
+ changes.push({
+ level: "review",
+ path: key,
+ message: "Channel, message, protocol, or operation metadata changed.",
+ });
+ }
+ for (const key of next.keys())
+ if (!prior.has(key))
+ changes.push({
+ level: "non-breaking",
+ path: key,
+ message: "Operation was added.",
+ });
+ return changes.slice(0, 5_000);
+}
diff --git a/src/core/contract.ts b/src/core/contract.ts
new file mode 100644
index 0000000..e759ba7
--- /dev/null
+++ b/src/core/contract.ts
@@ -0,0 +1,809 @@
+import { assertBoundedText, safeJsonParse } from "@add-ideas/toolbox-helpers";
+import { inspectHar, type ExchangeSummary } from "./har";
+import { collectOperations } from "./operations";
+import { resolveLocalReference } from "./refs";
+import type {
+ ApiOperation,
+ JsonObject,
+ JsonValue,
+ LocalWorkspace,
+} from "./types";
+
+export type ContractLevel = "error" | "warning" | "info";
+
+export interface ContractDiagnostic {
+ exchange: number;
+ level: ContractLevel;
+ operation?: string;
+ location: string;
+ message: string;
+}
+
+export interface HarContractReport {
+ exchanges: ExchangeSummary[];
+ diagnostics: ContractDiagnostic[];
+ operations: number;
+ coveredOperations: number;
+ unmatchedExchanges: number;
+ errors: number;
+ warnings: number;
+}
+
+interface ResolvedObject {
+ value: JsonObject;
+ filename: string;
+}
+
+interface ValidationState {
+ steps: number;
+ diagnostics: string[];
+ active: Set;
+}
+
+const MAX_SCHEMA_STEPS = 100_000;
+const MAX_DIAGNOSTICS = 5_000;
+
+function asObject(value: JsonValue | undefined): JsonObject | undefined {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? value
+ : undefined;
+}
+
+function dereferenceObject(
+ workspace: LocalWorkspace,
+ value: JsonValue | undefined,
+ filename = workspace.entry,
+ depth = 0,
+): ResolvedObject | undefined {
+ const object = asObject(value);
+ if (!object) return undefined;
+ if (typeof object.$ref !== "string") return { value: object, filename };
+ if (depth > 20) throw new RangeError("Reference chain exceeds 20 links.");
+ const resolved = resolveLocalReference(workspace, object.$ref, filename);
+ return dereferenceObject(
+ workspace,
+ resolved.value,
+ resolved.document.filename,
+ depth + 1,
+ );
+}
+
+function canonical(value: JsonValue): string {
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key]!)}`)
+ .join(",")}}`;
+}
+
+function instanceType(value: JsonValue): string {
+ if (value === null) return "null";
+ if (Array.isArray(value)) return "array";
+ if (typeof value === "number" && Number.isInteger(value)) return "integer";
+ return typeof value;
+}
+
+function acceptsType(declared: string, value: JsonValue): boolean {
+ if (declared === "number") return typeof value === "number";
+ if (declared === "integer")
+ return typeof value === "number" && Number.isInteger(value);
+ return instanceType(value) === declared;
+}
+
+function schemaValid(
+ workspace: LocalWorkspace,
+ schema: JsonValue,
+ instance: JsonValue,
+ filename: string,
+ path: string,
+ state: ValidationState,
+): boolean {
+ state.steps += 1;
+ if (state.steps > MAX_SCHEMA_STEPS)
+ throw new RangeError(
+ `Contract validation exceeded ${MAX_SCHEMA_STEPS.toLocaleString()} schema steps.`,
+ );
+ if (typeof schema === "boolean") {
+ if (!schema)
+ state.diagnostics.push(`${path}: false schema rejects the value`);
+ return schema;
+ }
+ const object = asObject(schema);
+ if (!object) return true;
+ let valid = true;
+ if (typeof object.$ref === "string") {
+ const resolved = resolveLocalReference(workspace, object.$ref, filename);
+ const key = `${resolved.key}\u0000${path}`;
+ if (state.active.has(key))
+ throw new TypeError(
+ `Reference cycle does not advance the value at ${path}.`,
+ );
+ state.active.add(key);
+ try {
+ valid =
+ schemaValid(
+ workspace,
+ resolved.value,
+ instance,
+ resolved.document.filename,
+ path,
+ state,
+ ) && valid;
+ } finally {
+ state.active.delete(key);
+ }
+ }
+ const declared = Array.isArray(object.type)
+ ? object.type.filter((item): item is string => typeof item === "string")
+ : typeof object.type === "string"
+ ? [object.type]
+ : [];
+ if (
+ declared.length &&
+ !declared.some((type) => acceptsType(type, instance))
+ ) {
+ state.diagnostics.push(`${path}: expected ${declared.join(" or ")}`);
+ valid = false;
+ }
+ if (
+ object.const !== undefined &&
+ canonical(object.const) !== canonical(instance)
+ ) {
+ state.diagnostics.push(`${path}: value differs from const`);
+ valid = false;
+ }
+ if (
+ Array.isArray(object.enum) &&
+ !object.enum.some(
+ (candidate) => canonical(candidate) === canonical(instance),
+ )
+ ) {
+ state.diagnostics.push(`${path}: value is outside enum`);
+ valid = false;
+ }
+ if (Array.isArray(object.allOf))
+ for (const child of object.allOf)
+ valid =
+ schemaValid(workspace, child, instance, filename, path, state) && valid;
+ for (const keyword of ["anyOf", "oneOf"] as const)
+ if (Array.isArray(object[keyword])) {
+ let matches = 0;
+ for (const child of object[keyword]!) {
+ const branch: ValidationState = {
+ steps: state.steps,
+ diagnostics: [],
+ active: new Set(state.active),
+ };
+ if (schemaValid(workspace, child, instance, filename, path, branch))
+ matches += 1;
+ state.steps = Math.max(state.steps, branch.steps);
+ }
+ if (
+ (keyword === "anyOf" && matches === 0) ||
+ (keyword === "oneOf" && matches !== 1)
+ ) {
+ state.diagnostics.push(
+ `${path}: ${keyword} expected ${keyword === "oneOf" ? "exactly one" : "at least one"} matching branch`,
+ );
+ valid = false;
+ }
+ }
+ if (typeof instance === "string") {
+ const length = Array.from(instance).length;
+ if (typeof object.minLength === "number" && length < object.minLength) {
+ state.diagnostics.push(
+ `${path}: string is shorter than ${object.minLength}`,
+ );
+ valid = false;
+ }
+ if (typeof object.maxLength === "number" && length > object.maxLength) {
+ state.diagnostics.push(
+ `${path}: string is longer than ${object.maxLength}`,
+ );
+ valid = false;
+ }
+ }
+ if (typeof instance === "number") {
+ if (typeof object.minimum === "number" && instance < object.minimum) {
+ state.diagnostics.push(`${path}: number is below ${object.minimum}`);
+ valid = false;
+ }
+ if (typeof object.maximum === "number" && instance > object.maximum) {
+ state.diagnostics.push(`${path}: number exceeds ${object.maximum}`);
+ valid = false;
+ }
+ }
+ if (Array.isArray(instance)) {
+ if (
+ typeof object.minItems === "number" &&
+ instance.length < object.minItems
+ ) {
+ state.diagnostics.push(
+ `${path}: array has fewer than ${object.minItems} items`,
+ );
+ valid = false;
+ }
+ if (
+ typeof object.maxItems === "number" &&
+ instance.length > object.maxItems
+ ) {
+ state.diagnostics.push(
+ `${path}: array has more than ${object.maxItems} items`,
+ );
+ valid = false;
+ }
+ if (object.items !== undefined && !Array.isArray(object.items))
+ for (let index = 0; index < instance.length; index += 1)
+ valid =
+ schemaValid(
+ workspace,
+ object.items,
+ instance[index]!,
+ filename,
+ `${path}/${index}`,
+ state,
+ ) && valid;
+ }
+ if (asObject(instance)) {
+ const record = instance as JsonObject;
+ const properties = asObject(object.properties) ?? {};
+ if (Array.isArray(object.required))
+ for (const name of object.required)
+ if (typeof name === "string" && !Object.hasOwn(record, name)) {
+ state.diagnostics.push(
+ `${path}: missing required property ${JSON.stringify(name)}`,
+ );
+ valid = false;
+ }
+ for (const [name, child] of Object.entries(properties))
+ if (Object.hasOwn(record, name))
+ valid =
+ schemaValid(
+ workspace,
+ child,
+ record[name]!,
+ filename,
+ `${path}/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`,
+ state,
+ ) && valid;
+ if (object.additionalProperties === false)
+ for (const name of Object.keys(record))
+ if (!Object.hasOwn(properties, name)) {
+ state.diagnostics.push(
+ `${path}: unexpected property ${JSON.stringify(name)}`,
+ );
+ valid = false;
+ }
+ }
+ return valid;
+}
+
+function validateValue(
+ workspace: LocalWorkspace,
+ schema: JsonValue,
+ value: JsonValue,
+ filename: string,
+ path: string,
+): string[] {
+ const state: ValidationState = {
+ steps: 0,
+ diagnostics: [],
+ active: new Set(),
+ };
+ schemaValid(workspace, schema, value, filename, path, state);
+ return state.diagnostics.slice(0, 100);
+}
+
+function headerMap(value: JsonValue | undefined): Map {
+ const output = new Map();
+ if (!Array.isArray(value)) return output;
+ for (const raw of value) {
+ const header = asObject(raw);
+ if (typeof header?.name !== "string" || typeof header.value !== "string")
+ continue;
+ const name = header.name.toLowerCase();
+ output.set(name, [...(output.get(name) ?? []), header.value]);
+ }
+ return output;
+}
+
+function cookieMap(headers: Map): Map {
+ const output = new Map();
+ for (const line of headers.get("cookie") ?? [])
+ for (const part of line.split(";")) {
+ const index = part.indexOf("=");
+ if (index <= 0) continue;
+ const name = part.slice(0, index).trim();
+ const value = part.slice(index + 1).trim();
+ output.set(name, [...(output.get(name) ?? []), value]);
+ }
+ return output;
+}
+
+function decodedSegments(pathname: string): string[] {
+ return pathname
+ .split("/")
+ .filter(Boolean)
+ .map((segment) => {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+ });
+}
+
+function matchOperation(
+ operations: readonly ApiOperation[],
+ method: string,
+ url: URL,
+ serverBasePaths: readonly string[],
+): { operation: ApiOperation; pathValues: Map } | undefined {
+ const candidatePaths = [
+ url.pathname,
+ ...serverBasePaths
+ .filter(
+ (base) =>
+ base !== "/" &&
+ (url.pathname === base || url.pathname.startsWith(`${base}/`)),
+ )
+ .map((base) => url.pathname.slice(base.length) || "/"),
+ ];
+ const matches: Array<{
+ operation: ApiOperation;
+ pathValues: Map;
+ literals: number;
+ }> = [];
+ for (const operation of operations) {
+ if (
+ operation.method !== method &&
+ operation.method !== method.toUpperCase()
+ )
+ continue;
+ const expected = decodedSegments(operation.path);
+ for (const candidatePath of candidatePaths) {
+ const actual = decodedSegments(candidatePath);
+ if (expected.length !== actual.length) continue;
+ const pathValues = new Map();
+ let literals = 0;
+ let valid = true;
+ for (let index = 0; index < expected.length; index += 1) {
+ const template = /^\{([^{}]+)\}$/u.exec(expected[index]!);
+ if (template) pathValues.set(template[1]!, actual[index]!);
+ else if (expected[index] === actual[index]) literals += 1;
+ else {
+ valid = false;
+ break;
+ }
+ }
+ if (valid) matches.push({ operation, pathValues, literals });
+ }
+ }
+ matches.sort((left, right) => right.literals - left.literals);
+ return matches[0];
+}
+
+function parameterValue(
+ location: string,
+ name: string,
+ pathValues: Map,
+ url: URL,
+ headers: Map,
+ cookies: Map,
+): string[] {
+ if (location === "path")
+ return pathValues.has(name) ? [pathValues.get(name)!] : [];
+ if (location === "query") return url.searchParams.getAll(name);
+ if (location === "querystring")
+ return url.search.length ? [url.search.slice(1)] : [];
+ if (location === "header") return headers.get(name.toLowerCase()) ?? [];
+ if (location === "cookie") return cookies.get(name) ?? [];
+ return [];
+}
+
+function coerceParameter(values: string[], schema: JsonObject): JsonValue {
+ const declared = schema.type;
+ if (declared === "array") {
+ const items = values.flatMap((value) => value.split(","));
+ const itemSchema = asObject(schema.items) ?? {};
+ return items.map((value) => coerceParameter([value], itemSchema));
+ }
+ const value = values[0] ?? "";
+ if (declared === "integer" && /^-?(?:0|[1-9]\d*)$/u.test(value))
+ return Number(value);
+ if (declared === "number" && value.trim() && Number.isFinite(Number(value)))
+ return Number(value);
+ if (declared === "boolean" && /^(?:true|false)$/u.test(value))
+ return value === "true";
+ return value;
+}
+
+function mediaType(value: string | undefined): string {
+ return (value ?? "").split(";", 1)[0]!.trim().toLowerCase();
+}
+
+function contentSchema(
+ workspace: LocalWorkspace,
+ container: JsonObject | undefined,
+ actualType: string,
+ filename: string,
+): { schema?: JsonValue; filename: string; declared?: string } {
+ const content = asObject(container?.content);
+ if (!content) return { filename };
+ const keys = Object.keys(content);
+ const declared =
+ keys.find((key) => mediaType(key) === actualType) ??
+ keys.find((key) => key === "*/*") ??
+ keys.find(
+ (key) => key.endsWith("/*") && actualType.startsWith(key.slice(0, -1)),
+ ) ??
+ keys.find(
+ (key) => key === "application/json" && /[/+]json$/u.test(actualType),
+ );
+ if (!declared) return { filename };
+ const resolved = dereferenceObject(workspace, content[declared], filename);
+ return {
+ schema: resolved?.value.schema,
+ filename: resolved?.filename ?? filename,
+ declared,
+ };
+}
+
+function parseJsonBody(
+ value: JsonValue | undefined,
+ label: string,
+): JsonValue | undefined {
+ const body = asObject(value);
+ if (!body || typeof body.text !== "string") return undefined;
+ if (body.encoding === "base64") {
+ if (body.text.length > 4 * 1024 * 1024)
+ throw new RangeError(`${label} base64 body exceeds 4 MiB.`);
+ const binary = atob(body.text);
+ const bytes = Uint8Array.from(binary, (character) =>
+ character.charCodeAt(0),
+ );
+ return safeJsonParse(new TextDecoder().decode(bytes), {
+ maxTextChars: 2 * 1024 * 1024,
+ maxDepth: 64,
+ maxNodes: 100_000,
+ rejectDangerousKeys: true,
+ }) as JsonValue;
+ }
+ return safeJsonParse(assertBoundedText(body.text, 2 * 1024 * 1024, label), {
+ maxTextChars: 2 * 1024 * 1024,
+ maxDepth: 64,
+ maxNodes: 100_000,
+ rejectDangerousKeys: true,
+ }) as JsonValue;
+}
+
+export function validateHarAgainstOpenApi(
+ source: string,
+ workspace: LocalWorkspace,
+): HarContractReport {
+ const exchanges = inspectHar(source);
+ const parsed = safeJsonParse(
+ assertBoundedText(source, 8 * 1024 * 1024, "HAR length"),
+ {
+ maxTextChars: 8 * 1024 * 1024,
+ maxDepth: 64,
+ maxNodes: 200_000,
+ rejectDangerousKeys: true,
+ },
+ ) as JsonValue;
+ const entries = asObject(asObject(parsed)?.log)?.entries;
+ if (!Array.isArray(entries)) throw new TypeError("Expected HAR log entries.");
+ const root = workspace.documents.get(workspace.entry)?.value;
+ if (!root) throw new ReferenceError("OpenAPI entry document is unavailable.");
+ const operations = collectOperations(root);
+ const requestOperations = operations.filter(
+ (operation) => operation.kind === "path",
+ );
+ const serverBasePaths = Array.isArray(root.servers)
+ ? root.servers.map(asObject).flatMap((server) => {
+ if (typeof server?.url !== "string" || server.url.includes("{"))
+ return [];
+ try {
+ return [
+ new URL(server.url, "https://local.invalid").pathname.replace(
+ /\/$/u,
+ "",
+ ) || "/",
+ ];
+ } catch {
+ return [];
+ }
+ })
+ : [];
+ const diagnostics: ContractDiagnostic[] = [];
+ const covered = new Set();
+ let unmatchedExchanges = 0;
+ const add = (item: ContractDiagnostic) => {
+ if (diagnostics.length < MAX_DIAGNOSTICS) diagnostics.push(item);
+ };
+
+ entries.forEach((raw, exchange) => {
+ const entry = asObject(raw);
+ const request = asObject(entry?.request);
+ const response = asObject(entry?.response);
+ if (typeof request?.method !== "string" || typeof request.url !== "string")
+ return;
+ let url: URL;
+ try {
+ url = new URL(request.url, "https://local.invalid");
+ } catch {
+ add({
+ exchange,
+ level: "error",
+ location: "request.url",
+ message: "URL is invalid.",
+ });
+ unmatchedExchanges += 1;
+ return;
+ }
+ const match = matchOperation(
+ requestOperations,
+ request.method,
+ url,
+ serverBasePaths,
+ );
+ if (!match) {
+ add({
+ exchange,
+ level: "error",
+ location: "request",
+ message: `${request.method.toUpperCase()} ${url.pathname} does not match a declared operation.`,
+ });
+ unmatchedExchanges += 1;
+ return;
+ }
+ const { operation, pathValues } = match;
+ covered.add(operation.key);
+ exchanges[exchange] = { ...exchanges[exchange]!, operation: operation.key };
+ const headers = headerMap(request.headers);
+ const cookies = cookieMap(headers);
+ const parameters = [
+ ...operation.inheritedParameters,
+ ...(Array.isArray(operation.value.parameters)
+ ? operation.value.parameters
+ : []),
+ ];
+ for (const rawParameter of parameters) {
+ let resolved: ResolvedObject | undefined;
+ try {
+ resolved = dereferenceObject(workspace, rawParameter);
+ } catch (reason) {
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "parameter",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : "Parameter reference failed.",
+ });
+ continue;
+ }
+ const parameter = resolved?.value;
+ if (
+ !parameter ||
+ typeof parameter.name !== "string" ||
+ typeof parameter.in !== "string"
+ )
+ continue;
+ const values = parameterValue(
+ parameter.in,
+ parameter.name,
+ pathValues,
+ url,
+ headers,
+ cookies,
+ );
+ if (!values.length && parameter.required === true) {
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: `${parameter.in}.${parameter.name}`,
+ message: "Required parameter is missing.",
+ });
+ } else if (values.length && parameter.schema !== undefined) {
+ const schema = asObject(parameter.schema) ?? parameter.schema;
+ for (const message of validateValue(
+ workspace,
+ schema,
+ coerceParameter(values, asObject(schema) ?? {}),
+ resolved?.filename ?? workspace.entry,
+ `${parameter.in}.${parameter.name}`,
+ ))
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: `${parameter.in}.${parameter.name}`,
+ message,
+ });
+ }
+ }
+
+ let requestBody: ResolvedObject | undefined;
+ try {
+ requestBody = dereferenceObject(workspace, operation.value.requestBody);
+ } catch (reason) {
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "request.body",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : "Request-body reference failed.",
+ });
+ }
+ const postData = asObject(request.postData);
+ if (
+ requestBody?.value.required === true &&
+ typeof postData?.text !== "string"
+ )
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "request.body",
+ message: "Required request body is missing.",
+ });
+ if (postData && requestBody) {
+ const actualType = mediaType(
+ typeof postData.mimeType === "string"
+ ? postData.mimeType
+ : headers.get("content-type")?.[0],
+ );
+ const selected = contentSchema(
+ workspace,
+ requestBody.value,
+ actualType,
+ requestBody.filename,
+ );
+ if (!selected.declared)
+ add({
+ exchange,
+ level: "warning",
+ operation: operation.key,
+ location: "request.content-type",
+ message: `Captured media type ${actualType || "(missing)"} is not declared.`,
+ });
+ else if (selected.schema !== undefined && /[/+]json$/u.test(actualType))
+ try {
+ const body = parseJsonBody(postData, "HAR request JSON body");
+ if (body !== undefined)
+ for (const message of validateValue(
+ workspace,
+ selected.schema,
+ body,
+ selected.filename,
+ "request.body",
+ ))
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "request.body",
+ message,
+ });
+ } catch (reason) {
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "request.body",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : "Request JSON could not be parsed.",
+ });
+ }
+ }
+
+ const status =
+ typeof response?.status === "number" ? String(response.status) : "";
+ const responses = asObject(operation.value.responses) ?? {};
+ const responseKey =
+ (status && Object.hasOwn(responses, status) ? status : undefined) ??
+ (status && Object.hasOwn(responses, `${status[0]}XX`)
+ ? `${status[0]}XX`
+ : undefined) ??
+ (Object.hasOwn(responses, "default") ? "default" : undefined);
+ if (!responseKey) {
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "response.status",
+ message: `Captured status ${status || "(missing)"} is not declared.`,
+ });
+ } else {
+ try {
+ const responseObject = dereferenceObject(
+ workspace,
+ responses[responseKey],
+ );
+ const content = asObject(response?.content);
+ const responseHeaders = headerMap(response?.headers);
+ const actualType = mediaType(
+ typeof content?.mimeType === "string"
+ ? content.mimeType
+ : responseHeaders.get("content-type")?.[0],
+ );
+ if (responseObject && content && typeof content.text === "string") {
+ const selected = contentSchema(
+ workspace,
+ responseObject.value,
+ actualType,
+ responseObject.filename,
+ );
+ if (!selected.declared)
+ add({
+ exchange,
+ level: "warning",
+ operation: operation.key,
+ location: "response.content-type",
+ message: `Captured media type ${actualType || "(missing)"} is not declared for status ${responseKey}.`,
+ });
+ else if (
+ selected.schema !== undefined &&
+ /[/+]json$/u.test(actualType)
+ ) {
+ const body = parseJsonBody(content, "HAR response JSON body");
+ if (body !== undefined)
+ for (const message of validateValue(
+ workspace,
+ selected.schema,
+ body,
+ selected.filename,
+ "response.body",
+ ))
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "response.body",
+ message,
+ });
+ }
+ }
+ } catch (reason) {
+ add({
+ exchange,
+ level: "error",
+ operation: operation.key,
+ location: "response",
+ message:
+ reason instanceof Error
+ ? reason.message
+ : "Response contract could not be inspected.",
+ });
+ }
+ }
+ });
+
+ for (let index = 0; index < exchanges.length; index += 1) {
+ const related = diagnostics.filter((item) => item.exchange === index);
+ exchanges[index] = {
+ ...exchanges[index]!,
+ errors: related.filter((item) => item.level === "error").length,
+ warnings: related.filter((item) => item.level === "warning").length,
+ };
+ }
+ return {
+ exchanges,
+ diagnostics,
+ operations: requestOperations.length,
+ coveredOperations: covered.size,
+ unmatchedExchanges,
+ errors: diagnostics.filter((item) => item.level === "error").length,
+ warnings: diagnostics.filter((item) => item.level === "warning").length,
+ };
+}
diff --git a/src/core/har.ts b/src/core/har.ts
index b4107c7..fc83269 100644
--- a/src/core/har.ts
+++ b/src/core/har.ts
@@ -16,6 +16,9 @@ export interface ExchangeSummary {
requestBytes?: number;
responseBytes?: number;
mimeType?: string;
+ operation?: string;
+ errors?: number;
+ warnings?: number;
}
export function inspectHar(source: string): ExchangeSummary[] {
diff --git a/src/core/operations.ts b/src/core/operations.ts
index d08c343..4427e8d 100644
--- a/src/core/operations.ts
+++ b/src/core/operations.ts
@@ -16,6 +16,7 @@ const METHODS = [
"head",
"patch",
"trace",
+ "query",
] as const;
function asObject(value: JsonValue | undefined): JsonObject | undefined {
return value && typeof value === "object" && !Array.isArray(value)
@@ -26,9 +27,13 @@ function asObject(value: JsonValue | undefined): JsonObject | undefined {
export function collectOperations(root: JsonObject): ApiOperation[] {
const paths = asObject(root.paths) ?? {};
const result: ApiOperation[] = [];
- for (const [path, pathValue] of Object.entries(paths)) {
+ const collectPathItem = (
+ path: string,
+ pathValue: JsonValue,
+ kind: ApiOperation["kind"],
+ ) => {
const pathItem = asObject(pathValue);
- if (!pathItem || !path.startsWith("/")) continue;
+ if (!pathItem || (kind === "path" && !path.startsWith("/"))) return;
const inheritedParameters = Array.isArray(pathItem.parameters)
? pathItem.parameters
: [];
@@ -36,9 +41,13 @@ export function collectOperations(root: JsonObject): ApiOperation[] {
const value = asObject(pathItem[method]);
if (!value) continue;
result.push({
- key: `${method.toUpperCase()} ${path}`,
+ key:
+ kind === "webhook"
+ ? `WEBHOOK ${path} ${method.toUpperCase()}`
+ : `${method.toUpperCase()} ${path}`,
method: method.toUpperCase(),
path,
+ kind,
operationId:
typeof value.operationId === "string" ? value.operationId : undefined,
summary: typeof value.summary === "string" ? value.summary : undefined,
@@ -51,6 +60,49 @@ export function collectOperations(root: JsonObject): ApiOperation[] {
if (result.length > 2_000)
throw new RangeError("Operation count exceeds 2,000");
}
+ if (/^3\.2\./u.test(String(root.openapi ?? ""))) {
+ const additional = asObject(pathItem.additionalOperations) ?? {};
+ for (const [method, rawValue] of Object.entries(additional)) {
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/u.test(method)) continue;
+ if (
+ METHODS.some((known) => known.toUpperCase() === method.toUpperCase())
+ )
+ continue;
+ const value = asObject(rawValue);
+ if (!value) continue;
+ result.push({
+ key:
+ kind === "webhook"
+ ? `WEBHOOK ${path} ${method}`
+ : `${method} ${path}`,
+ method,
+ path,
+ kind,
+ operationId:
+ typeof value.operationId === "string"
+ ? value.operationId
+ : undefined,
+ summary:
+ typeof value.summary === "string" ? value.summary : undefined,
+ tags: Array.isArray(value.tags)
+ ? value.tags.filter((tag): tag is string => typeof tag === "string")
+ : [],
+ value,
+ inheritedParameters,
+ });
+ if (result.length > 2_000)
+ throw new RangeError("Operation count exceeds 2,000");
+ }
+ }
+ };
+ for (const [path, pathValue] of Object.entries(paths)) {
+ collectPathItem(path, pathValue, "path");
+ }
+ if (/^3\.(?:1|2)\./u.test(String(root.openapi ?? ""))) {
+ const webhooks = asObject(root.webhooks) ?? {};
+ for (const [name, pathValue] of Object.entries(webhooks)) {
+ collectPathItem(name, pathValue, "webhook");
+ }
}
return result;
}
@@ -123,14 +175,23 @@ export function operationExample(
? asObject(root.servers[0])
: undefined;
const base =
- typeof server?.url === "string" && !server.url.includes("{")
- ? server.url.replace(/\/$/u, "")
- : "https://api.example.test";
+ operation.kind === "webhook"
+ ? "https://receiver.example.test"
+ : typeof server?.url === "string" && !server.url.includes("{")
+ ? server.url.replace(/\/$/u, "")
+ : "https://api.example.test";
if (base !== "https://api.example.test")
notices.push(
"The declared server URL is reproduced as inert text; it is not contacted.",
);
- let path = operation.path;
+ let path =
+ operation.kind === "webhook"
+ ? `/webhooks/${encodeURIComponent(operation.path)}`
+ : operation.path;
+ if (operation.kind === "webhook")
+ notices.push(
+ "Webhook receiver URL is a local example placeholder because OpenAPI webhook names do not declare a delivery URL.",
+ );
const parameters = [
...operation.inheritedParameters,
...(Array.isArray(operation.value.parameters)
diff --git a/src/core/parse.ts b/src/core/parse.ts
index 8148b1a..8449775 100644
--- a/src/core/parse.ts
+++ b/src/core/parse.ts
@@ -6,6 +6,7 @@ import type {
JsonValue,
ValidationProblem,
} from "./types";
+import { validateAsyncApi } from "./asyncapi";
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const MAX_SOURCE = 4 * 1024 * 1024;
@@ -121,11 +122,11 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
path: "/openapi",
message: "Missing OpenAPI version string.",
});
- else if (!/^3\.(?:0|1)\.\d+(?:[-+].*)?$/u.test(version))
+ else if (!/^3\.(?:0|1|2)\.\d+(?:[-+].*)?$/u.test(version))
problems.push({
level: "error",
path: "/openapi",
- message: `Version “${version}” is outside the supported OpenAPI 3.0/3.1 families.`,
+ message: `Version “${version}” is outside the supported OpenAPI 3.0/3.1/3.2 families.`,
});
const info = object(root.info);
if (!info)
@@ -167,6 +168,45 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
path: "/swagger",
message: "Swagger 2.0 fields are not interpreted by this OpenAPI 3 tool.",
});
+ if (/^3\.2\./u.test(version ?? "") && paths)
+ for (const [path, rawPathItem] of Object.entries(paths)) {
+ const additional = object(object(rawPathItem)?.additionalOperations);
+ if (!additional) continue;
+ for (const [method, operation] of Object.entries(additional)) {
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/u.test(method))
+ problems.push({
+ level: "error",
+ path: `/paths/${path}/additionalOperations/${method}`,
+ message:
+ "Additional-operation key is not a valid bounded HTTP method token.",
+ });
+ else if (
+ [
+ "GET",
+ "PUT",
+ "POST",
+ "DELETE",
+ "OPTIONS",
+ "HEAD",
+ "PATCH",
+ "TRACE",
+ "QUERY",
+ ].includes(method.toUpperCase())
+ )
+ problems.push({
+ level: "error",
+ path: `/paths/${path}/additionalOperations/${method}`,
+ message:
+ "A fixed Path Item operation must use its dedicated field.",
+ });
+ else if (!object(operation))
+ problems.push({
+ level: "error",
+ path: `/paths/${path}/additionalOperations/${method}`,
+ message: "Additional operation must be an object.",
+ });
+ }
+ }
const refs: string[] = [];
const visit = (value: JsonValue, path: string, depth: number) => {
if (depth > 64) return;
@@ -200,3 +240,19 @@ export function validateOpenApi(document: ApiDocument): ValidationProblem[] {
});
return problems.slice(0, 1_000);
}
+
+export function apiDescriptionKind(
+ document: ApiDocument,
+): "openapi" | "asyncapi" | "unknown" {
+ if (typeof document.value.openapi === "string") return "openapi";
+ if (typeof document.value.asyncapi === "string") return "asyncapi";
+ return "unknown";
+}
+
+export function validateApiDescription(
+ document: ApiDocument,
+): ValidationProblem[] {
+ return apiDescriptionKind(document) === "asyncapi"
+ ? validateAsyncApi(document)
+ : validateOpenApi(document);
+}
diff --git a/src/core/refs.ts b/src/core/refs.ts
index 2253242..4a1dc06 100644
--- a/src/core/refs.ts
+++ b/src/core/refs.ts
@@ -139,7 +139,7 @@ export function generateSchemaSample(
active.delete(resolved.key);
}
}
- for (const key of ["example", "default"] as const)
+ for (const key of ["example", "default", "const"] as const)
if (object[key] !== undefined) return object[key]!;
if (Array.isArray(object.enum) && object.enum.length)
return object.enum[0]!;
diff --git a/src/core/types.ts b/src/core/types.ts
index 540fe31..b510985 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -21,6 +21,7 @@ export interface ApiOperation {
key: string;
method: string;
path: string;
+ kind: "path" | "webhook";
operationId?: string;
summary?: string;
tags: string[];
diff --git a/src/styles.css b/src/styles.css
index a4081bc..37493c4 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -504,6 +504,9 @@ iframe {
.diagnostic-list li.warning {
border-left-color: var(--mail-warning);
}
+.diagnostic-list li.error {
+ border-left-color: var(--toolbox-danger);
+}
.diagnostic-list code {
color: var(--toolbox-muted);
font-size: 0.7rem;
@@ -557,6 +560,20 @@ iframe {
.top-gap {
margin-top: 0.7rem;
}
+.message-card {
+ display: grid;
+ gap: 0.55rem;
+ margin-top: 0.75rem;
+ padding: 0.8rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.7rem;
+ background: var(--toolbox-surface-soft);
+}
+.message-card h4,
+.message-card p,
+.message-card pre {
+ margin: 0;
+}
.report-preview {
min-height: 12rem;
}
diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json
index 2408351..6adeec6 100644
--- a/src/toolbox/manifest.source.json
+++ b/src/toolbox/manifest.source.json
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.api-tools",
"name": "API Tools",
- "version": "0.1.0",
- "description": "Inspect and compare API descriptions locally.",
+ "version": "0.2.0",
+ "description": "Inspect and compare HTTP and event API descriptions locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "data", "network"],
- "tags": ["openapi", "swagger", "har", "schema", "http"],
+ "tags": ["openapi", "asyncapi", "har", "schema", "http", "events"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,32 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
+ "io": {
+ "accepts": [
+ {
+ "mediaType": "application/json",
+ "extensions": [".json", ".har"]
+ },
+ {
+ "mediaType": "application/yaml",
+ "extensions": [".yaml", ".yml"]
+ },
+ {
+ "mediaType": "text/plain",
+ "extensions": [".txt", ".http"]
+ }
+ ],
+ "produces": [
+ {
+ "mediaType": "application/json",
+ "extensions": [".json"]
+ }
+ ]
+ },
+ "capabilities": {
+ "required": [],
+ "optional": ["workers"]
+ },
"privacy": {
"processing": "local",
"fileUploads": true,
diff --git a/src/version.ts b/src/version.ts
index 76162f8..0ba6e37 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = "0.1.0";
+export const APP_VERSION = "0.2.0";
diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts
index eabe6e6..37bdd08 100644
--- a/tests/browser/app.spec.ts
+++ b/tests/browser/app.spec.ts
@@ -69,7 +69,7 @@ test("retains a valid model, compares revisions and inspects HAR", async ({
page,
}) => {
await page.goto("/deep/nested/api/");
- await page.getByLabel("OpenAPI source").fill('not: "unterminated');
+ await page.getByLabel("API description source").fill('not: "unterminated');
await page.getByRole("button", { name: "Analyse description" }).click();
await expect(page.getByRole("alert")).toContainText("last successful");
await expect(
@@ -124,7 +124,7 @@ test("integrates help, dark theme, PWA identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/api/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.api-tools",
- version: "0.1.0",
+ version: "0.2.0",
privacy: { processing: "local", telemetry: false },
});
});
diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts
new file mode 100644
index 0000000..8ce780b
--- /dev/null
+++ b/tests/browser/responsive.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "@playwright/test";
+
+test("keeps the primary workspace inside a narrow viewport", async ({
+ page,
+}) => {
+ await page.goto("/deep/nested/api/");
+ await expect(page.locator("main").first()).toBeVisible();
+ await expect(
+ page.locator("main .loading, main .workbench-loading"),
+ ).toHaveCount(0);
+
+ const widths = await page.evaluate(() => ({
+ content: document.documentElement.scrollWidth,
+ viewport: document.documentElement.clientWidth,
+ }));
+ expect(widths.viewport).toBeLessThanOrEqual(430);
+ expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
+});
diff --git a/tests/components/workbench.test.tsx b/tests/components/workbench.test.tsx
index 9904f14..aec28cb 100644
--- a/tests/components/workbench.test.tsx
+++ b/tests/components/workbench.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Workbench } from "../../src/components/Workbench";
@@ -12,9 +12,9 @@ describe("API workbench", () => {
).toBeInTheDocument();
expect(screen.getAllByText("GET", { exact: true })).toHaveLength(2);
expect(screen.getByText(/curl --request GET/iu)).toBeInTheDocument();
- await user.clear(screen.getByLabelText("OpenAPI source"));
+ await user.clear(screen.getByLabelText("API description source"));
await user.type(
- screen.getByLabelText("OpenAPI source"),
+ screen.getByLabelText("API description source"),
'not: "unterminated',
);
await user.click(
@@ -26,6 +26,36 @@ describe("API workbench", () => {
).toHaveLength(3);
});
+ it("opens AsyncAPI operations and derives local message examples", async () => {
+ const user = userEvent.setup();
+ render();
+ fireEvent.change(screen.getByLabelText("API description source"), {
+ target: {
+ value: `asyncapi: 3.1.0
+info: { title: Events, version: '1' }
+defaultContentType: application/json
+channels:
+ users:
+ address: users.signed-up
+ messages:
+ signedUp: { payload: { type: object, properties: { id: { const: user-1 } } } }
+operations:
+ emit:
+ action: send
+ channel: { $ref: '#/channels/users' }
+`,
+ },
+ });
+ await user.click(
+ screen.getByRole("button", { name: "Analyse description" }),
+ );
+ expect(screen.getAllByText("SEND", { exact: true })).not.toHaveLength(0);
+ expect(
+ screen.getAllByText("users.signed-up", { exact: true }),
+ ).toHaveLength(2);
+ expect(screen.getAllByText(/user-1/iu)).toHaveLength(2);
+ });
+
it("shows security, comparison and exchange workspaces", async () => {
const user = userEvent.setup();
render();
diff --git a/tests/core/operations-compare-har.test.ts b/tests/core/operations-compare-har.test.ts
index 71e3e9a..b2a7e1a 100644
--- a/tests/core/operations-compare-har.test.ts
+++ b/tests/core/operations-compare-har.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
-import { compareApis } from "../../src/core/compare";
+import { compareApiDescriptions, compareApis } from "../../src/core/compare";
+import { validateHarAgainstOpenApi } from "../../src/core/contract";
import { inspectHar, inspectRawExchange } from "../../src/core/har";
import {
collectOperations,
@@ -38,6 +39,46 @@ describe("operation derivation, comparisons and saved exchanges", () => {
]);
});
+ it("inventories and compares OpenAPI 3.1 webhook receiver operations", () => {
+ const document = parseApiDocument(`openapi: 3.1.0
+info: { title: Hooks, version: '1' }
+paths: {}
+webhooks:
+ orderChanged:
+ post:
+ operationId: receiveOrderChange
+ requestBody:
+ content:
+ application/json:
+ schema: { type: object, properties: { id: { type: string } } }
+ responses: { '204': { description: accepted } }
+`);
+ const operations = collectOperations(document.value);
+ expect(operations).toEqual([
+ expect.objectContaining({
+ key: "WEBHOOK orderChanged POST",
+ kind: "webhook",
+ method: "POST",
+ path: "orderChanged",
+ }),
+ ]);
+ const example = operationExample(createWorkspace(document), operations[0]!);
+ expect(example.url).toBe(
+ "https://receiver.example.test/webhooks/orderChanged",
+ );
+ expect(example.notices.join(" ")).toMatch(/placeholder/iu);
+
+ const removed = parseApiDocument(
+ "openapi: 3.1.0\ninfo: {title: Hooks, version: '2'}\npaths: {}\n",
+ );
+ expect(compareApis(document, removed)).toContainEqual(
+ expect.objectContaining({
+ level: "breaking",
+ path: "WEBHOOK orderChanged POST",
+ }),
+ );
+ });
+
it("reports a missing local reference without aborting generation", () => {
const document = parseApiDocument(
"openapi: 3.1.0\ninfo: {title: Missing, version: '1'}\npaths: {/x: {get: {responses: {'200': {description: ok, content: {application/json: {schema: {$ref: 'missing.yaml#/Result'}}}}}}}}",
@@ -66,6 +107,90 @@ describe("operation derivation, comparisons and saved exchanges", () => {
);
});
+ it("reports directional request and response schema regressions", () => {
+ const before = parseApiDocument(`openapi: 3.1.0
+info: { title: Contract, version: '1' }
+paths:
+ /items:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { type: object, properties: { kind: { type: string, enum: [a, b] } } }
+ responses:
+ '200':
+ description: ok
+ content:
+ application/json:
+ schema: { type: object, required: [id], properties: { id: { type: string } } }
+`);
+ const after = parseApiDocument(`openapi: 3.1.0
+info: { title: Contract, version: '2' }
+paths:
+ /items:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema: { type: object, required: [kind], properties: { kind: { type: string, enum: [a] } } }
+ responses:
+ '200':
+ description: ok
+ content:
+ application/json:
+ schema: { type: object, properties: {} }
+`);
+ const changes = compareApis(before, after);
+ expect(changes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ level: "breaking",
+ message: expect.stringContaining("Request enum removed"),
+ }),
+ expect.objectContaining({
+ level: "breaking",
+ message: expect.stringContaining("became required"),
+ }),
+ expect.objectContaining({
+ level: "breaking",
+ message: expect.stringContaining("Required response property"),
+ }),
+ ]),
+ );
+ });
+
+ it("collects OpenAPI 3.2 QUERY and custom methods", () => {
+ const document = parseApiDocument(
+ `openapi: 3.2.0
+info: { title: Extended, version: '1' }
+paths:
+ /items:
+ query: { responses: { '200': { description: ok } } }
+ additionalOperations:
+ COPY: { responses: { '204': { description: copied } } }
+`,
+ );
+ expect(collectOperations(document.value).map((item) => item.key)).toEqual([
+ "QUERY /items",
+ "COPY /items",
+ ]);
+ });
+
+ it("compares AsyncAPI operations without treating them as HTTP paths", () => {
+ const before = parseApiDocument(
+ "asyncapi: 3.1.0\ninfo: {title: E, version: '1'}\nchannels: { c: {address: events, messages: {}} }\noperations: { send: {action: send, channel: {$ref: '#/channels/c'}} }",
+ );
+ const after = parseApiDocument(
+ "asyncapi: 3.1.0\ninfo: {title: E, version: '2'}\nchannels: { c: {address: events, messages: {}} }\noperations: {}",
+ );
+ expect(compareApiDescriptions(before, after)).toContainEqual(
+ expect.objectContaining({
+ level: "breaking",
+ message: "Operation was removed.",
+ }),
+ );
+ });
+
it("summarizes HAR and raw exchanges without exposing headers", () => {
const har = inspectHar(
'{"log":{"entries":[{"time":7,"request":{"method":"POST","url":"https://example.test/x","headers":[{"name":"Authorization","value":"secret"}],"postData":{"text":"abc"}},"response":{"status":201,"headers":[],"content":{"size":9,"mimeType":"application/json"}}}]}}',
@@ -84,4 +209,49 @@ describe("operation derivation, comparisons and saved exchanges", () => {
)[0],
).toMatchObject({ method: "GET", url: "/x", status: 204 });
});
+
+ it("matches HAR exchanges to operations and validates captured response JSON", () => {
+ const document = parseApiDocument(SPEC, "openapi.yaml");
+ const report = validateHarAgainstOpenApi(
+ JSON.stringify({
+ log: {
+ entries: [
+ {
+ request: {
+ method: "GET",
+ url: "https://api.example.test/items/item-1",
+ headers: [
+ { name: "Authorization", value: "Bearer do-not-report" },
+ ],
+ },
+ response: {
+ status: 200,
+ headers: [],
+ content: {
+ mimeType: "application/json",
+ text: JSON.stringify({ id: 42 }),
+ },
+ },
+ },
+ {
+ request: {
+ method: "POST",
+ url: "https://api.example.test/not-declared",
+ headers: [],
+ },
+ response: { status: 404, headers: [], content: {} },
+ },
+ ],
+ },
+ }),
+ createWorkspace(document),
+ );
+ expect(report.coveredOperations).toBe(1);
+ expect(report.unmatchedExchanges).toBe(1);
+ expect(report.exchanges[0]?.operation).toBe("GET /items/{id}");
+ expect(report.diagnostics.map((item) => item.message).join("\n")).toMatch(
+ /expected string|does not match a declared operation/iu,
+ );
+ expect(JSON.stringify(report)).not.toContain("do-not-report");
+ });
});
diff --git a/tests/core/parse-refs.test.ts b/tests/core/parse-refs.test.ts
index 2dec66b..4be4d75 100644
--- a/tests/core/parse-refs.test.ts
+++ b/tests/core/parse-refs.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { parseApiDocument, validateOpenApi } from "../../src/core/parse";
+import { collectAsyncOperations } from "../../src/core/asyncapi";
+import {
+ parseApiDocument,
+ validateApiDescription,
+ validateOpenApi,
+} from "../../src/core/parse";
import {
createWorkspace,
generateSchemaSample,
@@ -23,6 +28,23 @@ describe("OpenAPI parsing and local references", () => {
);
});
+ it("recognizes OpenAPI 3.2 QUERY and bounded additional operations", () => {
+ const document = parseApiDocument(
+ `openapi: 3.2.0
+info: { title: Extended, version: '1' }
+paths:
+ /search:
+ query: { responses: { '200': { description: ok } } }
+ additionalOperations:
+ COPY: { responses: { '204': { description: copied } } }
+`,
+ "extended.yaml",
+ );
+ expect(
+ validateOpenApi(document).some((item) => item.level === "error"),
+ ).toBe(false);
+ });
+
it("rejects dangerous YAML keys and alias expansion", () => {
expect(() =>
parseApiDocument("__proto__: { polluted: true }", "bad.yaml"),
@@ -72,4 +94,68 @@ describe("OpenAPI parsing and local references", () => {
});
expect(sample.notices[0]).toMatch(/cycle/iu);
});
+
+ it("inventories AsyncAPI 3.1 channels, operations, and local message samples", () => {
+ const document = parseApiDocument(
+ `asyncapi: 3.1.0
+info: { title: Events, version: '1' }
+defaultContentType: application/json
+servers:
+ broker: { host: broker.example.test, protocol: mqtt }
+channels:
+ signedUp:
+ address: users.signed-up
+ messages:
+ user: { $ref: '#/components/messages/User' }
+operations:
+ sendUser:
+ action: send
+ channel: { $ref: '#/channels/signedUp' }
+components:
+ messages:
+ User:
+ name: UserSignedUp
+ payload:
+ type: object
+ properties: { id: { type: string, example: user-1 } }
+`,
+ "asyncapi.yaml",
+ );
+ expect(
+ validateApiDescription(document).some((item) => item.level === "error"),
+ ).toBe(false);
+ expect(collectAsyncOperations(createWorkspace(document))).toEqual([
+ expect.objectContaining({
+ operationId: "sendUser",
+ action: "send",
+ address: "users.signed-up",
+ protocols: ["mqtt"],
+ messages: [
+ expect.objectContaining({
+ name: "UserSignedUp",
+ payload: { id: "user-1" },
+ }),
+ ],
+ }),
+ ]);
+ });
+
+ it("retains focused AsyncAPI 2.x publish/subscribe support", () => {
+ const document = parseApiDocument(
+ `asyncapi: 2.6.0
+info: { title: Legacy events, version: '1' }
+channels:
+ users:
+ publish:
+ operationId: publishUser
+ message: { payload: { type: string, example: hello } }
+`,
+ );
+ expect(collectAsyncOperations(createWorkspace(document))[0]).toMatchObject({
+ action: "publish",
+ address: "users",
+ operationId: "publishUser",
+ messages: [{ payload: "hello" }],
+ });
+ });
});