Compare commits

...
Author SHA1 Message Date
Gregory Schier ee775eec42 fix(openapi): merge composed schema examples 2026-08-19 13:32:40 -07:00
Gregory Schier ee47472d6f fix(openapi): resolve schema examples 2026-08-19 13:32:40 -07:00
2 changed files with 478 additions and 46 deletions
+190 -46
View File
@@ -997,13 +997,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 +1037,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 +1093,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 +1101,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 +1154,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") ??
@@ -1168,7 +1171,7 @@ function valueToXml(
const attributeNamespaces: UnknownRecord[] = [];
const children: string[] = [];
for (const [name, propertyValue] of Object.entries(value)) {
const propertySchema = toRecord(importState.resolve(properties[name]));
const propertySchema = toRecord(importState.resolveSchema(properties[name]));
const xml = toRecord(propertySchema.xml);
if (xml.attribute === true) {
attributes.push(
@@ -1223,9 +1226,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(
@@ -1233,16 +1239,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),
@@ -1263,12 +1269,19 @@ function schemaToExample(
): unknown {
if (depth > MAX_EXAMPLE_DEPTH) return {};
const resolved = importState.resolve(schema, visitedRefs);
const schemaRecord = toRecord(schema);
const ref = stringAt(schemaRecord, "$ref");
if (ref != null && visitedRefs.has(ref)) return {};
const nextVisitedRefs = new Set(visitedRefs);
if (ref != null) nextVisitedRefs.add(ref);
const resolved = importState.resolveSchema(schema, visitedRefs);
if (!isRecord(resolved)) return "";
const explicitExample = firstPresent(
resolved.example,
firstExampleValue(resolved.examples),
firstExampleValue(resolved.examples, importState),
resolved.const,
resolved.default,
);
if (explicitExample != null) return explicitExample;
@@ -1276,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 childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
const compositionExample = allOf.reduce<UnknownRecord>((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";
@@ -1322,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;
@@ -1747,8 +1793,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;
}
@@ -1852,12 +1899,109 @@ 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<string>()): unknown {
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);
}
return this.#mergeAllOfStructure(resolved, structureVisitedRefs);
}
#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 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);
}
if (isRecord(base.additionalProperties) && isRecord(overlay.additionalProperties)) {
merged.additionalProperties = this.#mergeSchemaObjects(
base.additionalProperties,
overlay.additionalProperties,
);
}
return merged;
}
#resolveLocalReference(ref: string): unknown {
return ref
.slice(2)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
return this.resolve(resolved, nextVisitedRefs);
}
}
@@ -66,6 +66,294 @@ 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" },
},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.body).toEqual({
text: JSON.stringify({ name: "root", child: {} }, null, 2),
});
});
test("Imports requests directly from OpenAPI details", async () => {
const imported = await convertOpenApi(
JSON.stringify({