From 18a01ccc73d7f422be25ddcfb23bb706b2e6a360 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 06:54:47 -0700 Subject: [PATCH] fix(openapi): generate valid request bodies --- plugins/importer-openapi/src/index.ts | 137 +++++++++++++++---- plugins/importer-openapi/tests/index.test.ts | 130 ++++++++++++++++++ 2 files changed, 241 insertions(+), 26 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 49658049..656562f1 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -809,14 +809,13 @@ function importBody({ (c): c is string => typeof c === "string", ); const bodyType = contentType ?? "application/json"; + const schema = importState.resolve(bodyParameter.schema); + const example = schemaToExample(schema, importState); + const isBinary = stringAt(schema, "format") === "binary"; return { headers: [{ enabled: true, name: "Content-Type", value: bodyType }], - bodyType, - body: { - text: formatBodyText( - schemaToExample(importState.resolve(bodyParameter.schema), importState), - ), - }, + bodyType: isBinary ? "binary" : bodyType, + body: isBinary ? {} : { text: formatMediaTypeBody(bodyType, example, schema, importState) }, }; } @@ -834,11 +833,15 @@ function importBody({ headers: [{ enabled: true, name: "Content-Type", value: contentType }], bodyType: contentType, body: { - form: formParameters.map((p) => ({ - enabled: p.required === true, - name: stringAt(p, "name") ?? "", - value: parameterExample(p, importState), - })), + form: formParameters.map((p) => { + const base = { + enabled: p.required === true, + name: stringAt(p, "name") ?? "", + }; + return stringAt(p, "type") === "file" + ? { ...base, file: "" } + : { ...base, value: parameterExample(p, importState) }; + }), }, }; } @@ -861,45 +864,125 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) headers: [{ enabled: true, name: "Content-Type", value: contentType }], bodyType: contentType, body: { - form: schemaToFormParameters(importState.resolve(mediaType.schema), importState), + form: schemaToFormParameters( + importState.resolve(mediaType.schema), + importState, + isRecord(example) ? example : undefined, + ), }, }; } + const schema = importState.resolve(mediaType.schema); + const isBinary = + contentType === "application/octet-stream" || stringAt(schema, "format") === "binary"; + return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], - bodyType: contentType === "application/octet-stream" ? "binary" : contentType, - body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) }, + bodyType: isBinary ? "binary" : contentType, + body: isBinary ? {} : { text: formatMediaTypeBody(contentType, example, schema, importState) }, }; } function chooseContentType(contentTypes: string[]): string | null { + const jsonType = contentTypes.find((contentType) => { + const normalized = contentType.toLowerCase().split(";", 1)[0]; + return normalized?.endsWith("+json") === true; + }); for (const preference of BODY_CONTENT_TYPE_PREFERENCE) { const exact = contentTypes.find((c) => c.toLowerCase() === preference); if (exact != null) return exact; + if (preference === "application/json" && jsonType != null) return jsonType; } return contentTypes[0] ?? null; } +function formatMediaTypeBody( + contentType: string, + example: unknown, + schema: unknown, + importState: ImportState, +): string { + const normalized = contentType.toLowerCase().split(";", 1)[0] ?? ""; + if (normalized === "application/json" || normalized.endsWith("+json")) { + return JSON.stringify(example, null, 2) ?? ""; + } + if ( + normalized === "application/xml" || + normalized === "text/xml" || + normalized.endsWith("+xml") + ) { + return typeof example === "string" + ? example + : valueToXml(example, schema, importState, stringAt(toRecord(schema).xml, "name") ?? "root"); + } + return formatBodyText(example); +} + +function valueToXml( + value: unknown, + schema: unknown, + importState: ImportState, + elementName: string, +): string { + const resolvedSchema = toRecord(importState.resolve(schema)); + if (Array.isArray(value)) { + const itemSchema = importState.resolve(resolvedSchema.items); + const itemName = stringAt(toRecord(itemSchema).xml, "name") ?? "item"; + const items = value.map((item) => valueToXml(item, itemSchema, importState, itemName)).join(""); + return `<${elementName}>${items}`; + } + if (isRecord(value)) { + const properties = toRecord(resolvedSchema.properties); + const attributes: string[] = []; + const children: string[] = []; + for (const [name, propertyValue] of Object.entries(value)) { + const propertySchema = toRecord(importState.resolve(properties[name])); + const xml = toRecord(propertySchema.xml); + const xmlName = stringAt(xml, "name") ?? name; + if (xml.attribute === true) { + attributes.push(`${xmlName}="${escapeXml(stringifyExampleValue(propertyValue))}"`); + } else { + children.push(valueToXml(propertyValue, propertySchema, importState, xmlName)); + } + } + const attributeText = attributes.length > 0 ? ` ${attributes.join(" ")}` : ""; + return `<${elementName}${attributeText}>${children.join("")}`; + } + return `<${elementName}>${escapeXml(stringifyExampleValue(value))}`; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown { const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples)); if (directExample != null) return directExample; return schemaToExample(importState.resolve(mediaType.schema), importState); } -function schemaToFormParameters(schema: unknown, importState: ImportState) { +function schemaToFormParameters( + schema: unknown, + importState: ImportState, + example?: UnknownRecord, +) { const resolvedSchema = toRecord(importState.resolve(schema)); const required = toArray(resolvedSchema.required).filter( (name): name is string => typeof name === "string", ); - const properties = Object.entries(toRecord(resolvedSchema.properties)).slice( - 0, - MAX_EXAMPLE_PROPERTIES, - ); + const properties = Object.entries(toRecord(resolvedSchema.properties)) + .filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true) + .slice(0, MAX_EXAMPLE_PROPERTIES); return properties.map(([name, property]) => { const resolvedProperty = toRecord(importState.resolve(property)); - const example = schemaToExample(resolvedProperty, importState); + const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState); const base = { enabled: required.includes(name), name, @@ -907,7 +990,7 @@ function schemaToFormParameters(schema: unknown, importState: ImportState) { if (stringAt(resolvedProperty, "format") === "binary") { return { ...base, file: "" }; } - return { ...base, value: stringifyExampleValue(example) }; + return { ...base, value: stringifyExampleValue(propertyExample) }; }); } @@ -954,11 +1037,13 @@ function schemaToExample( const required = toArray(resolved.required).filter( (name): name is string => typeof name === "string", ); - const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => { - const aRequired = required.includes(a); - const bRequired = required.includes(b); - return aRequired === bRequired ? 0 : aRequired ? -1 : 1; - }); + 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 diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 35250182..a1ac2375 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -673,6 +673,136 @@ describe("importer-openapi", () => { ); }); + test("Serializes request examples according to their media type", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Media Type Test", version: "1.0.0" }, + paths: { + "/xml": { + post: { + requestBody: { + content: { + "application/xml": { + schema: { + type: "object", + xml: { name: "user" }, + properties: { name: { type: "string", example: "Ada" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/json-string": { + post: { + requestBody: { + content: { "application/json": { schema: { type: "string", example: "hello" } } }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: "Ada", + }); + expect(imported?.resources.httpRequests[1]?.body).toEqual({ text: '"hello"' }); + }); + + test("Omits read-only properties from generated request bodies", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Read Only Test", version: "1.0.0" }, + paths: { + "/users": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string", readOnly: true, example: "server-id" }, + name: { type: "string", example: "Ada" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ name: "Ada" }, null, 2), + }); + }); + + test("Imports Swagger 2 file parameters as file form entries", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + swagger: "2.0", + info: { title: "File Upload Test", version: "1.0.0" }, + host: "example.com", + consumes: ["multipart/form-data"], + paths: { + "/upload": { + post: { + parameters: [{ name: "upload", in: "formData", required: true, type: "file" }], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + form: [{ enabled: true, name: "upload", file: "" }], + }); + }); + + test("Serializes Swagger 2 XML request bodies as XML", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + swagger: "2.0", + info: { title: "Swagger XML Test", version: "1.0.0" }, + host: "example.com", + consumes: ["application/xml"], + paths: { + "/users": { + post: { + parameters: [ + { + name: "user", + in: "body", + required: true, + schema: { + type: "object", + xml: { name: "user" }, + properties: { name: { type: "string", example: "Ada" } }, + }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: "Ada", + }); + }); + test("Imports Swagger 2 basic auth and cookie API keys", async () => { const imported = await convertOpenApi( JSON.stringify({