From 4eebd606ef55f5e13a7577e9d5a67fdc2365859b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 07:16:44 -0700 Subject: [PATCH] Fix OpenAPI importer issues found by spec review (#599) --- plugins/importer-openapi/src/index.ts | 354 +++++++++++++----- .../tests/__snapshots__/index.test.ts.snap | 90 ++--- plugins/importer-openapi/tests/index.test.ts | 325 +++++++++++++++- 3 files changed, 617 insertions(+), 152 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 920c0288..ea1acac0 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -200,16 +200,14 @@ export async function convertOpenApi(contents: string): Promise importState.resolve(p)) + .filter(isRecord) + .filter((p) => stringAt(p, "in") === "path" && stringAt(p, "name") != null) + .map((p) => [stringAt(p, "name") as string, parameterExample(p, importState)] as const), + ); + const { url, placeholderNames } = buildOperationUrl( + operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }), + path, + pathExampleValues, + ); const urlParameters = [ - ...importUrlParameters({ importState, parameters }), + ...importUrlParameters({ importState, parameters, placeholderNames }), ...authentication.urlParameters, ]; const headers = mergeHeaders( authentication.headers, importHeaderParameters({ importState, parameters }), + importCookieHeader({ importState, parameters }), body.headers, importAcceptHeader({ importState, operation, spec }), ); @@ -333,10 +344,7 @@ function importOperation({ name: importOperationName(operation, method, path), description, method: method.toUpperCase(), - url: buildOperationUrl( - operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }), - path, - ), + url, urlParameters, headers, body: body.body, @@ -466,11 +474,25 @@ function parseSpec(contents: string): unknown { } } +/** + * The spec requires string versions, but unquoted YAML like `swagger: 2.0` + * parses as a number and such documents are common enough to accept. + */ function isOpenApiSpec(value: unknown): value is UnknownRecord { const spec = toRecord(value); - const openapi = stringAt(spec, "openapi"); - const swagger = stringAt(spec, "swagger"); - return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0"); + const openapi = versionString(spec.openapi); + return isRecord(spec.paths) && (/^3(\.|$)/.test(openapi ?? "") || isSwagger2(spec)); +} + +function isSwagger2(spec: UnknownRecord): boolean { + const swagger = versionString(spec.swagger); + return swagger === "2.0" || swagger === "2"; +} + +function versionString(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return undefined; } function importInfoDescription(info: UnknownRecord): string | undefined { @@ -645,8 +667,27 @@ function findOrCreateFolderId({ return folder.id; } -function buildOperationUrl(baseUrl: string, path: string): string { - return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1")); +/** + * Yaak's `:name` placeholders only substitute when they span a whole path + * segment. A template elsewhere in a segment, like `/report.{format}`, would + * import as text that never substitutes, and its leftover parameter would then + * be sent as a query parameter — so those get their example inlined instead. + */ +function buildOperationUrl( + baseUrl: string, + path: string, + inlineValues: Map, +): { url: string; placeholderNames: Set } { + const placeholderNames = new Set(); + const converted = path.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, (_, prefix, name) => { + placeholderNames.add(name); + return `${prefix}:${name}`; + }); + const inlined = converted.replaceAll(/{([^}/]+)}/g, (match, name) => { + const value = inlineValues.get(name); + return value == null || value === "" ? match : value; + }); + return { url: joinUrlParts(baseUrl, inlined), placeholderNames }; } function importBaseUrl(spec: UnknownRecord): string { @@ -660,7 +701,7 @@ function importBaseUrl(spec: UnknownRecord): string { if (host == null) return stringAt(spec, "basePath") ?? ""; const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https"; - return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? ""); + return trimTrailingSlashes(joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "")); } function importServerEnvironments(spec: UnknownRecord): { name: string; url: string }[] { @@ -673,8 +714,7 @@ function importServerEnvironments(spec: UnknownRecord): { name: string; url: str .filter(({ url }) => url.length > 0); if (servers.length === 0) { const hasSwaggerServer = - stringAt(spec, "swagger") === "2.0" && - (stringAt(spec, "host") != null || stringAt(spec, "basePath") != null); + isSwagger2(spec) && (stringAt(spec, "host") != null || stringAt(spec, "basePath") != null); return [ { name: hasSwaggerServer ? "Server 1" : "Default", @@ -705,12 +745,17 @@ function serverUrlOrigin(value: string): string { } } +/** + * Request URLs are `${[baseUrl]}/path`, so a trailing slash here would put a + * double slash on the wire. Trimming also turns a bare `/` server into "", + * which renders the same URLs without a protocol-relative `//path`. + */ function interpolateServerUrl(server: UnknownRecord): string { let url = stringAt(server, "url") ?? ""; for (const [name, variable] of Object.entries(toRecord(server.variables))) { url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default)); } - return url; + return trimTrailingSlashes(url); } function joinUrlParts(baseUrl: string, path: string): string { @@ -733,16 +778,25 @@ function trimTrailingSlashes(value: string): string { function importUrlParameters({ importState, parameters, + placeholderNames, }: { importState: ImportState; parameters: unknown[]; + placeholderNames: Set; }): HttpUrlParameter[] { return parameters .map((p) => importState.resolve(p)) .filter(isRecord) - .filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path") + .filter( + (p) => + stringAt(p, "in") === "query" || + (stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")), + ) .map((p) => ({ - enabled: p.required === true, + // Path parameters are required by definition, and a disabled one would + // leave the literal `:name` in the sent URL even for sloppy specs that + // omit `required: true` + enabled: p.required === true || stringAt(p, "in") === "path", name: stringAt(p, "in") === "path" ? `:${stringAt(p, "name") ?? ""}` @@ -752,6 +806,11 @@ function importUrlParameters({ .filter(({ name }) => name.length > 0); } +// The spec says header parameters with these names SHALL be ignored; Accept and +// Content-Type come from the operation's media types, Authorization from its +// security requirements +const IGNORED_HEADER_PARAMETERS = new Set(["accept", "authorization", "content-type"]); + function importHeaderParameters({ importState, parameters, @@ -763,6 +822,7 @@ function importHeaderParameters({ .map((p) => importState.resolve(p)) .filter(isRecord) .filter((p) => stringAt(p, "in") === "header") + .filter((p) => !IGNORED_HEADER_PARAMETERS.has((stringAt(p, "name") ?? "").toLowerCase())) .map((p) => ({ enabled: p.required === true, name: stringAt(p, "name") ?? "", @@ -771,10 +831,47 @@ function importHeaderParameters({ .filter(({ name }) => name.length > 0); } +/** Yaak has no cookie parameter row, so cookie parameters become the header they would produce */ +function importCookieHeader({ + importState, + parameters, +}: { + importState: ImportState; + parameters: unknown[]; +}): HttpRequestHeader[] { + const cookieParameters = parameters + .map((p) => importState.resolve(p)) + .filter(isRecord) + .filter((p) => stringAt(p, "in") === "cookie") + .filter((p) => (stringAt(p, "name") ?? "").length > 0); + if (cookieParameters.length === 0) return []; + + return [ + { + enabled: cookieParameters.some((p) => p.required === true), + name: "Cookie", + value: cookieParameters + .map((p) => `${stringAt(p, "name")}=${parameterExample(p, importState)}`) + .join("; "), + }, + ]; +} + function parameterExample(parameter: UnknownRecord, importState: ImportState): string { - const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples)); + const directExample = firstPresent( + parameter.example, + firstExampleValue(parameter.examples, importState), + ); if (directExample != null) return stringifyExampleValue(directExample); - return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState)); + const example = stringifyExampleValue( + schemaToExample(importState.resolve(parameter.schema), importState), + ); + // An empty path segment makes a URL that matches nothing, so the name at + // least keeps the request sendable and shows what belongs there + if (example === "" && stringAt(parameter, "in") === "path") { + return stringAt(parameter, "name") ?? ""; + } + return example; } function importBody({ @@ -801,13 +898,13 @@ function importBody({ .map((p) => importState.resolve(p)) .find((p) => isRecord(p) && stringAt(p, "in") === "body"); if (isRecord(bodyParameter)) { - const contentType = toArray(operation.consumes ?? spec.consumes).find( - (c): c is string => typeof c === "string", - ); - const bodyType = contentType ?? "application/json"; + const contentType = + toArray(operation.consumes ?? spec.consumes).find( + (c): c is string => typeof c === "string", + ) ?? "application/json"; return { - headers: [{ enabled: true, name: "Content-Type", value: bodyType }], - bodyType, + headers: [{ enabled: true, name: "Content-Type", value: contentType }], + bodyType: yaakBodyType(contentType), body: { text: formatBodyText( schemaToExample(importState.resolve(bodyParameter.schema), importState), @@ -847,15 +944,12 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) if (contentType == null) return { headers: [], body: {}, bodyType: null }; const mediaType = toRecord(content[contentType]); - const example = mediaTypeExample(mediaType, importState); + const bodyType = yaakBodyType(contentType); - if ( - contentType === "application/x-www-form-urlencoded" || - contentType === "multipart/form-data" - ) { + if (bodyType === "application/x-www-form-urlencoded" || bodyType === "multipart/form-data") { return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], - bodyType: contentType, + bodyType, body: { form: schemaToFormParameters(importState.resolve(mediaType.schema), importState), }, @@ -864,34 +958,65 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) 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, + body: + bodyType === "binary" + ? {} + : { text: formatBodyText(mediaTypeExample(mediaType, importState)) }, }; } function chooseContentType(contentTypes: string[]): string | null { for (const preference of BODY_CONTENT_TYPE_PREFERENCE) { - const exact = contentTypes.find((c) => c.toLowerCase() === preference); + const exact = contentTypes.find((c) => mediaTypeOf(c) === preference); if (exact != null) return exact; } return contentTypes[0] ?? null; } +function mediaTypeOf(contentType: string): string { + return contentType.toLowerCase().split(";")[0]?.trim() ?? ""; +} + +/** + * Yaak's body editors key off a fixed set of body types, while the Content-Type + * header keeps the spec's exact media type. Anything unrecognized becomes + * "other", the app's plain-text body with an explicit Content-Type. + */ +function yaakBodyType(contentType: string): string { + const mediaType = mediaTypeOf(contentType); + if (mediaType === "application/json" || mediaType.endsWith("+json")) return "application/json"; + if (mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml")) { + return "text/xml"; + } + if (mediaType === "application/x-www-form-urlencoded" || mediaType === "multipart/form-data") { + return mediaType; + } + if (mediaType === "application/octet-stream") return "binary"; + return "other"; +} + 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); } function schemaToFormParameters(schema: unknown, importState: ImportState) { 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 sources = [ + ...toArray(resolvedSchema.allOf).map((s) => toRecord(importState.resolve(s))), + resolvedSchema, + ]; + const required = sources + .flatMap((s) => toArray(s.required)) + .filter((name): name is string => typeof name === "string"); + const properties = [ + ...new Map(sources.flatMap((s) => Object.entries(toRecord(s.properties)))).entries(), + ].slice(0, MAX_EXAMPLE_PROPERTIES); return properties.map(([name, property]) => { const resolvedProperty = toRecord(importState.resolve(property)); @@ -920,20 +1045,22 @@ function schemaToExample( const explicitExample = firstPresent( resolved.example, - firstExampleValue(resolved.examples), + firstExampleValue(resolved.examples, importState), resolved.default, ); - if (explicitExample != null) return explicitExample; + if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved); const enumValues = toArray(resolved.enum); if (enumValues.length > 0) return enumValues[0]; const allOf = toArray(resolved.allOf); if (allOf.length > 0) { - return allOf.reduce((merged, childSchema) => { + const merged = allOf.reduce((merged, childSchema) => { const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs); return isRecord(childExample) ? { ...merged, ...childExample } : merged; }, {}); + // Sibling properties are their own constraint alongside the allOf branches + return { ...merged, ...objectPropertiesExample(resolved, importState, depth, visitedRefs) }; } const oneOf = toArray(resolved.oneOf); @@ -947,29 +1074,76 @@ function schemaToExample( 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)).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 objectPropertiesExample(resolved, importState, depth, visitedRefs); } if (type === "integer" || type === "number") return 0; if (type === "boolean") return false; - if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z"; - if (stringAt(resolved, "format") === "date") return "2026-01-01"; - return ""; + return FORMAT_EXAMPLES[stringAt(resolved, "format") ?? ""] ?? ""; +} + +const FORMAT_EXAMPLES: Record = { + "date-time": "2026-01-01T00:00:00Z", + date: "2026-01-01", + email: "user@example.com", + hostname: "example.com", + ipv4: "127.0.0.1", + ipv6: "::1", + uri: "https://example.com", + url: "https://example.com", + uuid: "00000000-0000-0000-0000-000000000000", +}; + +/** + * YAML coerces unquoted scalars, so specs routinely carry `example: 12345` on a + * `type: string` field. Sending the number fails the spec's own schema, and the + * declared type is the author's stated intent. + */ +function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown { + const rawType = schema.type; + const declared = + typeof rawType === "string" + ? rawType + : Array.isArray(rawType) + ? rawType.find((t) => t !== "null") + : null; + + if (declared === "string" && (typeof example === "number" || typeof example === "boolean")) { + return String(example); + } + if ( + (declared === "integer" || declared === "number") && + typeof example === "string" && + example.trim() !== "" && + Number.isFinite(Number(example)) + ) { + return Number(example); + } + return example; +} + +function objectPropertiesExample( + schema: UnknownRecord, + importState: ImportState, + depth: number, + visitedRefs: Set, +): UnknownRecord { + const required = toArray(schema.required).filter( + (name): name is string => typeof name === "string", + ); + const properties = Object.entries(toRecord(schema.properties)).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 inferSchemaType(schema: UnknownRecord): string { @@ -1394,8 +1568,13 @@ function stringifyExampleValue(value: unknown): string { return JSON.stringify(value); } -function firstExampleValue(examples: unknown): unknown { - const firstExample = Object.values(toRecord(examples))[0]; +/** + * `examples` is a map of (possibly `$ref`) Example objects on media types and + * parameters, but a plain array of values on OpenAPI 3.1 schemas. + */ +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; } @@ -1425,23 +1604,6 @@ function isPresent(value: T | null | undefined): value is T { return value != null && value !== ""; } -/** Recursively render all nested object properties */ -function convertTemplateSyntax(obj: T): T { - if (typeof obj === "string") { - // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax - return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T; - } - if (Array.isArray(obj) && obj != null) { - return obj.map(convertTemplateSyntax) as T; - } - if (typeof obj === "object" && obj != null) { - return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]), - ) as T; - } - return obj; -} - function deleteUndefinedAttrs(obj: T): T { if (Array.isArray(obj) && obj != null) { return obj.map(deleteUndefinedAttrs) as T; @@ -1503,7 +1665,11 @@ class ImportState { .slice(2) .split("/") .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) - .reduce((current, part) => toRecord(current)[part], this.#spec); + .reduce( + (current, part) => + Array.isArray(current) ? current[Number(part)] : toRecord(current)[part], + this.#spec, + ); return this.resolve(resolved, nextVisitedRefs); } diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index 86a2eff5..0f88d57b 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -163,18 +163,13 @@ Responses: "model": "http_request", "name": "Retrieve one version of a particular API", "sortPriority": 5, - "url": "\${[baseUrl]}/specs/:provider/:api.json", + "url": "\${[baseUrl]}/specs/:provider/2.1.0.json", "urlParameters": [ { "enabled": true, "name": ":provider", "value": "apis.guru", }, - { - "enabled": true, - "name": ":api", - "value": "2.1.0", - }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, @@ -207,7 +202,7 @@ Responses: "model": "http_request", "name": "Retrieve one version of a particular API with a serviceName.", "sortPriority": 6, - "url": "\${[baseUrl]}/specs/:provider/:service/:api.json", + "url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json", "urlParameters": [ { "enabled": true, @@ -219,11 +214,6 @@ Responses: "name": ":service", "value": "graph", }, - { - "enabled": true, - "name": ":api", - "value": "2.1.0", - }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, @@ -256,14 +246,8 @@ Responses: "model": "http_request", "name": "List all APIs for a particular provider", "sortPriority": 7, - "url": "\${[baseUrl]}/:provider.json", - "urlParameters": [ - { - "enabled": true, - "name": ":provider", - "value": "apis.guru", - }, - ], + "url": "\${[baseUrl]}/apis.guru.json", + "urlParameters": [], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, { @@ -631,7 +615,7 @@ Responses: { "enabled": true, "name": ":anything", - "value": "", + "value": "anything", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -660,7 +644,7 @@ Responses: { "enabled": true, "name": ":anything", - "value": "", + "value": "anything", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -689,7 +673,7 @@ Responses: { "enabled": true, "name": ":anything", - "value": "", + "value": "anything", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -718,7 +702,7 @@ Responses: { "enabled": true, "name": ":anything", - "value": "", + "value": "anything", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -747,7 +731,7 @@ Responses: { "enabled": true, "name": ":anything", - "value": "", + "value": "anything", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -776,7 +760,7 @@ Responses: { "enabled": true, "name": ":anything", - "value": "", + "value": "anything", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -836,12 +820,12 @@ Responses: { "enabled": true, "name": ":user", - "value": "", + "value": "user", }, { "enabled": true, "name": ":passwd", - "value": "", + "value": "passwd", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -860,13 +844,7 @@ Responses: - 200: Sucessful authentication. - 401: Unsuccessful authentication.", "folderId": "GENERATE_ID::FOLDER_1", - "headers": [ - { - "enabled": false, - "name": "Authorization", - "value": "", - }, - ], + "headers": [], "id": "GENERATE_ID::HTTP_REQUEST_15", "method": "GET", "model": "http_request", @@ -1093,12 +1071,12 @@ Responses: { "enabled": true, "name": ":name", - "value": "", + "value": "name", }, { "enabled": true, "name": ":value", - "value": "", + "value": "value", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -1364,17 +1342,17 @@ Responses: { "enabled": true, "name": ":qop", - "value": "", + "value": "qop", }, { "enabled": true, "name": ":user", - "value": "", + "value": "user", }, { "enabled": true, "name": ":passwd", - "value": "", + "value": "passwd", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -1407,17 +1385,17 @@ Responses: { "enabled": true, "name": ":qop", - "value": "", + "value": "qop", }, { "enabled": true, "name": ":user", - "value": "", + "value": "user", }, { "enabled": true, "name": ":passwd", - "value": "", + "value": "passwd", }, { "enabled": true, @@ -1457,17 +1435,17 @@ Responses: { "enabled": true, "name": ":qop", - "value": "", + "value": "qop", }, { "enabled": true, "name": ":user", - "value": "", + "value": "user", }, { "enabled": true, "name": ":passwd", - "value": "", + "value": "passwd", }, { "enabled": true, @@ -1587,7 +1565,7 @@ Responses: { "enabled": true, "name": ":etag", - "value": "", + "value": "etag", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -1678,12 +1656,12 @@ Responses: { "enabled": true, "name": ":user", - "value": "", + "value": "user", }, { "enabled": true, "name": ":passwd", - "value": "", + "value": "passwd", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2317,7 +2295,7 @@ Responses: { "enabled": true, "name": ":codes", - "value": "", + "value": "codes", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2350,7 +2328,7 @@ Responses: { "enabled": true, "name": ":codes", - "value": "", + "value": "codes", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2383,7 +2361,7 @@ Responses: { "enabled": true, "name": ":codes", - "value": "", + "value": "codes", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2416,7 +2394,7 @@ Responses: { "enabled": true, "name": ":codes", - "value": "", + "value": "codes", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2449,7 +2427,7 @@ Responses: { "enabled": true, "name": ":codes", - "value": "", + "value": "codes", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2482,7 +2460,7 @@ Responses: { "enabled": true, "name": ":codes", - "value": "", + "value": "codes", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", @@ -2777,7 +2755,7 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = ` "variables": [ { "name": "baseUrl", - "value": "http://xkcd.com/", + "value": "http://xkcd.com", }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index deabbfba..ec441265 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -787,7 +787,8 @@ describe("importer-openapi", () => { expect(imported?.resources.httpRequests[0]).toEqual( expect.objectContaining({ - bodyType: "application/xml", + // Yaak's XML body type; the header keeps the spec's media type + bodyType: "text/xml", headers: expect.arrayContaining([ { enabled: true, name: "Content-Type", value: "application/xml" }, ]), @@ -840,7 +841,7 @@ describe("importer-openapi", () => { expect.objectContaining({ name: "Server 1", variables: [ - { name: "baseUrl", value: "https://example.com/" }, + { name: "baseUrl", value: "https://example.com" }, { name: "auth_basic_auth_username", value: "" }, { name: "auth_basic_auth_password", value: "" }, { name: "auth_cookie_key_key", value: "" }, @@ -987,6 +988,326 @@ describe("importer-openapi", () => { ]); }); + test("Resolves references that point into arrays", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Array Ref Test", version: "1.0.0" }, + paths: { + "/a": { + get: { + parameters: [ + { name: "limit", in: "query", required: true, schema: { example: "42" } }, + ], + responses: {}, + }, + }, + "/b": { + get: { parameters: [{ $ref: "#/paths/~1a/get/parameters/0" }], responses: {} }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[1]?.urlParameters).toEqual([ + { enabled: true, name: "limit", value: "42" }, + ]); + }); + + test("Resolves example references and 3.1 example arrays", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Examples Test", version: "1.0.0" }, + paths: { + "/a": { + post: { + parameters: [ + { name: "q", in: "query", schema: { type: "string", examples: ["hello"] } }, + ], + requestBody: { + content: { + "application/json": { + schema: { type: "object" }, + examples: { main: { $ref: "#/components/examples/Main" } }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { examples: { Main: { value: { x: "from-example" } } } }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ x: "from-example" }, null, 2), + }); + expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([ + { enabled: false, name: "q", value: "hello" }, + ]); + }); + + test("Keeps template-looking braces in descriptions and examples", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Braces Test", version: "1.0.0" }, + paths: { + "/a": { + post: { + description: "Use {{placeholders}} in the template", + requestBody: { + content: { + "application/json": { schema: { type: "string", example: "Hi {{name}}" } }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.description).toContain("{{placeholders}}"); + expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: "Hi {{name}}" }); + }); + + test("Ignores header parameters the spec reserves for other mechanisms", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Reserved Headers Test", version: "1.0.0" }, + paths: { + "/a": { + post: { + parameters: [ + { name: "Content-Type", in: "header", schema: { example: "application/xml" } }, + { name: "Accept", in: "header", schema: { example: "text/html" } }, + { name: "Authorization", in: "header", schema: { example: "custom" } }, + { name: "X-Custom", in: "header", schema: { example: "kept" } }, + ], + requestBody: { content: { "application/json": { schema: { type: "object" } } } }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.headers).toEqual([ + { enabled: false, name: "X-Custom", value: "kept" }, + { enabled: true, name: "Content-Type", value: "application/json" }, + ]); + }); + + test("Imports cookie parameters as a Cookie header", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Cookie Test", version: "1.0.0" }, + paths: { + "/a": { + get: { + parameters: [ + { name: "session", in: "cookie", required: true, schema: { example: "abc" } }, + { name: "theme", in: "cookie", schema: { example: "dark" } }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.headers).toEqual([ + { enabled: true, name: "Cookie", value: "session=abc; theme=dark" }, + ]); + }); + + test("Inlines path templates that Yaak placeholders cannot express", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Mid-Segment Test", version: "1.0.0" }, + paths: { + "/report.{format}": { + get: { + parameters: [ + { name: "format", in: "path", required: true, schema: { example: "csv" } }, + ], + responses: {}, + }, + }, + "/tasks/{id}:cancel": { + post: { + parameters: [{ name: "id", in: "path", required: true, schema: { example: "7" } }], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests.map((r) => [r.url, r.urlParameters])).toEqual([ + ["${[baseUrl]}/report.csv", []], + ["${[baseUrl]}/tasks/:id:cancel", [{ enabled: true, name: ":id", value: "7" }]], + ]); + }); + + test("Enables path parameters even when required is omitted", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Sloppy Path Test", version: "1.0.0" }, + paths: { + "/users/{userId}": { + get: { + parameters: [{ name: "userId", in: "path", schema: { type: "string" } }], + responses: {}, + }, + }, + }, + }), + ); + + // No example either, so the name stands in for an empty path segment + expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([ + { enabled: true, name: ":userId", value: "userId" }, + ]); + }); + + test("Coerces examples to the declared schema type", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Coercion Test", version: "1.0.0" }, + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + password: { type: "string", example: 12345 }, + count: { type: "integer", example: "3" }, + note: { example: 7 }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ password: "12345", count: 3, note: 7 }, null, 2), + }); + }); + + test("Merges allOf branches with sibling properties", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "AllOf Test", version: "1.0.0" }, + paths: { + "/a": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + allOf: [ + { type: "object", properties: { fromAllOf: { example: "a" } } }, + ], + properties: { sibling: { example: "b" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ fromAllOf: "a", sibling: "b" }, null, 2), + }); + }); + + test("Accepts unquoted YAML version numbers", async () => { + const imported = await convertOpenApi( + ["swagger: 2.0", "info:", " title: Unquoted Test", ' version: "1"', "host: example.com", "paths:", " /a:", " get:", " responses: {}"].join("\n"), + ); + + expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/a"); + // The numeric version must feed server detection too, or the selectable + // environment overrides baseUrl with an empty value + expect(imported?.resources.environments[1]).toEqual( + expect.objectContaining({ + name: "Server 1", + variables: [{ name: "baseUrl", value: "https://example.com" }], + }), + ); + }); + + test("Normalizes body types to Yaak's editors", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Body Type Test", version: "1.0.0" }, + paths: { + "/vnd-json": { + post: { + requestBody: { + content: { "application/vnd.api+json": { schema: { type: "object" } } }, + }, + responses: {}, + }, + }, + "/plain": { + post: { + requestBody: { content: { "text/plain": { schema: { type: "string" } } } }, + responses: {}, + }, + }, + }, + }), + ); + + expect( + imported?.resources.httpRequests.map((r) => [r.bodyType, r.headers?.[0]?.value]), + ).toEqual([ + ["application/json", "application/vnd.api+json"], + ["other", "text/plain"], + ]); + }); + + test("Trims trailing slashes from server URLs", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Trailing Slash Test", version: "1.0.0" }, + servers: [{ url: "https://api.example.com/v1/" }], + paths: { "/pets": { get: { responses: {} } } }, + }), + ); + + expect(imported?.resources.environments[1]?.variables).toEqual([ + { name: "baseUrl", value: "https://api.example.com/v1" }, + ]); + }); + test("Reports references that point outside the document", async () => { const imported = await convertOpenApi( JSON.stringify({