mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 04:44:12 +02:00
Resolve OpenAPI 3.1 reference examples correctly (#592)
This commit is contained in:
@@ -37,6 +37,7 @@ const BODY_CONTENT_TYPE_PREFERENCE = [
|
|||||||
"text/plain",
|
"text/plain",
|
||||||
];
|
];
|
||||||
const MAX_EXAMPLE_DEPTH = 8;
|
const MAX_EXAMPLE_DEPTH = 8;
|
||||||
|
const MAX_SCHEMA_RESOLUTION_DEPTH = MAX_EXAMPLE_DEPTH;
|
||||||
const MAX_EXAMPLE_PROPERTIES = 25;
|
const MAX_EXAMPLE_PROPERTIES = 25;
|
||||||
const MAX_DESCRIPTION_ITEMS = 40;
|
const MAX_DESCRIPTION_ITEMS = 40;
|
||||||
const MAX_NAME_LENGTH = 100;
|
const MAX_NAME_LENGTH = 100;
|
||||||
@@ -997,13 +998,16 @@ function parameterExample(parameter: UnknownRecord, importState: ImportState): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown {
|
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 (directExample != null) return directExample;
|
||||||
if (isRecord(parameter.content)) {
|
if (isRecord(parameter.content)) {
|
||||||
const mediaType = toRecord(Object.values(parameter.content)[0]);
|
const mediaType = toRecord(Object.values(parameter.content)[0]);
|
||||||
return mediaTypeExample(mediaType, importState);
|
return mediaTypeExample(mediaType, importState);
|
||||||
}
|
}
|
||||||
return schemaToExample(importState.resolve(parameter.schema), importState);
|
return schemaToExample(parameter.schema, importState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function importBody({
|
function importBody({
|
||||||
@@ -1034,7 +1038,7 @@ function importBody({
|
|||||||
(c): c is string => typeof c === "string",
|
(c): c is string => typeof c === "string",
|
||||||
);
|
);
|
||||||
const bodyType = contentType ?? "application/json";
|
const bodyType = contentType ?? "application/json";
|
||||||
const schema = importState.resolve(bodyParameter.schema);
|
const schema = importState.resolveSchema(bodyParameter.schema);
|
||||||
const example = schemaToExample(schema, importState);
|
const example = schemaToExample(schema, importState);
|
||||||
const isBinary = stringAt(schema, "format") === "binary";
|
const isBinary = stringAt(schema, "format") === "binary";
|
||||||
return {
|
return {
|
||||||
@@ -1090,7 +1094,7 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
|
|||||||
bodyType: contentType,
|
bodyType: contentType,
|
||||||
body: {
|
body: {
|
||||||
form: schemaToFormParameters(
|
form: schemaToFormParameters(
|
||||||
importState.resolve(mediaType.schema),
|
mediaType.schema,
|
||||||
importState,
|
importState,
|
||||||
isRecord(example) ? example : undefined,
|
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 =
|
const isBinary =
|
||||||
contentType === "application/octet-stream" || stringAt(schema, "format") === "binary";
|
contentType === "application/octet-stream" || stringAt(schema, "format") === "binary";
|
||||||
|
|
||||||
@@ -1151,10 +1155,10 @@ function valueToXml(
|
|||||||
elementName: string,
|
elementName: string,
|
||||||
isDocumentRoot = false,
|
isDocumentRoot = false,
|
||||||
): string {
|
): string {
|
||||||
const resolvedSchema = toRecord(importState.resolve(schema));
|
const resolvedSchema = toRecord(importState.resolveSchema(schema));
|
||||||
const schemaXml = toRecord(resolvedSchema.xml);
|
const schemaXml = toRecord(resolvedSchema.xml);
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const itemSchema = importState.resolve(resolvedSchema.items);
|
const itemSchema = importState.resolveSchema(resolvedSchema.items);
|
||||||
const shouldWrap = schemaXml.wrapped === true || isDocumentRoot;
|
const shouldWrap = schemaXml.wrapped === true || isDocumentRoot;
|
||||||
const itemName =
|
const itemName =
|
||||||
stringAt(toRecord(itemSchema).xml, "name") ??
|
stringAt(toRecord(itemSchema).xml, "name") ??
|
||||||
@@ -1165,7 +1169,7 @@ function valueToXml(
|
|||||||
if (isRecord(value)) {
|
if (isRecord(value)) {
|
||||||
const properties = toRecord(resolvedSchema.properties);
|
const properties = toRecord(resolvedSchema.properties);
|
||||||
const entries = Object.entries(value).map(([name, propertyValue]) => {
|
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) };
|
return { name, propertyValue, propertySchema, xml: toRecord(propertySchema.xml) };
|
||||||
});
|
});
|
||||||
const usedPrefixes = new Set(["xml", "xmlns"]);
|
const usedPrefixes = new Set(["xml", "xmlns"]);
|
||||||
@@ -1261,9 +1265,12 @@ function escapeXml(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
|
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;
|
if (directExample != null) return directExample;
|
||||||
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
return schemaToExample(mediaType.schema, importState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaToFormParameters(
|
function schemaToFormParameters(
|
||||||
@@ -1271,16 +1278,16 @@ function schemaToFormParameters(
|
|||||||
importState: ImportState,
|
importState: ImportState,
|
||||||
example?: UnknownRecord,
|
example?: UnknownRecord,
|
||||||
) {
|
) {
|
||||||
const resolvedSchema = toRecord(importState.resolve(schema));
|
const resolvedSchema = toRecord(importState.resolveSchema(schema));
|
||||||
const required = toArray(resolvedSchema.required).filter(
|
const required = toArray(resolvedSchema.required).filter(
|
||||||
(name): name is string => typeof name === "string",
|
(name): name is string => typeof name === "string",
|
||||||
);
|
);
|
||||||
const properties = Object.entries(toRecord(resolvedSchema.properties))
|
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);
|
.slice(0, MAX_EXAMPLE_PROPERTIES);
|
||||||
|
|
||||||
return properties.map(([name, property]) => {
|
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 propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState);
|
||||||
const base = {
|
const base = {
|
||||||
enabled: required.includes(name),
|
enabled: required.includes(name),
|
||||||
@@ -1301,12 +1308,20 @@ function schemaToExample(
|
|||||||
): unknown {
|
): unknown {
|
||||||
if (depth > MAX_EXAMPLE_DEPTH) return {};
|
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 (!isRecord(resolved)) return "";
|
||||||
|
if (closesReferenceCycle && Object.keys(resolved).length === 0) return {};
|
||||||
|
|
||||||
const explicitExample = firstPresent(
|
const explicitExample = firstPresent(
|
||||||
resolved.example,
|
resolved.example,
|
||||||
firstExampleValue(resolved.examples),
|
firstExampleValue(resolved.examples, importState),
|
||||||
|
resolved.const,
|
||||||
resolved.default,
|
resolved.default,
|
||||||
);
|
);
|
||||||
if (explicitExample != null) return explicitExample;
|
if (explicitExample != null) return explicitExample;
|
||||||
@@ -1314,45 +1329,40 @@ function schemaToExample(
|
|||||||
const enumValues = toArray(resolved.enum);
|
const enumValues = toArray(resolved.enum);
|
||||||
if (enumValues.length > 0) return enumValues[0];
|
if (enumValues.length > 0) return enumValues[0];
|
||||||
|
|
||||||
|
const propertyExample = schemaPropertiesToExample(
|
||||||
|
resolved,
|
||||||
|
importState,
|
||||||
|
depth,
|
||||||
|
nextVisitedRefs,
|
||||||
|
);
|
||||||
const allOf = toArray(resolved.allOf);
|
const allOf = toArray(resolved.allOf);
|
||||||
if (allOf.length > 0) {
|
if (allOf.length > 0) {
|
||||||
return allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
const compositionExample = allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
||||||
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
|
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 oneOf = toArray(resolved.oneOf);
|
||||||
const anyOf = toArray(resolved.anyOf);
|
const anyOf = toArray(resolved.anyOf);
|
||||||
if (oneOf.length > 0 || anyOf.length > 0) {
|
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);
|
const type = inferSchemaType(resolved);
|
||||||
if (type === "array") {
|
if (type === "array") {
|
||||||
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
|
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.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),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (type === "object") return propertyExample;
|
||||||
if (type === "integer" || type === "number") return 0;
|
if (type === "integer" || type === "number") return 0;
|
||||||
if (type === "boolean") return false;
|
if (type === "boolean") return false;
|
||||||
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
|
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
|
||||||
@@ -1360,6 +1370,44 @@ function schemaToExample(
|
|||||||
return "";
|
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 {
|
function inferSchemaType(schema: UnknownRecord): string {
|
||||||
const rawType = schema.type;
|
const rawType = schema.type;
|
||||||
if (typeof rawType === "string") return rawType;
|
if (typeof rawType === "string") return rawType;
|
||||||
@@ -1785,8 +1833,9 @@ function stringifyExampleValue(value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstExampleValue(examples: unknown): unknown {
|
function firstExampleValue(examples: unknown, importState: ImportState): unknown {
|
||||||
const firstExample = Object.values(toRecord(examples))[0];
|
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;
|
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
|
||||||
return firstExample;
|
return firstExample;
|
||||||
}
|
}
|
||||||
@@ -1890,12 +1939,125 @@ class ImportState {
|
|||||||
return value;
|
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<string>(),
|
||||||
|
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<string>,
|
||||||
|
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<UnknownRecord>((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)
|
.slice(2)
|
||||||
.split("/")
|
.split("/")
|
||||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||||
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
|
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
|
||||||
|
|
||||||
return this.resolve(resolved, nextVisitedRefs);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
'<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({
|
||||||
|
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<string, unknown> = {
|
||||||
|
[`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 () => {
|
test("Imports requests directly from OpenAPI details", async () => {
|
||||||
const imported = await convertOpenApi(
|
const imported = await convertOpenApi(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user