mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-20 02:13:58 +02:00
fix(openapi): merge composed schema examples
This commit is contained in:
@@ -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<UnknownRecord>((merged, childSchema) => {
|
||||
const compositionExample = allOf.reduce<UnknownRecord>((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<string>,
|
||||
): 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<string>()): 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<string>): UnknownRecord {
|
||||
const allOf = toArray(schema.allOf);
|
||||
if (allOf.length === 0) return schema;
|
||||
|
||||
const composed = allOf.reduce<UnknownRecord>((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 {
|
||||
|
||||
@@ -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:
|
||||
'<payload><s:renamed xmlns:s="urn:shared">' +
|
||||
"<inherited>base</inherited><local>sibling</local>" +
|
||||
"</s:renamed></payload>",
|
||||
},
|
||||
{
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user