diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index f6d2cb18..94fea8b4 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -37,6 +37,7 @@ const BODY_CONTENT_TYPE_PREFERENCE = [ "text/plain", ]; const MAX_EXAMPLE_DEPTH = 8; +const MAX_SCHEMA_RESOLUTION_DEPTH = MAX_EXAMPLE_DEPTH; const MAX_EXAMPLE_PROPERTIES = 25; const MAX_DESCRIPTION_ITEMS = 40; const MAX_NAME_LENGTH = 100; @@ -997,13 +998,16 @@ function parameterExample(parameter: UnknownRecord, importState: ImportState): s } function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown { - const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples)); + const directExample = firstPresent( + parameter.example, + firstExampleValue(parameter.examples, importState), + ); if (directExample != null) return directExample; if (isRecord(parameter.content)) { const mediaType = toRecord(Object.values(parameter.content)[0]); return mediaTypeExample(mediaType, importState); } - return schemaToExample(importState.resolve(parameter.schema), importState); + return schemaToExample(parameter.schema, importState); } function importBody({ @@ -1034,7 +1038,7 @@ function importBody({ (c): c is string => typeof c === "string", ); const bodyType = contentType ?? "application/json"; - const schema = importState.resolve(bodyParameter.schema); + const schema = importState.resolveSchema(bodyParameter.schema); const example = schemaToExample(schema, importState); const isBinary = stringAt(schema, "format") === "binary"; return { @@ -1090,7 +1094,7 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) bodyType: contentType, body: { form: schemaToFormParameters( - importState.resolve(mediaType.schema), + mediaType.schema, importState, isRecord(example) ? example : undefined, ), @@ -1098,7 +1102,7 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) }; } - const schema = importState.resolve(mediaType.schema); + const schema = importState.resolveSchema(mediaType.schema); const isBinary = contentType === "application/octet-stream" || stringAt(schema, "format") === "binary"; @@ -1151,10 +1155,10 @@ function valueToXml( elementName: string, isDocumentRoot = false, ): string { - const resolvedSchema = toRecord(importState.resolve(schema)); + const resolvedSchema = toRecord(importState.resolveSchema(schema)); const schemaXml = toRecord(resolvedSchema.xml); if (Array.isArray(value)) { - const itemSchema = importState.resolve(resolvedSchema.items); + const itemSchema = importState.resolveSchema(resolvedSchema.items); const shouldWrap = schemaXml.wrapped === true || isDocumentRoot; const itemName = stringAt(toRecord(itemSchema).xml, "name") ?? @@ -1165,7 +1169,7 @@ function valueToXml( if (isRecord(value)) { const properties = toRecord(resolvedSchema.properties); const entries = Object.entries(value).map(([name, propertyValue]) => { - const propertySchema = toRecord(importState.resolve(properties[name])); + const propertySchema = toRecord(importState.resolveSchema(properties[name])); return { name, propertyValue, propertySchema, xml: toRecord(propertySchema.xml) }; }); const usedPrefixes = new Set(["xml", "xmlns"]); @@ -1261,9 +1265,12 @@ function escapeXml(value: string): string { } function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown { - const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples)); + const directExample = firstPresent( + mediaType.example, + firstExampleValue(mediaType.examples, importState), + ); if (directExample != null) return directExample; - return schemaToExample(importState.resolve(mediaType.schema), importState); + return schemaToExample(mediaType.schema, importState); } function schemaToFormParameters( @@ -1271,16 +1278,16 @@ function schemaToFormParameters( importState: ImportState, example?: UnknownRecord, ) { - const resolvedSchema = toRecord(importState.resolve(schema)); + const resolvedSchema = toRecord(importState.resolveSchema(schema)); const required = toArray(resolvedSchema.required).filter( (name): name is string => typeof name === "string", ); const properties = Object.entries(toRecord(resolvedSchema.properties)) - .filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true) + .filter(([, property]) => toRecord(importState.resolveSchema(property)).readOnly !== true) .slice(0, MAX_EXAMPLE_PROPERTIES); return properties.map(([name, property]) => { - const resolvedProperty = toRecord(importState.resolve(property)); + const resolvedProperty = toRecord(importState.resolveSchema(property)); const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState); const base = { enabled: required.includes(name), @@ -1301,12 +1308,20 @@ function schemaToExample( ): unknown { if (depth > MAX_EXAMPLE_DEPTH) return {}; - const resolved = importState.resolve(schema, visitedRefs); + const schemaRecord = toRecord(schema); + const ref = stringAt(schemaRecord, "$ref"); + const closesReferenceCycle = ref != null && visitedRefs.has(ref); + const nextVisitedRefs = new Set(visitedRefs); + if (ref != null) nextVisitedRefs.add(ref); + + const resolved = importState.resolveSchema(schema, visitedRefs); if (!isRecord(resolved)) return ""; + if (closesReferenceCycle && Object.keys(resolved).length === 0) return {}; const explicitExample = firstPresent( resolved.example, - firstExampleValue(resolved.examples), + firstExampleValue(resolved.examples, importState), + resolved.const, resolved.default, ); if (explicitExample != null) return explicitExample; @@ -1314,45 +1329,40 @@ function schemaToExample( const enumValues = toArray(resolved.enum); if (enumValues.length > 0) return enumValues[0]; + const propertyExample = schemaPropertiesToExample( + resolved, + importState, + depth, + nextVisitedRefs, + ); const allOf = toArray(resolved.allOf); if (allOf.length > 0) { - return allOf.reduce((merged, childSchema) => { - const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs); - return isRecord(childExample) ? { ...merged, ...childExample } : merged; + const compositionExample = allOf.reduce((merged, childSchema) => { + const childExample = schemaToExample(childSchema, importState, depth + 1, nextVisitedRefs); + return isRecord(childExample) ? mergeExampleRecords(merged, childExample) : merged; }, {}); + return mergeExampleRecords(compositionExample, propertyExample); } const oneOf = toArray(resolved.oneOf); const anyOf = toArray(resolved.anyOf); if (oneOf.length > 0 || anyOf.length > 0) { - return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs); + const compositionExample = schemaToExample( + oneOf[0] ?? anyOf[0], + importState, + depth + 1, + nextVisitedRefs, + ); + return Object.keys(propertyExample).length > 0 + ? mergeExampleRecords(isRecord(compositionExample) ? compositionExample : {}, propertyExample) + : compositionExample; } const type = inferSchemaType(resolved); if (type === "array") { - return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)]; - } - if (type === "object") { - const required = toArray(resolved.required).filter( - (name): name is string => typeof name === "string", - ); - const properties = Object.entries(toRecord(resolved.properties)) - .filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true) - .sort(([a], [b]) => { - const aRequired = required.includes(a); - const bRequired = required.includes(b); - return aRequired === bRequired ? 0 : aRequired ? -1 : 1; - }); - - return Object.fromEntries( - properties - .slice(0, MAX_EXAMPLE_PROPERTIES) - .map(([name, property]) => [ - name, - schemaToExample(property, importState, depth + 1, visitedRefs), - ]), - ); + return [schemaToExample(resolved.items, importState, depth + 1, nextVisitedRefs)]; } + if (type === "object") return propertyExample; if (type === "integer" || type === "number") return 0; if (type === "boolean") return false; if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z"; @@ -1360,6 +1370,44 @@ function schemaToExample( return ""; } +function schemaPropertiesToExample( + schema: UnknownRecord, + importState: ImportState, + depth: number, + visitedRefs: Set, +): UnknownRecord { + const required = toArray(schema.required).filter( + (name): name is string => typeof name === "string", + ); + const properties = Object.entries(toRecord(schema.properties)) + .filter(([, property]) => toRecord(importState.resolveSchema(property)).readOnly !== true) + .sort(([a], [b]) => { + const aRequired = required.includes(a); + const bRequired = required.includes(b); + return aRequired === bRequired ? 0 : aRequired ? -1 : 1; + }); + + return Object.fromEntries( + properties + .slice(0, MAX_EXAMPLE_PROPERTIES) + .map(([name, property]) => [ + name, + schemaToExample(property, importState, depth + 1, visitedRefs), + ]), + ); +} + +function mergeExampleRecords(base: UnknownRecord, overlay: UnknownRecord): UnknownRecord { + const merged = { ...base }; + for (const [name, value] of Object.entries(overlay)) { + const baseValue = merged[name]; + merged[name] = isRecord(baseValue) && isRecord(value) + ? mergeExampleRecords(baseValue, value) + : value; + } + return merged; +} + function inferSchemaType(schema: UnknownRecord): string { const rawType = schema.type; if (typeof rawType === "string") return rawType; @@ -1785,8 +1833,9 @@ function stringifyExampleValue(value: unknown): string { return JSON.stringify(value); } -function firstExampleValue(examples: unknown): unknown { - const firstExample = Object.values(toRecord(examples))[0]; +function firstExampleValue(examples: unknown, importState: ImportState): unknown { + if (Array.isArray(examples)) return examples[0]; + const firstExample = importState.resolve(Object.values(toRecord(examples))[0]); if (isRecord(firstExample) && "value" in firstExample) return firstExample.value; return firstExample; } @@ -1890,12 +1939,125 @@ class ImportState { return value; } - const resolved = value.$ref + const resolved = this.#resolveLocalReference(value.$ref); + + return this.resolve(resolved, nextVisitedRefs); + } + + /** Schema Objects allow `$ref` siblings in OpenAPI 3.1 and later. */ + resolveSchema( + value: unknown, + visitedRefs = new Set(), + compositionDepth = 0, + ): unknown { + if (!isRecord(value)) return value; + + let resolved: unknown = value; + const structureVisitedRefs = new Set(visitedRefs); + const siblingLayers: UnknownRecord[] = []; + while (isRecord(resolved) && typeof resolved.$ref === "string") { + const ref = resolved.$ref; + const siblings = Object.fromEntries( + Object.entries(resolved).filter(([key]) => key !== "$ref"), + ); + if (structureVisitedRefs.has(ref)) { + resolved = siblings; + break; + } + if (!ref.startsWith("#/")) { + this.#unresolvedRefs.add(ref); + break; + } + + structureVisitedRefs.add(ref); + siblingLayers.push(siblings); + resolved = this.#resolveLocalReference(ref); + } + if (!isRecord(resolved)) return resolved; + + for (let index = siblingLayers.length - 1; index >= 0; index--) { + resolved = this.#mergeSchemaObjects(resolved, siblingLayers[index] ?? {}); + } + return this.#mergeAllOfStructure(resolved, structureVisitedRefs, compositionDepth); + } + + #mergeAllOfStructure( + schema: UnknownRecord, + visitedRefs: Set, + depth: number, + ): UnknownRecord { + if (depth > MAX_SCHEMA_RESOLUTION_DEPTH) return schema; + const allOf = toArray(schema.allOf); + if (allOf.length === 0) return schema; + + const composed = allOf.reduce((merged, childSchema) => { + const child = this.resolveSchema(childSchema, new Set(visitedRefs), depth + 1); + if (!isRecord(child)) return merged; + const childStructure = Object.fromEntries( + Object.entries(child).filter(([key]) => key !== "allOf"), + ); + return this.#mergeSchemaObjects(merged, childStructure); + }, {}); + return this.#mergeSchemaObjects(composed, schema); + } + + #mergeSchemaObjects(base: UnknownRecord, overlay: UnknownRecord, depth = 0): UnknownRecord { + const merged: UnknownRecord = { ...base, ...overlay }; + if (depth > MAX_SCHEMA_RESOLUTION_DEPTH) return merged; + + const baseProperties = toRecord(base.properties); + const overlayProperties = toRecord(overlay.properties); + const propertyNames = new Set([...Object.keys(baseProperties), ...Object.keys(overlayProperties)]); + if (propertyNames.size > 0) { + merged.properties = Object.fromEntries( + [...propertyNames].map((name) => { + const baseProperty = baseProperties[name]; + const overlayProperty = overlayProperties[name]; + if (isRecord(baseProperty) && isRecord(overlayProperty)) { + return [name, this.#mergeSchemaObjects(baseProperty, overlayProperty, depth + 1)]; + } + return [name, overlayProperty ?? baseProperty]; + }), + ); + } + + const baseRequired = toArray(base.required).filter( + (name): name is string => typeof name === "string", + ); + const overlayRequired = toArray(overlay.required).filter( + (name): name is string => typeof name === "string", + ); + if (baseRequired.length > 0 || overlayRequired.length > 0) { + merged.required = [...new Set([...baseRequired, ...overlayRequired])]; + } + + const baseAllOf = toArray(base.allOf); + const overlayAllOf = toArray(overlay.allOf); + if (baseAllOf.length > 0 && overlayAllOf.length > 0) { + merged.allOf = [...baseAllOf, ...overlayAllOf]; + } + if (isRecord(base.xml) && isRecord(overlay.xml)) { + merged.xml = { ...base.xml, ...overlay.xml }; + } + if (isRecord(base.items) && isRecord(overlay.items)) { + merged.items = this.#mergeSchemaObjects(base.items, overlay.items, depth + 1); + } + if (isRecord(base.additionalProperties) && isRecord(overlay.additionalProperties)) { + merged.additionalProperties = this.#mergeSchemaObjects( + base.additionalProperties, + overlay.additionalProperties, + depth + 1, + ); + } + + return merged; + } + + #resolveLocalReference(ref: string): unknown { + return ref .slice(2) .split("/") .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) .reduce((current, part) => toRecord(current)[part], this.#spec); - - return this.resolve(resolved, nextVisitedRefs); } } diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 6caa55cf..0cad3c1b 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -66,6 +66,410 @@ describe("importer-openapi", () => { ]); }); + test("Imports OpenAPI 3.1 schema reference siblings and examples", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Reference Examples", version: "1.0.0" }, + paths: { + "/sibling": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Message", + example: { text: "overridden by sibling" }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/example-ref": { + post: { + requestBody: { + content: { + "application/json": { + examples: { sample: { $ref: "#/components/examples/Message" } }, + }, + }, + }, + responses: {}, + }, + }, + "/schema-values": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + fromExamples: { type: "string", examples: ["first", "second"] }, + fromConst: { const: "fixed" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/sibling-form": { + post: { + requestBody: { + content: { + "multipart/form-data": { + schema: { + $ref: "#/components/schemas/MessageForm", + required: ["extra"], + properties: { extra: { type: "string", default: "sibling" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { + schemas: { + Message: { type: "object", properties: { text: { default: "base" } } }, + MessageForm: { + $ref: "#/components/schemas/BaseMessageForm", + required: ["middle"], + properties: { + middle: { type: "string", default: "intermediate" }, + optional: { type: "string", default: "optional" }, + }, + }, + BaseMessageForm: { + type: "object", + required: ["base"], + properties: { base: { type: "string", default: "referenced" } }, + }, + }, + examples: { + Message: { value: { text: "resolved example" } }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests.map((request) => request.body)).toEqual([ + { text: JSON.stringify({ text: "overridden by sibling" }, null, 2) }, + { text: JSON.stringify({ text: "resolved example" }, null, 2) }, + { text: JSON.stringify({ fromExamples: "first", fromConst: "fixed" }, null, 2) }, + { + form: [ + { enabled: true, name: "base", value: "referenced" }, + { enabled: true, name: "middle", value: "intermediate" }, + { enabled: false, name: "optional", value: "optional" }, + { enabled: true, name: "extra", value: "sibling" }, + ], + }, + ]); + }); + + test("Merges colliding and composed schema properties", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Composed Schema Examples", version: "1.0.0" }, + paths: { + "/colliding-property": { + post: { + requestBody: { + content: { + "application/xml": { + schema: { + $ref: "#/components/schemas/BasePayload", + properties: { + shared: { + xml: { name: "renamed" }, + properties: { + local: { type: "string", default: "sibling" }, + }, + }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/composition-siblings": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + allOf: [ + { + type: "object", + properties: { + shared: { + type: "object", + properties: { + fromBranch: { type: "string", default: "branch" }, + }, + }, + branchOnly: { type: "string", default: "branch" }, + }, + }, + ], + properties: { + shared: { + type: "object", + properties: { + fromSibling: { type: "string", default: "sibling" }, + }, + }, + siblingOnly: { type: "string", default: "sibling" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/composition-form": { + post: { + requestBody: { + content: { + "multipart/form-data": { + schema: { + $ref: "#/components/schemas/ComposedForm", + required: ["siblingField"], + properties: { + siblingField: { type: "string", default: "sibling" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { + schemas: { + Shared: { + type: "object", + xml: { namespace: "urn:shared", prefix: "s" }, + properties: { + inherited: { type: "string", default: "base" }, + }, + }, + BasePayload: { + type: "object", + xml: { name: "payload" }, + properties: { + shared: { + $ref: "#/components/schemas/Shared", + xml: { name: "base-shared" }, + }, + }, + }, + ComposedForm: { + allOf: [ + { + type: "object", + required: ["baseField"], + properties: { + baseField: { type: "string", default: "base" }, + }, + }, + ], + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests.map((request) => request.body)).toEqual([ + { + text: + '' + + "basesibling" + + "", + }, + { + text: JSON.stringify( + { + shared: { fromBranch: "branch", fromSibling: "sibling" }, + branchOnly: "branch", + siblingOnly: "sibling", + }, + null, + 2, + ), + }, + { + form: [ + { enabled: true, name: "baseField", value: "base" }, + { enabled: true, name: "siblingField", value: "sibling" }, + ], + }, + ]); + }); + + test("Stops circular schema references when generating examples", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Circular References", version: "1.0.0" }, + paths: { + "/nodes": { + post: { + requestBody: { + content: { + "application/json": { schema: { $ref: "#/components/schemas/Node" } }, + }, + }, + responses: {}, + }, + }, + }, + components: { + schemas: { + Node: { + type: "object", + properties: { + name: { type: "string", example: "root" }, + child: { + $ref: "#/components/schemas/Node", + required: ["relationship"], + properties: { + relationship: { type: "string", example: "nested" }, + }, + }, + }, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ name: "root", child: { relationship: "nested" } }, null, 2), + }); + }); + + test("Bounds deeply colliding schema merges", async () => { + const depth = 12_000; + const nestedSchema = (leaf: string, levels: number) => + '{"type":"object","properties":{"next":'.repeat(levels) + leaf + "}}".repeat(levels); + const baseSchema = nestedSchema('{"type":"string","default":"base"}', depth); + const siblingProperty = nestedSchema('{"type":"string","example":"sibling"}', depth - 1); + + const imported = await convertOpenApi( + '{"openapi":"3.1.0","info":{"title":"Deep Merge","version":"1.0.0"},' + + '"paths":{"/deep":{"post":{"requestBody":{"content":{"application/json":' + + '{"schema":{"$ref":"#/components/schemas/DeepBase","properties":{"next":' + + siblingProperty + + '}}}}},"responses":{}}}},"components":{"schemas":{"DeepBase":' + + baseSchema + + "}}}", + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify( + { + next: { + next: { + next: { + next: { + next: { + next: { + next: { + next: { + next: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + null, + 2, + ), + }); + }); + + test("Bounds deeply nested inline allOf schemas", async () => { + const depth = 12_000; + const schema = '{"allOf":['.repeat(depth) + '{"type":"string","example":"leaf"}' + "]}".repeat(depth); + const imported = await convertOpenApi( + '{"openapi":"3.1.0","info":{"title":"Deep allOf","version":"1.0.0"},' + + '"paths":{"/deep":{"post":{"requestBody":{"content":{"application/json":{"schema":' + + schema + + '}}},"responses":{}}}}}', + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({}, null, 2), + }); + }); + + test("Resolves long local reference chains without truncating schemas", async () => { + const depth = 12_000; + const schemas: Record = { + [`Ref${depth}`]: { + type: "object", + properties: { target: { type: "string", example: "reached" } }, + }, + }; + for (let index = depth - 1; index >= 0; index--) { + schemas[`Ref${index}`] = { + $ref: `#/components/schemas/Ref${index + 1}`, + ...(index === 1 + ? { properties: { middle: { type: "string", example: "sibling" } } } + : {}), + }; + } + + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Long Reference Chain", version: "1.0.0" }, + paths: { + "/long-ref": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Ref0", + properties: { outer: { type: "string", example: "request" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { schemas }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify( + { target: "reached", middle: "sibling", outer: "request" }, + null, + 2, + ), + }); + }); + test("Imports requests directly from OpenAPI details", async () => { const imported = await convertOpenApi( JSON.stringify({