diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 90da4209..159e7110 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -1289,45 +1289,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 compositionExample = allOf.reduce((merged, childSchema) => { const childExample = schemaToExample(childSchema, importState, depth + 1, nextVisitedRefs); - return isRecord(childExample) ? { ...merged, ...childExample } : merged; + 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, nextVisitedRefs); + 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, nextVisitedRefs)]; } - 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.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, 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"; @@ -1335,6 +1330,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; @@ -1873,41 +1906,95 @@ class ImportState { /** Schema Objects allow `$ref` siblings in OpenAPI 3.1 and later. */ resolveSchema(value: unknown, visitedRefs = new Set()): unknown { - if (!isRecord(value) || typeof value.$ref !== "string") return value; - if (visitedRefs.has(value.$ref)) return {}; - if (!value.$ref.startsWith("#/")) { - this.#unresolvedRefs.add(value.$ref); - return value; + if (!isRecord(value)) return value; + + let resolved: UnknownRecord = value; + let structureVisitedRefs = visitedRefs; + if (typeof value.$ref === "string") { + if (visitedRefs.has(value.$ref)) return {}; + if (!value.$ref.startsWith("#/")) { + this.#unresolvedRefs.add(value.$ref); + return value; + } + + const nextVisitedRefs = new Set(visitedRefs); + nextVisitedRefs.add(value.$ref); + structureVisitedRefs = nextVisitedRefs; + const referenced = this.resolveSchema( + this.#resolveLocalReference(value.$ref), + nextVisitedRefs, + ); + if (!isRecord(referenced)) return referenced; + + const siblings = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$ref")); + resolved = this.#mergeSchemaObjects(referenced, siblings); } - const nextVisitedRefs = new Set(visitedRefs); - nextVisitedRefs.add(value.$ref); - const resolved = this.resolveSchema( - this.#resolveLocalReference(value.$ref), - nextVisitedRefs, - ); - if (!isRecord(resolved)) return resolved; + return this.#mergeAllOfStructure(resolved, structureVisitedRefs); + } - const siblings = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$ref")); - const resolvedProperties = toRecord(resolved.properties); - const siblingProperties = toRecord(siblings.properties); - const resolvedRequired = toArray(resolved.required).filter( + #mergeAllOfStructure(schema: UnknownRecord, visitedRefs: Set): UnknownRecord { + 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)); + 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): UnknownRecord { + const merged: UnknownRecord = { ...base, ...overlay }; + 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)]; + } + return [name, overlayProperty ?? baseProperty]; + }), + ); + } + + const baseRequired = toArray(base.required).filter( (name): name is string => typeof name === "string", ); - const siblingRequired = toArray(siblings.required).filter( + 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])]; + } - return { - ...resolved, - ...siblings, - ...(Object.keys(resolvedProperties).length > 0 || Object.keys(siblingProperties).length > 0 - ? { properties: { ...resolvedProperties, ...siblingProperties } } - : {}), - ...(resolvedRequired.length > 0 || siblingRequired.length > 0 - ? { required: [...new Set([...resolvedRequired, ...siblingRequired])] } - : {}), - }; + 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); + } + if (isRecord(base.additionalProperties) && isRecord(overlay.additionalProperties)) { + merged.additionalProperties = this.#mergeSchemaObjects( + base.additionalProperties, + overlay.additionalProperties, + ); + } + + return merged; } #resolveLocalReference(ref: string): unknown { diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index f7a53737..32df9fa7 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -173,6 +173,151 @@ describe("importer-openapi", () => { ]); }); + 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({