mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-27 05:44:02 +02:00
Generate media-aware OpenAPI request bodies (#589)
This commit is contained in:
@@ -1034,14 +1034,13 @@ 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 example = schemaToExample(schema, importState);
|
||||||
|
const isBinary = stringAt(schema, "format") === "binary";
|
||||||
return {
|
return {
|
||||||
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
|
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
|
||||||
bodyType,
|
bodyType: isBinary ? "binary" : bodyType,
|
||||||
body: {
|
body: isBinary ? {} : { text: formatMediaTypeBody(bodyType, example, schema, importState) },
|
||||||
text: formatBodyText(
|
|
||||||
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,11 +1058,15 @@ function importBody({
|
|||||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
bodyType: contentType,
|
bodyType: contentType,
|
||||||
body: {
|
body: {
|
||||||
form: formParameters.map((p) => ({
|
form: formParameters.map((p) => {
|
||||||
enabled: p.required === true,
|
const base = {
|
||||||
name: stringAt(p, "name") ?? "",
|
enabled: p.required === true,
|
||||||
value: parameterExample(p, importState),
|
name: stringAt(p, "name") ?? "",
|
||||||
})),
|
};
|
||||||
|
return stringAt(p, "type") === "file"
|
||||||
|
? { ...base, file: "" }
|
||||||
|
: { ...base, value: parameterExample(p, importState) };
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1086,45 +1089,161 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
|
|||||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
bodyType: contentType,
|
bodyType: contentType,
|
||||||
body: {
|
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 {
|
return {
|
||||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
|
bodyType: isBinary ? "binary" : contentType,
|
||||||
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
|
body: isBinary ? {} : { text: formatMediaTypeBody(contentType, example, schema, importState) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseContentType(contentTypes: string[]): string | null {
|
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) {
|
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
|
||||||
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
|
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
|
||||||
if (exact != null) return exact;
|
if (exact != null) return exact;
|
||||||
|
if (preference === "application/json" && jsonType != null) return jsonType;
|
||||||
}
|
}
|
||||||
return contentTypes[0] ?? null;
|
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, "root", true);
|
||||||
|
}
|
||||||
|
return formatBodyText(example);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToXml(
|
||||||
|
value: unknown,
|
||||||
|
schema: unknown,
|
||||||
|
importState: ImportState,
|
||||||
|
elementName: string,
|
||||||
|
isDocumentRoot = false,
|
||||||
|
): string {
|
||||||
|
const resolvedSchema = toRecord(importState.resolve(schema));
|
||||||
|
const schemaXml = toRecord(resolvedSchema.xml);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const itemSchema = importState.resolve(resolvedSchema.items);
|
||||||
|
const shouldWrap = schemaXml.wrapped === true || isDocumentRoot;
|
||||||
|
const itemName =
|
||||||
|
stringAt(toRecord(itemSchema).xml, "name") ??
|
||||||
|
(shouldWrap ? stringAt(schemaXml, "name") ?? elementName : elementName);
|
||||||
|
const items = value.map((item) => valueToXml(item, itemSchema, importState, itemName)).join("");
|
||||||
|
return shouldWrap ? xmlElement(elementName, schemaXml, items) : items;
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const properties = toRecord(resolvedSchema.properties);
|
||||||
|
const attributes: string[] = [];
|
||||||
|
const attributeNamespaces: UnknownRecord[] = [];
|
||||||
|
const children: string[] = [];
|
||||||
|
for (const [name, propertyValue] of Object.entries(value)) {
|
||||||
|
const propertySchema = toRecord(importState.resolve(properties[name]));
|
||||||
|
const xml = toRecord(propertySchema.xml);
|
||||||
|
if (xml.attribute === true) {
|
||||||
|
attributes.push(
|
||||||
|
`${qualifiedXmlName(name, xml)}="${escapeXml(stringifyExampleValue(propertyValue))}"`,
|
||||||
|
);
|
||||||
|
attributeNamespaces.push(xml);
|
||||||
|
} else {
|
||||||
|
children.push(valueToXml(propertyValue, propertySchema, importState, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xmlElement(elementName, schemaXml, children.join(""), attributes, attributeNamespaces);
|
||||||
|
}
|
||||||
|
return xmlElement(elementName, schemaXml, escapeXml(stringifyExampleValue(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlElement(
|
||||||
|
fallbackName: string,
|
||||||
|
xml: UnknownRecord,
|
||||||
|
content: string,
|
||||||
|
attributes: string[] = [],
|
||||||
|
additionalNamespaces: UnknownRecord[] = [],
|
||||||
|
): string {
|
||||||
|
const name = qualifiedXmlName(fallbackName, xml);
|
||||||
|
const namespaces = new Map<string, string>();
|
||||||
|
for (const metadata of [xml, ...additionalNamespaces]) {
|
||||||
|
const namespace = stringAt(metadata, "namespace");
|
||||||
|
if (namespace == null) continue;
|
||||||
|
const prefix = stringAt(metadata, "prefix");
|
||||||
|
namespaces.set(prefix == null ? "xmlns" : `xmlns:${prefix}`, namespace);
|
||||||
|
}
|
||||||
|
const namespaceAttributes = [...namespaces].map(
|
||||||
|
([attribute, namespace]) => `${attribute}="${escapeXml(namespace)}"`,
|
||||||
|
);
|
||||||
|
const attributeText = [...namespaceAttributes, ...attributes].join(" ");
|
||||||
|
const openingTag = attributeText.length > 0 ? `<${name} ${attributeText}>` : `<${name}>`;
|
||||||
|
return `${openingTag}${content}</${name}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualifiedXmlName(fallbackName: string, xml: UnknownRecord): string {
|
||||||
|
const name = stringAt(xml, "name") ?? fallbackName;
|
||||||
|
const prefix = stringAt(xml, "prefix");
|
||||||
|
return prefix == null ? name : `${prefix}:${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
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));
|
||||||
if (directExample != null) return directExample;
|
if (directExample != null) return directExample;
|
||||||
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
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 resolvedSchema = toRecord(importState.resolve(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)).slice(
|
const properties = Object.entries(toRecord(resolvedSchema.properties))
|
||||||
0,
|
.filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true)
|
||||||
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.resolve(property));
|
||||||
const example = schemaToExample(resolvedProperty, importState);
|
const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState);
|
||||||
const base = {
|
const base = {
|
||||||
enabled: required.includes(name),
|
enabled: required.includes(name),
|
||||||
name,
|
name,
|
||||||
@@ -1132,7 +1251,7 @@ function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
|||||||
if (stringAt(resolvedProperty, "format") === "binary") {
|
if (stringAt(resolvedProperty, "format") === "binary") {
|
||||||
return { ...base, file: "" };
|
return { ...base, file: "" };
|
||||||
}
|
}
|
||||||
return { ...base, value: stringifyExampleValue(example) };
|
return { ...base, value: stringifyExampleValue(propertyExample) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,11 +1298,13 @@ function schemaToExample(
|
|||||||
const required = toArray(resolved.required).filter(
|
const required = toArray(resolved.required).filter(
|
||||||
(name): name is string => typeof name === "string",
|
(name): name is string => typeof name === "string",
|
||||||
);
|
);
|
||||||
const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => {
|
const properties = Object.entries(toRecord(resolved.properties))
|
||||||
const aRequired = required.includes(a);
|
.filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true)
|
||||||
const bRequired = required.includes(b);
|
.sort(([a], [b]) => {
|
||||||
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
const aRequired = required.includes(a);
|
||||||
});
|
const bRequired = required.includes(b);
|
||||||
|
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
properties
|
properties
|
||||||
|
|||||||
@@ -983,6 +983,218 @@ 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: "<user><name>Ada</name></user>",
|
||||||
|
});
|
||||||
|
expect(imported?.resources.httpRequests[1]?.body).toEqual({ text: '"hello"' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Honors XML array wrapping and namespaces", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.4",
|
||||||
|
info: { title: "XML Metadata Test", version: "1.0.0" },
|
||||||
|
paths: {
|
||||||
|
"/catalog": {
|
||||||
|
post: {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/xml": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
xml: { name: "catalog", namespace: "urn:catalog", prefix: "c" },
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
example: "42",
|
||||||
|
xml: { attribute: true, namespace: "urn:metadata", prefix: "m" },
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
example: ["one", "two"],
|
||||||
|
xml: {
|
||||||
|
name: "tags",
|
||||||
|
namespace: "urn:tags",
|
||||||
|
prefix: "t",
|
||||||
|
wrapped: true,
|
||||||
|
},
|
||||||
|
items: { type: "string", xml: { name: "tag" } },
|
||||||
|
},
|
||||||
|
aliases: {
|
||||||
|
type: "array",
|
||||||
|
example: ["Ada", "A"],
|
||||||
|
xml: { name: "ignored", wrapped: false },
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
xml: { name: "alias", namespace: "urn:aliases", prefix: "a" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/values": {
|
||||||
|
post: {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/xml": {
|
||||||
|
schema: {
|
||||||
|
type: "array",
|
||||||
|
example: ["one", "two"],
|
||||||
|
xml: { name: "values", wrapped: false },
|
||||||
|
items: { type: "string", xml: { name: "value" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||||
|
text:
|
||||||
|
'<c:catalog xmlns:c="urn:catalog" xmlns:m="urn:metadata" m:id="42">' +
|
||||||
|
'<t:tags xmlns:t="urn:tags"><tag>one</tag><tag>two</tag></t:tags>' +
|
||||||
|
'<a:alias xmlns:a="urn:aliases">Ada</a:alias>' +
|
||||||
|
'<a:alias xmlns:a="urn:aliases">A</a:alias>' +
|
||||||
|
"</c:catalog>",
|
||||||
|
});
|
||||||
|
expect(imported?.resources.httpRequests[1]?.body).toEqual({
|
||||||
|
text: "<values><value>one</value><value>two</value></values>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "<user><name>Ada</name></user>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Imports Swagger 2 basic auth and cookie API keys", async () => {
|
test("Imports Swagger 2 basic auth and cookie API keys", async () => {
|
||||||
const imported = await convertOpenApi(
|
const imported = await convertOpenApi(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user