From 36fec8b00547c562adf76b7cb67d04ea2dc58ace Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 18 Aug 2026 20:24:57 -0700 Subject: [PATCH 01/13] Render all columns in irregular CSV responses (#584) --- .../responseViewers/CsvViewer.test.tsx | 32 +++++++++++++++++++ .../components/responseViewers/CsvViewer.tsx | 18 +++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 apps/yaak-client/components/responseViewers/CsvViewer.test.tsx diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx b/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx new file mode 100644 index 00000000..e3df58c4 --- /dev/null +++ b/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { CsvViewerInner } from "./CsvViewer"; + +vi.mock("@yaakapp-internal/ui", () => ({ + Table: ({ children }: { children: ReactNode }) => {children}
, + TableBody: ({ children }: { children: ReactNode }) => {children}, + TableCell: ({ children }: { children: ReactNode }) => {children}, + TableHead: ({ children }: { children: ReactNode }) => {children}, + TableHeaderCell: ({ children }: { children: ReactNode }) => {children}, + TableRow: ({ children }: { children: ReactNode }) => {children}, +})); + +describe("CsvViewer", () => { + test("renders columns that extend beyond the first row", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("ID de usuario"); + expect(markup).toContain("42041"); + expect(markup.match(//g)).toHaveLength(20); + }); +}); diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.tsx b/apps/yaak-client/components/responseViewers/CsvViewer.tsx index fe94480d..f318a437 100644 --- a/apps/yaak-client/components/responseViewers/CsvViewer.tsx +++ b/apps/yaak-client/components/responseViewers/CsvViewer.tsx @@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) { export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) { const parsed = useMemo(() => { if (text == null) return null; - return Papa.parse>(text, { header: true, skipEmptyLines: true }); + return Papa.parse(text, { skipEmptyLines: true }); }, [text]); if (parsed === null) return null; + const header = parsed.data[0] ?? []; + const rows = parsed.data.slice(1); + const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0); + const columnIndexes = Array.from({ length: columnCount }, (_, index) => index); + return (
- {parsed.meta.fields?.map((field) => ( - {field} + {columnIndexes.map((columnIndex) => ( + {header[columnIndex] ?? ""} ))} - {parsed.data.map((row, i) => ( + {rows.map((row, i) => ( // oxlint-disable-next-line react/no-array-index-key - {parsed.meta.fields?.map((key) => ( - {row[key] ?? ""} + {row.map((cell, columnIndex) => ( + // oxlint-disable-next-line react/no-array-index-key + {cell} ))} ))} From a2d54ca77476a1523a2fa687d81c506321e3490f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 07:07:18 -0700 Subject: [PATCH 02/13] Preserve base URL variable when OpenAPI servers are omitted (#585) --- plugins/importer-openapi/src/index.ts | 28 ++++++++++---------- plugins/importer-openapi/tests/index.test.ts | 20 ++++++++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index d9f2fb96..417cd496 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -63,20 +63,20 @@ export async function convertOpenApi(contents: string): Promise 0 ? "${[baseUrl]}" : ""; - - if (baseUrl.length > 0) { - resources.environments.push({ - model: "environment", - id: importState.generateId("environment"), - workspaceId: workspace.id, - name: "Global Variables", - variables: [{ name: "baseUrl", value: baseUrl }], - parentModel: "workspace", - parentId: null, - sortPriority: importState.nextSortPriority(), - }); - } + // A local spec has no document URL against which OpenAPI's implicit "/" + // server can resolve. Keep the shared variable even when its initial value + // is empty so users can configure the host once instead of editing requests. + const requestBaseUrl = "${[baseUrl]}"; + resources.environments.push({ + model: "environment", + id: importState.generateId("environment"), + workspaceId: workspace.id, + name: "Global Variables", + variables: [{ name: "baseUrl", value: baseUrl }], + parentModel: "workspace", + parentId: null, + sortPriority: importState.nextSortPriority(), + }); const folderIdsByTag = new Map(); const routeLabels = new Map(); diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 68ca24fb..b9dd9977 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -229,6 +229,26 @@ describe("importer-openapi", () => { expect(imported).toBeUndefined(); }); + test("Creates an editable baseUrl variable when OpenAPI omits servers", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Serverless OpenAPI Test", version: "1.0.0" }, + paths: { + "/api/widgets": { get: { responses: {} } }, + }, + }), + ); + + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ + name: "Global Variables", + variables: [{ name: "baseUrl", value: "" }], + }), + ]); + expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/api/widgets"); + }); + test("Prefers operation and path servers over the spec base URL", async () => { const imported = await convertOpenApi( JSON.stringify({ From df1fd864b2fe6eeffcd5f0d633ec8ddf3c467900 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 07:10:32 -0700 Subject: [PATCH 03/13] Support OpenAPI 3.2 QUERY and additional operations (#591) --- plugins/importer-openapi/src/index.ts | 25 ++++++++++++++++---- plugins/importer-openapi/tests/index.test.ts | 22 +++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 417cd496..c6ad51c0 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -21,7 +21,7 @@ type ImportResources = { httpRequests: AtLeast[]; }; -const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"]; +const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"]; const BODY_CONTENT_TYPE_PREFERENCE = [ "application/json", "application/x-www-form-urlencoded", @@ -103,10 +103,7 @@ export async function convertOpenApi(contents: string): Promise { + const operation = importState.resolve(pathItem[method]); + return isRecord(operation) ? [{ method, operation }] : []; + }); + + for (const [method, rawOperation] of Object.entries(toRecord(pathItem.additionalOperations))) { + if (HTTP_METHODS.includes(method.toLowerCase())) continue; + const operation = importState.resolve(rawOperation); + if (isRecord(operation)) operations.push({ method, operation }); + } + return operations; +} + /** * Two operations sharing a summary are indistinguishable once imported, so the * colliding ones get their route appended. Names that are already unique within diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index b9dd9977..4fbbd08b 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -13,6 +13,28 @@ describe("importer-openapi", () => { .readdirSync(realWorldFixturesPath) .filter((fixture) => fixture.endsWith(".yaml")); + test("Imports OpenAPI 3.2 QUERY and additional operations", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.2.0", + info: { title: "OpenAPI 3.2 Operations", version: "1.0.0" }, + paths: { + "/resources": { + query: { summary: "Query resources", responses: {} }, + additionalOperations: { + COPY: { summary: "Copy resources", responses: {} }, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests).toEqual([ + expect.objectContaining({ method: "QUERY", name: "Query resources", url: "/resources" }), + expect.objectContaining({ method: "COPY", name: "Copy resources", url: "/resources" }), + ]); + }); + test("Maps operation description to request description", async () => { const imported = await convertOpenApi( JSON.stringify({ From 3332ae263faa7b6d46248a91a3ad004fad428d7b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 07:42:54 -0700 Subject: [PATCH 04/13] Align OpenAPI 3.2 URL expectations with base URL variables (#594) --- plugins/importer-openapi/tests/index.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 4fbbd08b..da2c688c 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -30,8 +30,16 @@ describe("importer-openapi", () => { ); expect(imported?.resources.httpRequests).toEqual([ - expect.objectContaining({ method: "QUERY", name: "Query resources", url: "/resources" }), - expect.objectContaining({ method: "COPY", name: "Copy resources", url: "/resources" }), + expect.objectContaining({ + method: "QUERY", + name: "Query resources", + url: "${[baseUrl]}/resources", + }), + expect.objectContaining({ + method: "COPY", + name: "Copy resources", + url: "${[baseUrl]}/resources", + }), ]); }); From d89831a84dced311913f1c7d652571e4024c5769 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 08:35:12 -0700 Subject: [PATCH 05/13] Preserve OpenAPI security requirement semantics (#586) --- plugins/importer-openapi/src/index.ts | 254 +++++++++++++++--- .../tests/__snapshots__/index.test.ts.snap | 6 +- plugins/importer-openapi/tests/index.test.ts | 161 ++++++++++- 3 files changed, 381 insertions(+), 40 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index c6ad51c0..4a22b2af 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -20,6 +20,11 @@ type ImportResources = { folders: AtLeast[]; httpRequests: AtLeast[]; }; +type ImportedAuthentication = Pick & { + headers: HttpRequestHeader[]; + urlParameters: HttpUrlParameter[]; +}; +type AuthenticationVariableRegistry = Map; const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"]; const BODY_CONTENT_TYPE_PREFERENCE = [ @@ -62,6 +67,7 @@ export async function convertOpenApi(contents: string): Promise 0) { + let environment = resources.environments[0]; + if (environment == null) { + environment = { + model: "environment", + id: importState.generateId("environment"), + workspaceId: workspace.id, + name: "Global Variables", + variables: [], + parentModel: "workspace", + parentId: null, + sortPriority: importState.nextSortPriority(), + }; + resources.environments.push(environment); + } + environment.variables.push(...authenticationVariables.values()); + } + disambiguateNames(resources.httpRequests, routeLabels); return { @@ -200,6 +225,7 @@ function importOperation({ spec, workspaceId, folderId, + authenticationVariables, }: { importState: ImportState; method: string; @@ -211,6 +237,7 @@ function importOperation({ spec: UnknownRecord; workspaceId: string; folderId: string | null; + authenticationVariables: AuthenticationVariableRegistry; }): ImportResources["httpRequests"][0] { importState.beginOperation(); const parameters = mergeParameters({ @@ -219,13 +246,27 @@ function importOperation({ operationParameters: toArray(operation.parameters), }); const body = importBody({ importState, operation, parameters, spec }); - const urlParameters = importUrlParameters({ importState, parameters }); + const authentication = importAuthentication({ + authenticationVariables, + importState, + operation, + spec, + }); + const urlParameters = [ + ...importUrlParameters({ importState, parameters }), + ...authentication.urlParameters, + ]; const headers = mergeHeaders( + authentication.headers, importHeaderParameters({ importState, parameters }), body.headers, importAcceptHeader({ importState, operation, spec }), ); - const authentication = importAuthentication({ importState, operation, spec }); + const { + headers: _authenticationHeaders, + urlParameters: _authenticationParameters, + ...auth + } = authentication; // Built after everything else, so it can report the refs they left unresolved const description = importOperationDescription({ @@ -249,7 +290,7 @@ function importOperation({ body: body.body, bodyType: body.bodyType, sortPriority: importState.nextSortPriority(), - ...authentication, + ...auth, }; } @@ -842,52 +883,142 @@ function inferSchemaType(schema: UnknownRecord): string { } function importAuthentication({ + authenticationVariables, importState, operation, spec, }: { + authenticationVariables: AuthenticationVariableRegistry; importState: ImportState; operation: UnknownRecord; spec: UnknownRecord; -}): Pick { +}): ImportedAuthentication { const security = operation.security ?? spec.security; + if (Array.isArray(operation.security) && operation.security.length === 0) { + return { ...emptyAuthentication(), authenticationType: "none" }; + } if (!Array.isArray(security) || security.length === 0) { - return { authenticationType: null, authentication: {} }; + return emptyAuthentication(); + } + + // Security Requirement Objects are alternatives. If any alternative is + // empty, authentication is optional regardless of where it appears. + if ( + security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0) + ) { + return { ...emptyAuthentication(), authenticationType: "none" }; } const schemes = { ...toRecord(toRecord(spec.components).securitySchemes), ...toRecord(spec.securityDefinitions), }; - for (const requirement of security) { - for (const [schemeName, rawScopes] of Object.entries(toRecord(requirement))) { - const scheme = toRecord(importState.resolve(schemes[schemeName])); - const type = stringAt(scheme, "type"); - if (type === "oauth2") { - const oauth2 = importOAuth2(scheme, rawScopes); - if (oauth2 != null) return oauth2; - continue; - } - if (type === "apiKey") { - return { authenticationType: "apikey", authentication: importApiKey(scheme, schemeName) }; - } - // Swagger 2.0 spells basic auth as its own type rather than an HTTP scheme - if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) { - return { - authenticationType: "basic", - authentication: { username: "", password: "" }, - }; - } - if (type === "http" && schemeIs(scheme, "bearer")) { - return { - authenticationType: "bearer", - authentication: { token: "", prefix: "Bearer" }, - }; - } - } + for (const rawRequirement of security) { + if (!isRecord(rawRequirement)) continue; + + const imported = importSecurityRequirement({ + authenticationVariables, + importState, + requirement: rawRequirement, + schemes, + }); + if (imported != null) return imported; } - return { authenticationType: null, authentication: {} }; + return emptyAuthentication(); +} + +function importSecurityRequirement({ + authenticationVariables, + importState, + requirement, + schemes, +}: { + authenticationVariables: AuthenticationVariableRegistry; + importState: ImportState; + requirement: UnknownRecord; + schemes: UnknownRecord; +}): ImportedAuthentication | null { + const entries = Object.entries(requirement); + const headers: HttpRequestHeader[] = []; + const urlParameters: HttpUrlParameter[] = []; + let primaryAuthentication: Pick | null = + null; + + for (const [schemeName, rawScopes] of entries) { + const scheme = toRecord(importState.resolve(schemes[schemeName])); + const type = stringAt(scheme, "type"); + if (type === "apiKey") { + const variable = registerAuthenticationVariable(authenticationVariables, schemeName, "key"); + if (entries.length === 1) { + primaryAuthentication = { + authenticationType: "apikey", + authentication: importApiKey(scheme, schemeName, variable), + }; + } else { + materializeApiKey(scheme, schemeName, variable, headers, urlParameters); + } + continue; + } + + let candidate: Pick | null = null; + if (type === "oauth2") { + candidate = importOAuth2(scheme, rawScopes); + } else if (type === "openIdConnect") { + const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token"); + candidate = { + authenticationType: "bearer", + authentication: { token: templateVariable(token), prefix: "Bearer" }, + }; + } else if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) { + const username = registerAuthenticationVariable( + authenticationVariables, + schemeName, + "username", + ); + const password = registerAuthenticationVariable( + authenticationVariables, + schemeName, + "password", + ); + candidate = { + authenticationType: "basic", + authentication: { + username: templateVariable(username), + password: templateVariable(password), + }, + }; + } else if (type === "http" && schemeIs(scheme, "bearer")) { + const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token"); + candidate = { + authenticationType: "bearer", + authentication: { token: templateVariable(token), prefix: "Bearer" }, + }; + } + + // A requirement is an AND. Yaak can combine one auth plugin with explicit + // API-key parameters, but cannot represent two auth plugins on one request. + if (candidate == null || primaryAuthentication != null) return null; + primaryAuthentication = candidate; + } + + return { + ...(primaryAuthentication ?? { + authenticationType: entries.length > 1 ? "none" : null, + authentication: {}, + }), + headers, + urlParameters, + }; +} + +function emptyAuthentication(): ImportedAuthentication { + return { + authenticationType: null, + authentication: {}, + headers: [], + urlParameters: [], + }; } function schemeIs(scheme: UnknownRecord, name: string): boolean { @@ -899,14 +1030,67 @@ function schemeIs(scheme: UnknownRecord, name: string): boolean { * cookie key becomes the Cookie header it would have ended up in, pre-filled * with its name. Sending it as a header named after the cookie would just fail. */ -function importApiKey(scheme: UnknownRecord, schemeName: string): Record { +function importApiKey( + scheme: UnknownRecord, + schemeName: string, + variableName: string, +): Record { const key = stringAt(scheme, "name") ?? schemeName; const location = stringAt(scheme, "in"); + const value = templateVariable(variableName); if (location === "cookie") { - return { location: "header", key: "Cookie", value: `${key}=` }; + return { location: "header", key: "Cookie", value: `${key}=${value}` }; } - return { location: location === "query" ? "query" : "header", key, value: "" }; + return { location: location === "query" ? "query" : "header", key, value }; +} + +function materializeApiKey( + scheme: UnknownRecord, + schemeName: string, + variableName: string, + headers: HttpRequestHeader[], + urlParameters: HttpUrlParameter[], +): void { + const key = stringAt(scheme, "name") ?? schemeName; + const location = stringAt(scheme, "in"); + const value = templateVariable(variableName); + if (location === "query") { + urlParameters.push({ enabled: true, name: key, value }); + } else if (location === "cookie") { + headers.push({ enabled: true, name: "Cookie", value: `${key}=${value}` }); + } else { + headers.push({ enabled: true, name: key, value }); + } +} + +function registerAuthenticationVariable( + variables: AuthenticationVariableRegistry, + schemeName: string, + field: string, +): string { + const identity = JSON.stringify([schemeName, field]); + const existing = variables.get(identity); + if (existing != null) return existing.name; + + const schemePart = schemeName + .replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2") + .replaceAll(/[^a-zA-Z0-9]+/g, "_") + .replaceAll(/^_+|_+$/g, "") + .toLowerCase(); + const baseName = `auth_${schemePart || "security"}_${field}`; + let name = baseName; + let suffix = 2; + const names = new Set([...variables.values()].map((variable) => variable.name)); + while (names.has(name)) { + name = `${baseName}_${suffix++}`; + } + variables.set(identity, { name, value: "" }); + return name; +} + +function templateVariable(name: string): string { + return `\${[${name}]}`; } /** diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index 10458055..2f2bb039 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -2617,6 +2617,10 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = ` "name": "baseUrl", "value": "https://api.nasa.gov/planetary", }, + { + "name": "auth_api_key_key", + "value": "", + }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, @@ -2640,7 +2644,7 @@ Here's a link: https://example.com", "authentication": { "key": "api_key", "location": "query", - "value": "", + "value": "\${[auth_api_key_key]}", }, "authenticationType": "apikey", "body": {}, diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index da2c688c..d4576377 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -150,7 +150,10 @@ describe("importer-openapi", () => { expect(imported?.resources.environments).toEqual([ expect.objectContaining({ name: "Global Variables", - variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }], + variables: [ + { name: "baseUrl", value: "https://api.example.com/v1" }, + { name: "auth_token_auth_token", value: "" }, + ], }), ]); expect(imported?.resources.httpRequests).toEqual([ @@ -159,7 +162,7 @@ describe("importer-openapi", () => { method: "POST", url: "${[baseUrl]}/accounts/:accountId/members", authenticationType: "bearer", - authentication: { token: "", prefix: "Bearer" }, + authentication: { token: "${[auth_token_auth_token]}", prefix: "Bearer" }, bodyType: "application/json", body: { text: JSON.stringify( @@ -573,16 +576,166 @@ describe("importer-openapi", () => { expect(imported?.resources.httpRequests[0]).toEqual( expect.objectContaining({ authenticationType: "basic", - authentication: { username: "", password: "" }, + authentication: { + username: "${[auth_basic_auth_username]}", + password: "${[auth_basic_auth_password]}", + }, }), ); // The auth plugin has no cookie location, so it becomes the Cookie header expect(imported?.resources.httpRequests[1]).toEqual( expect.objectContaining({ authenticationType: "apikey", - authentication: { location: "header", key: "Cookie", value: "session=" }, + authentication: { + location: "header", + key: "Cookie", + value: "session=${[auth_cookie_key_key]}", + }, }), ); + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ + name: "Global Variables", + variables: [ + { name: "baseUrl", value: "https://example.com/" }, + { name: "auth_basic_auth_username", value: "" }, + { name: "auth_basic_auth_password", value: "" }, + { name: "auth_cookie_key_key", value: "" }, + ], + }), + ]); + }); + + test("Preserves anonymous security alternatives and explicit auth overrides", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Optional Auth", version: "1.0.0" }, + security: [{ bearerAuth: [] }], + paths: { + "/optional-auth-first": { + get: { security: [{ bearerAuth: [] }, {}], responses: {} }, + }, + "/optional-anonymous-first": { + get: { security: [{}, { bearerAuth: [] }], responses: {} }, + }, + "/public": { get: { security: [], responses: {} } }, + }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests).toEqual([ + expect.objectContaining({ authenticationType: "none", authentication: {} }), + expect.objectContaining({ authenticationType: "none", authentication: {} }), + expect.objectContaining({ authenticationType: "none", authentication: {} }), + ]); + }); + + test("Imports AND security requirements without dropping API keys", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Combined Auth", version: "1.0.0" }, + paths: { + "/combined": { + get: { + security: [{ bearerAuth: [], tenantKey: [], queryKey: [] }], + parameters: [ + { + in: "header", + name: "X-Tenant-Key", + example: "operation-value-must-not-replace-auth", + }, + ], + responses: {}, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + tenantKey: { type: "apiKey", in: "header", name: "X-Tenant-Key" }, + queryKey: { type: "apiKey", in: "query", name: "api_key" }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests).toEqual([ + expect.objectContaining({ + authenticationType: "bearer", + authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" }, + headers: [{ enabled: true, name: "X-Tenant-Key", value: "${[auth_tenant_key_key]}" }], + urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }], + }), + ]); + }); + + test("Keeps distinct credentials for security scheme names that normalize alike", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Auth Variable Names", version: "1.0.0" }, + paths: { + "/hyphen": { get: { security: [{ "api-key": [] }], responses: {} } }, + "/underscore": { get: { security: [{ api_key: [] }], responses: {} } }, + "/hyphen-again": { get: { security: [{ "api-key": [] }], responses: {} } }, + }, + components: { + securitySchemes: { + "api-key": { type: "apiKey", in: "header", name: "X-Hyphen-Key" }, + api_key: { type: "apiKey", in: "header", name: "X-Underscore-Key" }, + }, + }, + }), + ); + + expect(imported?.resources.environments[0]?.variables).toEqual([ + { name: "baseUrl", value: "" }, + { name: "auth_api_key_key", value: "" }, + { name: "auth_api_key_key_2", value: "" }, + ]); + expect(imported?.resources.httpRequests).toEqual([ + expect.objectContaining({ + authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }), + }), + expect.objectContaining({ + authentication: expect.objectContaining({ value: "${[auth_api_key_key_2]}" }), + }), + expect.objectContaining({ + authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }), + }), + ]); + }); + + test("Imports OpenID Connect as bearer authentication", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "OpenID Connect", version: "1.0.0" }, + paths: { "/me": { get: { security: [{ oidc: [] }], responses: {} } } }, + components: { + securitySchemes: { + oidc: { + type: "openIdConnect", + openIdConnectUrl: "https://accounts.example.com/.well-known/openid-configuration", + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests).toEqual([ + expect.objectContaining({ + authenticationType: "bearer", + authentication: { token: "${[auth_oidc_token]}", prefix: "Bearer" }, + }), + ]); }); test("Reports references that point outside the document", async () => { From 71c217d3f0ee37d290ca8fbd7fb8f6891b84045d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 08:43:17 -0700 Subject: [PATCH 06/13] Populate OAuth credential variables during OpenAPI import (#587) --- plugins/importer-openapi/src/index.ts | 146 ++++++++++++++++++- plugins/importer-openapi/tests/index.test.ts | 105 ++++++++++++- 2 files changed, 239 insertions(+), 12 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 4a22b2af..a18969c8 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -25,6 +25,7 @@ type ImportedAuthentication = Pick; +type OAuthVariableNames = { clientId: string; clientSecret: string }; const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"]; const BODY_CONTENT_TYPE_PREFERENCE = [ @@ -68,6 +69,7 @@ export async function convertOpenApi(contents: string): Promise request.authenticationType === "oauth2")) { + let globalEnvironment = resources.environments[0]; + if (globalEnvironment == null) { + globalEnvironment = { + model: "environment", + id: importState.generateId("environment"), + workspaceId: workspace.id, + name: "Global Variables", + variables: [], + parentModel: "workspace", + parentId: null, + sortPriority: importState.nextSortPriority(), + }; + resources.environments.push(globalEnvironment); + } + + const variableNames = new Set( + [...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [ + clientId, + clientSecret, + ]), + ); + if ( + resources.httpRequests.some( + (request) => + request.authenticationType === "oauth2" && + Object.values(toRecord(request.authentication)).some( + (value) => + typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")), + ), + ) + ) { + variableNames.add("baseUrlOrigin"); + } + globalEnvironment.variables.push(...[...variableNames].map((name) => ({ name, value: "" }))); + } + if (resources.httpRequests.length === 0) return undefined; if (authenticationVariables.size > 0) { @@ -218,6 +258,7 @@ function importOperation({ importState, method, operation, + oauthVariablesByScheme, path, pathItem, pathParameters, @@ -230,6 +271,7 @@ function importOperation({ importState: ImportState; method: string; operation: UnknownRecord; + oauthVariablesByScheme: Map; path: string; pathItem: UnknownRecord; pathParameters: unknown[]; @@ -249,6 +291,7 @@ function importOperation({ const authentication = importAuthentication({ authenticationVariables, importState, + oauthVariablesByScheme, operation, spec, }); @@ -885,11 +928,13 @@ function inferSchemaType(schema: UnknownRecord): string { function importAuthentication({ authenticationVariables, importState, + oauthVariablesByScheme, operation, spec, }: { authenticationVariables: AuthenticationVariableRegistry; importState: ImportState; + oauthVariablesByScheme: Map; operation: UnknownRecord; spec: UnknownRecord; }): ImportedAuthentication { @@ -919,8 +964,10 @@ function importAuthentication({ const imported = importSecurityRequirement({ authenticationVariables, importState, + oauthVariablesByScheme, requirement: rawRequirement, schemes, + spec, }); if (imported != null) return imported; } @@ -931,13 +978,17 @@ function importAuthentication({ function importSecurityRequirement({ authenticationVariables, importState, + oauthVariablesByScheme, requirement, schemes, + spec, }: { authenticationVariables: AuthenticationVariableRegistry; importState: ImportState; + oauthVariablesByScheme: Map; requirement: UnknownRecord; schemes: UnknownRecord; + spec: UnknownRecord; }): ImportedAuthentication | null { const entries = Object.entries(requirement); const headers: HttpRequestHeader[] = []; @@ -963,7 +1014,15 @@ function importSecurityRequirement({ let candidate: Pick | null = null; if (type === "oauth2") { - candidate = importOAuth2(scheme, rawScopes); + candidate = importOAuth2( + scheme, + rawScopes, + importBaseUrl(spec), + oauthVariablesByScheme.get(schemeName) ?? { + clientId: "oauth_client_id", + clientSecret: "oauth_client_secret", + }, + ); } else if (type === "openIdConnect") { const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token"); candidate = { @@ -1101,6 +1160,8 @@ function templateVariable(name: string): string { function importOAuth2( scheme: UnknownRecord, rawScopes: unknown, + baseUrl: string, + variableNames: OAuthVariableNames, ): Pick | null { const scope = toArray(rawScopes) .filter((s): s is string => typeof s === "string") @@ -1128,24 +1189,36 @@ function importOAuth2( } for (const { grantType, flow } of candidates) { - const authorizationUrl = stringAt(flow, "authorizationUrl"); - const accessTokenUrl = stringAt(flow, "tokenUrl"); + const authorizationUrl = resolveOAuthUrl(stringAt(flow, "authorizationUrl"), baseUrl); + const accessTokenUrl = resolveOAuthUrl(stringAt(flow, "tokenUrl"), baseUrl); if (authorizationUrl == null && accessTokenUrl == null) continue; const grantPatch = grantType === "authorization_code" - ? { authorizationUrl, accessTokenUrl, clientSecret: "" } + ? { + authorizationUrl, + accessTokenUrl, + clientSecret: templateVariable(variableNames.clientSecret), + } : grantType === "implicit" ? { authorizationUrl } : grantType === "password" - ? { accessTokenUrl, clientSecret: "", username: "", password: "" } - : { accessTokenUrl, clientSecret: "" }; + ? { + accessTokenUrl, + clientSecret: templateVariable(variableNames.clientSecret), + username: "", + password: "", + } + : { + accessTokenUrl, + clientSecret: templateVariable(variableNames.clientSecret), + }; return { authenticationType: "oauth2", authentication: { grantType, - clientId: "", + clientId: templateVariable(variableNames.clientId), headerPrefix: "Bearer", ...(scope.length > 0 ? { scope } : {}), ...grantPatch, @@ -1156,6 +1229,65 @@ function importOAuth2( return null; } +function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | undefined { + if (value == null) return undefined; + try { + return new URL(value).toString(); + } catch { + // Relative endpoint; resolve it against the API base below. + } + + if (baseUrl.length > 0) { + try { + return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString(); + } catch { + // A path-only server has no origin to resolve against. Preserve whether + // the OAuth endpoint is relative to that path or to the eventual origin. + } + } + + if (value.startsWith("//")) return value; + try { + const placeholderOrigin = "https://openapi-import.invalid"; + const relativeBase = new URL(`${trimTrailingSlashes(baseUrl)}/`, placeholderOrigin); + const resolved = new URL(value, relativeBase); + return `${templateVariable("baseUrlOrigin")}${resolved.pathname}${resolved.search}${resolved.hash}`; + } catch { + return joinUrlParts(templateVariable("baseUrlOrigin"), value); + } +} + +function buildOAuthVariablesByScheme( + importState: ImportState, + spec: UnknownRecord, +): Map { + const schemes = { + ...toRecord(toRecord(spec.components).securitySchemes), + ...toRecord(spec.securityDefinitions), + }; + const oauthSchemeNames = Object.entries(schemes) + .filter(([, scheme]) => stringAt(importState.resolve(scheme), "type") === "oauth2") + .map(([name]) => name); + const usedPrefixes = new Set(); + + return new Map( + oauthSchemeNames.map((schemeName) => { + const basePrefix = + oauthSchemeNames.length === 1 + ? "oauth" + : `oauth_${schemeName.replaceAll(/[^a-zA-Z0-9_]+/g, "_").replaceAll(/^_+|_+$/g, "") || "auth"}`; + let prefix = basePrefix; + let suffix = 2; + while (usedPrefixes.has(prefix)) prefix = `${basePrefix}_${suffix++}`; + usedPrefixes.add(prefix); + return [ + schemeName, + { clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` }, + ]; + }), + ); +} + function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] { const headers: HttpRequestHeader[] = []; for (const header of headerGroups.flat()) { diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index d4576377..4c11c582 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -342,8 +342,8 @@ describe("importer-openapi", () => { authenticationType: "oauth2", authentication: { grantType: "client_credentials", - clientId: "", - clientSecret: "", + clientId: "${[oauth_oauth_client_id]}", + clientSecret: "${[oauth_oauth_client_secret]}", headerPrefix: "Bearer", scope: "read write", accessTokenUrl: "https://example.com/token", @@ -355,12 +355,107 @@ describe("importer-openapi", () => { authenticationType: "oauth2", authentication: { grantType: "implicit", - clientId: "", + clientId: "${[oauth_implicitOauth_client_id]}", headerPrefix: "Bearer", authorizationUrl: "https://example.com/authorize", }, }), ); + expect(imported?.resources.environments[0]?.variables).toEqual([ + { name: "baseUrl", value: "" }, + { name: "oauth_oauth_client_id", value: "" }, + { name: "oauth_oauth_client_secret", value: "" }, + { name: "oauth_implicitOauth_client_id", value: "" }, + { name: "oauth_implicitOauth_client_secret", value: "" }, + ]); + }); + + test("Uses shared environment variables for OAuth2 client credentials", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "OAuth Environment Test", version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: { + "/users": { + get: { security: [{ oauth: ["read"] }], responses: {} }, + post: { security: [{ oauth: ["write"] }], responses: {} }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "/oauth/authorize", + tokenUrl: "/oauth/token", + scopes: { read: "Read users", write: "Write users" }, + }, + }, + }, + }, + }, + }), + ); + + expect(imported?.resources.environments[0]?.variables).toEqual([ + { name: "baseUrl", value: "https://api.example.com" }, + { name: "oauth_client_id", value: "" }, + { name: "oauth_client_secret", value: "" }, + ]); + expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([ + expect.objectContaining({ + clientId: "${[oauth_client_id]}", + clientSecret: "${[oauth_client_secret]}", + authorizationUrl: "https://api.example.com/oauth/authorize", + accessTokenUrl: "https://api.example.com/oauth/token", + }), + expect.objectContaining({ + clientId: "${[oauth_client_id]}", + clientSecret: "${[oauth_client_secret]}", + }), + ]); + }); + + test("Uses the shared base variable for OAuth endpoints with a path-only API base", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Path-only OAuth Test", version: "1.0.0" }, + servers: [{ url: "/api/v1" }], + paths: { + "/users": { get: { security: [{ oauth: [] }], responses: {} } }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "oauth/authorize", + tokenUrl: "/oauth/token", + scopes: {}, + }, + }, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.authentication).toEqual( + expect.objectContaining({ + authorizationUrl: "${[baseUrlOrigin]}/api/v1/oauth/authorize", + accessTokenUrl: "${[baseUrlOrigin]}/oauth/token", + }), + ); + expect(imported?.resources.environments[0]?.variables).toEqual([ + { name: "baseUrl", value: "/api/v1" }, + { name: "oauth_client_id", value: "" }, + { name: "oauth_client_secret", value: "" }, + { name: "baseUrlOrigin", value: "" }, + ]); }); test("Imports Swagger 2 OAuth2 flows and produces", async () => { @@ -388,8 +483,8 @@ describe("importer-openapi", () => { authenticationType: "oauth2", authentication: { grantType: "authorization_code", - clientId: "", - clientSecret: "", + clientId: "${[oauth_client_id]}", + clientSecret: "${[oauth_client_secret]}", headerPrefix: "Bearer", scope: "admin", authorizationUrl: "https://example.com/authorize", From c4f96f3f11440409cf028221ac05438e21c417c9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 12:45:32 -0700 Subject: [PATCH 07/13] Create environments for OpenAPI server URLs (#590) --- plugins/importer-openapi/src/index.ts | 166 ++++++++++---- .../tests/__snapshots__/index.test.ts.snap | 59 +++++ plugins/importer-openapi/tests/index.test.ts | 204 +++++++++++++++--- 3 files changed, 363 insertions(+), 66 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index a18969c8..920c0288 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -26,6 +26,7 @@ type ImportedAuthentication = Pick; type OAuthVariableNames = { clientId: string; clientSecret: string }; +type ServerOverrideVariable = { name: string; value: string }; const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"]; const BODY_CONTENT_TYPE_PREFERENCE = [ @@ -70,7 +71,9 @@ export async function convertOpenApi(contents: string): Promise(); const baseUrl = importBaseUrl(spec); + const serverEnvironments = importServerEnvironments(spec); // A local spec has no document URL against which OpenAPI's implicit "/" // server can resolve. Keep the shared variable even when its initial value // is empty so users can configure the host once instead of editing requests. @@ -129,6 +132,8 @@ export async function convertOpenApi(contents: string): Promise 1, spec, workspaceId: workspace.id, folderId, @@ -140,21 +145,6 @@ export async function convertOpenApi(contents: string): Promise request.authenticationType === "oauth2")) { - let globalEnvironment = resources.environments[0]; - if (globalEnvironment == null) { - globalEnvironment = { - model: "environment", - id: importState.generateId("environment"), - workspaceId: workspace.id, - name: "Global Variables", - variables: [], - parentModel: "workspace", - parentId: null, - sortPriority: importState.nextSortPriority(), - }; - resources.environments.push(globalEnvironment); - } - const variableNames = new Set( [...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [ clientId, @@ -173,28 +163,39 @@ export async function convertOpenApi(contents: string): Promise ({ name, value: "" }))); + resources.environments[0]?.variables.push( + ...[...variableNames].map((name) => ({ name, value: "" })), + ); } if (resources.httpRequests.length === 0) return undefined; - if (authenticationVariables.size > 0) { - let environment = resources.environments[0]; - if (environment == null) { - environment = { - model: "environment", - id: importState.generateId("environment"), - workspaceId: workspace.id, - name: "Global Variables", - variables: [], - parentModel: "workspace", - parentId: null, - sortPriority: importState.nextSortPriority(), - }; - resources.environments.push(environment); - } - environment.variables.push(...authenticationVariables.values()); - } + const baseEnvironment = resources.environments[0]; + if (baseEnvironment == null) return undefined; + baseEnvironment.variables.push(...authenticationVariables.values()); + + const environmentSpecificVariables = baseEnvironment.variables; + baseEnvironment.variables = [...serverOverrides.values()]; + resources.environments.push( + ...serverEnvironments.map(({ name, url }) => ({ + model: "environment" as const, + id: importState.generateId("environment"), + workspaceId: workspace.id, + name, + variables: environmentSpecificVariables.map((variable) => ({ + ...variable, + value: + variable.name === "baseUrl" + ? url + : variable.name === "baseUrlOrigin" + ? serverUrlOrigin(url) + : variable.value, + })), + parentModel: "environment" as const, + parentId: null, + sortPriority: importState.nextSortPriority(), + })), + ); disambiguateNames(resources.httpRequests, routeLabels); @@ -263,6 +264,8 @@ function importOperation({ pathItem, pathParameters, requestBaseUrl, + serverOverrides, + useDynamicServerUrls, spec, workspaceId, folderId, @@ -276,6 +279,8 @@ function importOperation({ pathItem: UnknownRecord; pathParameters: unknown[]; requestBaseUrl: string; + serverOverrides: Map; + useDynamicServerUrls: boolean; spec: UnknownRecord; workspaceId: string; folderId: string | null; @@ -294,6 +299,7 @@ function importOperation({ oauthVariablesByScheme, operation, spec, + useDynamicServerUrls, }); const urlParameters = [ ...importUrlParameters({ importState, parameters }), @@ -327,7 +333,10 @@ function importOperation({ name: importOperationName(operation, method, path), description, method: method.toUpperCase(), - url: buildOperationUrl(operationBaseUrl({ operation, pathItem, requestBaseUrl }), path), + url: buildOperationUrl( + operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }), + path, + ), urlParameters, headers, body: body.body, @@ -382,18 +391,26 @@ function operationBaseUrl({ operation, pathItem, requestBaseUrl, + serverOverrides, }: { operation: UnknownRecord; pathItem: UnknownRecord; requestBaseUrl: string; + serverOverrides: Map; }): string { for (const servers of [operation.servers, pathItem.servers]) { const override = toArray(servers) .map((s) => interpolateServerUrl(toRecord(s))) .find((url) => url.length > 0); - // Overrides are inlined rather than shared, since only the spec-level base - // URL becomes the baseUrl variable - if (override != null) return override; + if (override != null) { + let variable = serverOverrides.get(override); + if (variable == null) { + const suffix = serverOverrides.size === 0 ? "" : String(serverOverrides.size + 1); + variable = { name: `serverUrl${suffix}`, value: override }; + serverOverrides.set(override, variable); + } + return `\${[${variable.name}]}`; + } } return requestBaseUrl; } @@ -646,6 +663,48 @@ function importBaseUrl(spec: UnknownRecord): string { return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? ""); } +function importServerEnvironments(spec: UnknownRecord): { name: string; url: string }[] { + const servers = toArray(spec.servers) + .map(toRecord) + .map((server, index) => ({ + name: stringAt(server, "description")?.trim() || `Server ${index + 1}`, + url: interpolateServerUrl(server), + })) + .filter(({ url }) => url.length > 0); + if (servers.length === 0) { + const hasSwaggerServer = + stringAt(spec, "swagger") === "2.0" && + (stringAt(spec, "host") != null || stringAt(spec, "basePath") != null); + return [ + { + name: hasSwaggerServer ? "Server 1" : "Default", + url: hasSwaggerServer ? importBaseUrl(spec) : "", + }, + ]; + } + + const nameCounts = new Map(); + return servers.map((server) => { + const count = (nameCounts.get(server.name) ?? 0) + 1; + nameCounts.set(server.name, count); + return { ...server, name: count === 1 ? server.name : `${server.name} ${count}` }; + }); +} + +function serverUrlOrigin(value: string): string { + try { + const origin = new URL(value).origin; + return origin === "null" ? "" : origin; + } catch { + if (!value.startsWith("//")) return ""; + try { + return `//${new URL(`https:${value}`).host}`; + } catch { + return ""; + } + } +} + function interpolateServerUrl(server: UnknownRecord): string { let url = stringAt(server, "url") ?? ""; for (const [name, variable] of Object.entries(toRecord(server.variables))) { @@ -931,12 +990,14 @@ function importAuthentication({ oauthVariablesByScheme, operation, spec, + useDynamicServerUrls, }: { authenticationVariables: AuthenticationVariableRegistry; importState: ImportState; oauthVariablesByScheme: Map; operation: UnknownRecord; spec: UnknownRecord; + useDynamicServerUrls: boolean; }): ImportedAuthentication { const security = operation.security ?? spec.security; if (Array.isArray(operation.security) && operation.security.length === 0) { @@ -968,6 +1029,7 @@ function importAuthentication({ requirement: rawRequirement, schemes, spec, + useDynamicServerUrls, }); if (imported != null) return imported; } @@ -982,6 +1044,7 @@ function importSecurityRequirement({ requirement, schemes, spec, + useDynamicServerUrls, }: { authenticationVariables: AuthenticationVariableRegistry; importState: ImportState; @@ -989,6 +1052,7 @@ function importSecurityRequirement({ requirement: UnknownRecord; schemes: UnknownRecord; spec: UnknownRecord; + useDynamicServerUrls: boolean; }): ImportedAuthentication | null { const entries = Object.entries(requirement); const headers: HttpRequestHeader[] = []; @@ -1022,6 +1086,7 @@ function importSecurityRequirement({ clientId: "oauth_client_id", clientSecret: "oauth_client_secret", }, + useDynamicServerUrls, ); } else if (type === "openIdConnect") { const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token"); @@ -1162,6 +1227,7 @@ function importOAuth2( rawScopes: unknown, baseUrl: string, variableNames: OAuthVariableNames, + useDynamicServerUrls: boolean, ): Pick | null { const scope = toArray(rawScopes) .filter((s): s is string => typeof s === "string") @@ -1189,8 +1255,16 @@ function importOAuth2( } for (const { grantType, flow } of candidates) { - const authorizationUrl = resolveOAuthUrl(stringAt(flow, "authorizationUrl"), baseUrl); - const accessTokenUrl = resolveOAuthUrl(stringAt(flow, "tokenUrl"), baseUrl); + const authorizationUrl = resolveOAuthUrl( + stringAt(flow, "authorizationUrl"), + baseUrl, + useDynamicServerUrls, + ); + const accessTokenUrl = resolveOAuthUrl( + stringAt(flow, "tokenUrl"), + baseUrl, + useDynamicServerUrls, + ); if (authorizationUrl == null && accessTokenUrl == null) continue; const grantPatch = @@ -1229,7 +1303,11 @@ function importOAuth2( return null; } -function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | undefined { +function resolveOAuthUrl( + value: string | undefined, + baseUrl: string, + useDynamicServerUrls: boolean, +): string | undefined { if (value == null) return undefined; try { return new URL(value).toString(); @@ -1237,6 +1315,13 @@ function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | u // Relative endpoint; resolve it against the API base below. } + if (value.startsWith("//")) return value; + if (useDynamicServerUrls) { + return value.startsWith("/") + ? `${templateVariable("baseUrlOrigin")}${value}` + : joinUrlParts(templateVariable("baseUrl"), value); + } + if (baseUrl.length > 0) { try { return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString(); @@ -1246,7 +1331,6 @@ function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | u } } - if (value.startsWith("//")) return value; try { const placeholderOrigin = "https://openapi-import.invalid"; const relativeBase = new URL(`${trimTrailingSlashes(baseUrl)}/`, placeholderOrigin); diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index 2f2bb039..86a2eff5 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -11,6 +11,16 @@ exports[`importer-openapi > Snapshots real-world fixture apis-guru.yaml 1`] = ` "parentId": null, "parentModel": "workspace", "sortPriority": 0, + "variables": [], + "workspaceId": "GENERATE_ID::WORKSPACE_0", + }, + { + "id": "GENERATE_ID::ENVIRONMENT_1", + "model": "environment", + "name": "Server 1", + "parentId": null, + "parentModel": "environment", + "sortPriority": 9, "variables": [ { "name": "baseUrl", @@ -326,6 +336,16 @@ exports[`importer-openapi > Snapshots real-world fixture httpbin.yaml 1`] = ` "parentId": null, "parentModel": "workspace", "sortPriority": 0, + "variables": [], + "workspaceId": "GENERATE_ID::WORKSPACE_0", + }, + { + "id": "GENERATE_ID::ENVIRONMENT_1", + "model": "environment", + "name": "Server 1", + "parentId": null, + "parentModel": "environment", + "sortPriority": 90, "variables": [ { "name": "baseUrl", @@ -2612,6 +2632,16 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = ` "parentId": null, "parentModel": "workspace", "sortPriority": 0, + "variables": [], + "workspaceId": "GENERATE_ID::WORKSPACE_0", + }, + { + "id": "GENERATE_ID::ENVIRONMENT_1", + "model": "environment", + "name": "Server 1", + "parentId": null, + "parentModel": "environment", + "sortPriority": 3, "variables": [ { "name": "baseUrl", @@ -2624,6 +2654,25 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = ` ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, + { + "id": "GENERATE_ID::ENVIRONMENT_2", + "model": "environment", + "name": "Server 2", + "parentId": null, + "parentModel": "environment", + "sortPriority": 4, + "variables": [ + { + "name": "baseUrl", + "value": "http://api.nasa.gov/planetary", + }, + { + "name": "auth_api_key_key", + "value": "", + }, + ], + "workspaceId": "GENERATE_ID::WORKSPACE_0", + }, ], "folders": [ { @@ -2715,6 +2764,16 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = ` "parentId": null, "parentModel": "workspace", "sortPriority": 0, + "variables": [], + "workspaceId": "GENERATE_ID::WORKSPACE_0", + }, + { + "id": "GENERATE_ID::ENVIRONMENT_1", + "model": "environment", + "name": "Server 1", + "parentId": null, + "parentModel": "environment", + "sortPriority": 3, "variables": [ { "name": "baseUrl", diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 4c11c582..deabbfba 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -150,6 +150,11 @@ describe("importer-openapi", () => { expect(imported?.resources.environments).toEqual([ expect.objectContaining({ name: "Global Variables", + variables: [], + }), + expect.objectContaining({ + name: "Server 1", + parentModel: "environment", variables: [ { name: "baseUrl", value: "https://api.example.com/v1" }, { name: "auth_token_auth_token", value: "" }, @@ -252,6 +257,11 @@ describe("importer-openapi", () => { ); expect(imported?.resources.environments).toEqual([ expect.objectContaining({ + name: "Global Variables", + variables: [], + }), + expect.objectContaining({ + name: "Server 1", variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }], }), ]); @@ -276,6 +286,11 @@ describe("importer-openapi", () => { expect(imported?.resources.environments).toEqual([ expect.objectContaining({ name: "Global Variables", + variables: [], + }), + expect.objectContaining({ + name: "Default", + parentModel: "environment", variables: [{ name: "baseUrl", value: "" }], }), ]); @@ -304,8 +319,119 @@ describe("importer-openapi", () => { expect(imported?.resources.httpRequests.map((r) => r.url)).toEqual([ "${[baseUrl]}/root", - "https://path.example.com/path-level", - "https://operation.example.com/operation-level", + "${[serverUrl]}/path-level", + "${[serverUrl2]}/operation-level", + ]); + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ + name: "Global Variables", + variables: [ + { name: "serverUrl", value: "https://path.example.com" }, + { name: "serverUrl2", value: "https://operation.example.com" }, + ], + }), + expect.objectContaining({ + name: "Server 1", + variables: [{ name: "baseUrl", value: "https://root.example.com" }], + }), + ]); + }); + + test("Creates selectable environments for multiple OpenAPI servers", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Server Environments Test", version: "1.0.0" }, + servers: [ + { url: "https://api.example.com/v1", description: "Production" }, + { url: "https://sandbox.example.com/v1", description: "Sandbox" }, + ], + paths: { + "/oauth": { get: { security: [{ oauth: [] }], responses: {} } }, + "/api-key": { get: { security: [{ apiKey: [] }], responses: {} } }, + "/fixed": { + servers: [{ url: "https://fixed.example.com" }], + get: { responses: {} }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "/oauth/authorize", + tokenUrl: "oauth/token", + scopes: {}, + }, + }, + }, + apiKey: { type: "apiKey", in: "header", name: "X-API-Key" }, + }, + }, + }), + ); + + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ + name: "Global Variables", + variables: [{ name: "serverUrl", value: "https://fixed.example.com" }], + }), + expect.objectContaining({ + name: "Production", + parentModel: "environment", + variables: [ + { name: "baseUrl", value: "https://api.example.com/v1" }, + { name: "oauth_client_id", value: "" }, + { name: "oauth_client_secret", value: "" }, + { name: "baseUrlOrigin", value: "https://api.example.com" }, + { name: "auth_api_key_key", value: "" }, + ], + }), + expect.objectContaining({ + name: "Sandbox", + parentModel: "environment", + variables: [ + { name: "baseUrl", value: "https://sandbox.example.com/v1" }, + { name: "oauth_client_id", value: "" }, + { name: "oauth_client_secret", value: "" }, + { name: "baseUrlOrigin", value: "https://sandbox.example.com" }, + { name: "auth_api_key_key", value: "" }, + ], + }), + ]); + expect(imported?.resources.httpRequests[0]?.authentication).toEqual( + expect.objectContaining({ + authorizationUrl: "${[baseUrlOrigin]}/oauth/authorize", + accessTokenUrl: "${[baseUrl]}/oauth/token", + }), + ); + }); + + test("Creates variables for path servers without a top-level server", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Path Server Test", version: "1.0.0" }, + paths: { + "/items": { + servers: [{ url: "https://path.example.com" }], + get: { responses: {} }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.url).toBe("${[serverUrl]}/items"); + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ + name: "Global Variables", + variables: [{ name: "serverUrl", value: "https://path.example.com" }], + }), + expect.objectContaining({ + name: "Default", + variables: [{ name: "baseUrl", value: "" }], + }), ]); }); @@ -361,16 +487,22 @@ describe("importer-openapi", () => { }, }), ); - expect(imported?.resources.environments[0]?.variables).toEqual([ - { name: "baseUrl", value: "" }, - { name: "oauth_oauth_client_id", value: "" }, - { name: "oauth_oauth_client_secret", value: "" }, - { name: "oauth_implicitOauth_client_id", value: "" }, - { name: "oauth_implicitOauth_client_secret", value: "" }, - ]); + expect(imported?.resources.environments[0]?.variables).toEqual([]); + expect(imported?.resources.environments[1]).toEqual( + expect.objectContaining({ + name: "Default", + variables: [ + { name: "baseUrl", value: "" }, + { name: "oauth_oauth_client_id", value: "" }, + { name: "oauth_oauth_client_secret", value: "" }, + { name: "oauth_implicitOauth_client_id", value: "" }, + { name: "oauth_implicitOauth_client_secret", value: "" }, + ], + }), + ); }); - test("Uses shared environment variables for OAuth2 client credentials", async () => { + test("Uses server environment variables for OAuth2 client credentials", async () => { const imported = await convertOpenApi( JSON.stringify({ openapi: "3.0.4", @@ -399,10 +531,16 @@ describe("importer-openapi", () => { }), ); - expect(imported?.resources.environments[0]?.variables).toEqual([ - { name: "baseUrl", value: "https://api.example.com" }, - { name: "oauth_client_id", value: "" }, - { name: "oauth_client_secret", value: "" }, + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ name: "Global Variables", variables: [] }), + expect.objectContaining({ + name: "Server 1", + variables: [ + { name: "baseUrl", value: "https://api.example.com" }, + { name: "oauth_client_id", value: "" }, + { name: "oauth_client_secret", value: "" }, + ], + }), ]); expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([ expect.objectContaining({ @@ -418,7 +556,7 @@ describe("importer-openapi", () => { ]); }); - test("Uses the shared base variable for OAuth endpoints with a path-only API base", async () => { + test("Uses the server environment origin for OAuth endpoints with a path-only API base", async () => { const imported = await convertOpenApi( JSON.stringify({ openapi: "3.0.4", @@ -450,11 +588,17 @@ describe("importer-openapi", () => { accessTokenUrl: "${[baseUrlOrigin]}/oauth/token", }), ); - expect(imported?.resources.environments[0]?.variables).toEqual([ - { name: "baseUrl", value: "/api/v1" }, - { name: "oauth_client_id", value: "" }, - { name: "oauth_client_secret", value: "" }, - { name: "baseUrlOrigin", value: "" }, + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ name: "Global Variables", variables: [] }), + expect.objectContaining({ + name: "Server 1", + variables: [ + { name: "baseUrl", value: "/api/v1" }, + { name: "oauth_client_id", value: "" }, + { name: "oauth_client_secret", value: "" }, + { name: "baseUrlOrigin", value: "" }, + ], + }), ]); }); @@ -691,6 +835,10 @@ describe("importer-openapi", () => { expect(imported?.resources.environments).toEqual([ expect.objectContaining({ name: "Global Variables", + variables: [], + }), + expect.objectContaining({ + name: "Server 1", variables: [ { name: "baseUrl", value: "https://example.com/" }, { name: "auth_basic_auth_username", value: "" }, @@ -790,11 +938,17 @@ describe("importer-openapi", () => { }), ); - expect(imported?.resources.environments[0]?.variables).toEqual([ - { name: "baseUrl", value: "" }, - { name: "auth_api_key_key", value: "" }, - { name: "auth_api_key_key_2", value: "" }, - ]); + expect(imported?.resources.environments[0]?.variables).toEqual([]); + expect(imported?.resources.environments[1]).toEqual( + expect.objectContaining({ + name: "Default", + variables: [ + { name: "baseUrl", value: "" }, + { name: "auth_api_key_key", value: "" }, + { name: "auth_api_key_key_2", value: "" }, + ], + }), + ); expect(imported?.resources.httpRequests).toEqual([ expect.objectContaining({ authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }), From 4eebd606ef55f5e13a7577e9d5a67fdc2365859b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 07:16:44 -0700 Subject: [PATCH 08/13] 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({ From 2350689d7a7583a0d87d213ffbefc6c8ff8da6f8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 07:39:18 -0700 Subject: [PATCH 09/13] Import OpenAPI security the way Yaak inherits it (#601) --- plugins/importer-openapi/src/index.ts | 160 +++++++++++++----- .../tests/__snapshots__/index.test.ts.snap | 8 + plugins/importer-openapi/tests/index.test.ts | 152 ++++++++++++++++- 3 files changed, 274 insertions(+), 46 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index ea1acac0..6fce18aa 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -15,7 +15,7 @@ import YAML from "yaml"; type AtLeast = Partial & Pick; type UnknownRecord = Record; type ImportResources = { - workspaces: AtLeast[]; + workspaces: AtLeast[]; environments: AtLeast[]; folders: AtLeast[]; httpRequests: AtLeast[]; @@ -61,6 +61,7 @@ export async function convertOpenApi(contents: string): Promise 1, + }); + workspace.authentication = workspaceAuthentication.authentication; + workspace.authenticationType = workspaceAuthentication.authenticationType; + const folderIdsByTag = new Map(); const routeLabels = new Map(); for (const tag of toArray(spec.tags)) { @@ -125,6 +143,7 @@ export async function convertOpenApi(contents: string): Promise request.authenticationType === "oauth2")) { + const authenticationConfigs = [workspace, ...resources.httpRequests]; + if (authenticationConfigs.some((model) => model.authenticationType === "oauth2")) { const variableNames = new Set( [...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [ clientId, @@ -152,10 +172,10 @@ export async function convertOpenApi(contents: string): Promise - request.authenticationType === "oauth2" && - Object.values(toRecord(request.authentication)).some( + authenticationConfigs.some( + (model) => + model.authenticationType === "oauth2" && + Object.values(toRecord(model.authentication)).some( (value) => typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")), ), @@ -255,6 +275,7 @@ function disambiguateNames( function importOperation({ importState, + inheritedAuthentication, method, operation, oauthVariablesByScheme, @@ -270,6 +291,7 @@ function importOperation({ authenticationVariables, }: { importState: ImportState; + inheritedAuthentication: ImportedAuthentication; method: string; operation: UnknownRecord; oauthVariablesByScheme: Map; @@ -291,14 +313,23 @@ function importOperation({ operationParameters: toArray(operation.parameters), }); const body = importBody({ importState, operation, parameters, spec }); - const authentication = importAuthentication({ - authenticationVariables, - importState, - oauthVariablesByScheme, - operation, - spec, - useDynamicServerUrls, - }); + // Operations without their own security inherit the workspace's (null + // authenticationType), the same way an operation inherits spec security + const hasOwnSecurity = Array.isArray(operation.security); + const authentication = hasOwnSecurity + ? importAuthentication({ + authenticationVariables, + importState, + oauthVariablesByScheme, + security: operation.security, + spec, + useDynamicServerUrls, + }) + : { + ...emptyAuthentication(), + headers: inheritedAuthentication.headers, + urlParameters: inheritedAuthentication.urlParameters, + }; const pathExampleValues = new Map( parameters .map((p) => importState.resolve(p)) @@ -792,18 +823,52 @@ function importUrlParameters({ stringAt(p, "in") === "query" || (stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")), ) - .map((p) => ({ + .flatMap((p) => { + const name = stringAt(p, "name") ?? ""; + if (name.length === 0) return []; + // 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") ?? ""}` - : (stringAt(p, "name") ?? ""), - value: parameterExample(p, importState), - })) - .filter(({ name }) => name.length > 0); + const enabled = p.required === true || stringAt(p, "in") === "path"; + if (stringAt(p, "in") === "query") { + const raw = rawParameterExample(p, importState); + if (Array.isArray(raw)) { + const { separator } = queryArraySerialization(p); + if (separator == null) { + return raw.map((item) => ({ enabled, name, value: stringifyExampleValue(item) })); + } + return [{ enabled, name, value: raw.map(stringifyExampleValue).join(separator) }]; + } + } + + return [ + { + enabled, + name: stringAt(p, "in") === "path" ? `:${name}` : name, + value: parameterExample(p, importState), + }, + ]; + }); +} + +/** + * OpenAPI 3 query arrays default to form/explode, one parameter per item; + * Swagger 2 defaults to comma-separated unless collectionFormat says otherwise. + * A null separator means repeated parameters. + */ +function queryArraySerialization(parameter: UnknownRecord): { separator: string | null } { + const collectionFormat = stringAt(parameter, "collectionFormat"); + if (collectionFormat != null || parameter.schema == null) { + if (collectionFormat === "multi") return { separator: null }; + return { + separator: { csv: ",", ssv: " ", tsv: "\t", pipes: "|" }[collectionFormat ?? "csv"] ?? ",", + }; + } + const style = stringAt(parameter, "style"); + if (style === "spaceDelimited") return { separator: " " }; + if (style === "pipeDelimited") return { separator: "|" }; + return parameter.explode === false ? { separator: "," } : { separator: null }; } // The spec says header parameters with these names SHALL be ignored; Accept and @@ -857,15 +922,23 @@ function importCookieHeader({ ]; } -function parameterExample(parameter: UnknownRecord, importState: ImportState): string { +function rawParameterExample(parameter: UnknownRecord, importState: ImportState): unknown { const directExample = firstPresent( parameter.example, firstExampleValue(parameter.examples, importState), ); - if (directExample != null) return stringifyExampleValue(directExample); - const example = stringifyExampleValue( - schemaToExample(importState.resolve(parameter.schema), importState), - ); + if (directExample != null) return directExample; + // Swagger 2 parameters carry the schema keywords (type, items, default) + // directly on the parameter object + return schemaToExample(importState.resolve(parameter.schema ?? parameter), importState); +} + +function parameterExample(parameter: UnknownRecord, importState: ImportState): string { + const raw = rawParameterExample(parameter, importState); + // Simple/csv style, the default everywhere but query strings + const example = Array.isArray(raw) + ? raw.map(stringifyExampleValue).join(",") + : stringifyExampleValue(raw); // 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") { @@ -1158,34 +1231,28 @@ function inferSchemaType(schema: UnknownRecord): string { return "string"; } +/** + * Security Requirement Objects are ordered alternatives, so the first one this + * importer can represent wins. That makes `[{bearer}, {}]` import the bearer + * auth the author listed first, while `[{}, {bearer}]` imports as anonymous. + */ function importAuthentication({ authenticationVariables, importState, oauthVariablesByScheme, - operation, + security, spec, useDynamicServerUrls, }: { authenticationVariables: AuthenticationVariableRegistry; importState: ImportState; oauthVariablesByScheme: Map; - operation: UnknownRecord; + security: unknown; spec: UnknownRecord; useDynamicServerUrls: boolean; }): ImportedAuthentication { - const security = operation.security ?? spec.security; - if (Array.isArray(operation.security) && operation.security.length === 0) { - return { ...emptyAuthentication(), authenticationType: "none" }; - } - if (!Array.isArray(security) || security.length === 0) { - return emptyAuthentication(); - } - - // Security Requirement Objects are alternatives. If any alternative is - // empty, authentication is optional regardless of where it appears. - if ( - security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0) - ) { + if (!Array.isArray(security)) return emptyAuthentication(); + if (security.length === 0) { return { ...emptyAuthentication(), authenticationType: "none" }; } @@ -1195,6 +1262,9 @@ function importAuthentication({ }; for (const rawRequirement of security) { if (!isRecord(rawRequirement)) continue; + if (Object.keys(rawRequirement).length === 0) { + return { ...emptyAuthentication(), authenticationType: "none" }; + } const imported = importSecurityRequirement({ authenticationVariables, @@ -1208,7 +1278,9 @@ function importAuthentication({ if (imported != null) return imported; } - return emptyAuthentication(); + // Declared security this importer cannot represent (e.g. mutualTLS alone) + // should not fall back to inheriting some other authentication + return { ...emptyAuthentication(), authenticationType: "none" }; } function importSecurityRequirement({ diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index 0f88d57b..f9aac685 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -292,6 +292,8 @@ Responses: "websocketRequests": [], "workspaces": [ { + "authentication": {}, + "authenticationType": "none", "description": "Wikipedia for Web APIs. Repository of API definitions in OpenAPI format. **Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA). Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui) @@ -2587,6 +2589,8 @@ Responses: "websocketRequests": [], "workspaces": [ { + "authentication": {}, + "authenticationType": null, "description": "A simple HTTP Request & Response Service.

Run locally: $ docker run -p 80:80 kennethreitz/httpbin Contact: me@kennethreitz.org", @@ -2717,6 +2721,8 @@ Responses: "websocketRequests": [], "workspaces": [ { + "authentication": {}, + "authenticationType": null, "description": "This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery Contact: evan.t.yates@nasa.gov @@ -2819,6 +2825,8 @@ Responses: "websocketRequests": [], "workspaces": [ { + "authentication": {}, + "authenticationType": null, "description": "Webcomic of romance, sarcasm, math, and language.", "id": "GENERATE_ID::WORKSPACE_0", "model": "workspace", diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index ec441265..a6399036 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -850,7 +850,7 @@ describe("importer-openapi", () => { ]); }); - test("Preserves anonymous security alternatives and explicit auth overrides", async () => { + test("Respects the order of security alternatives and explicit auth overrides", async () => { const imported = await convertOpenApi( JSON.stringify({ openapi: "3.1.0", @@ -873,10 +873,158 @@ describe("importer-openapi", () => { }), ); + expect(imported?.resources.workspaces[0]).toEqual( + expect.objectContaining({ + authenticationType: "bearer", + authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" }, + }), + ); expect(imported?.resources.httpRequests).toEqual([ + // The author listed bearer before the anonymous alternative + expect.objectContaining({ + authenticationType: "bearer", + authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" }, + }), expect.objectContaining({ authenticationType: "none", authentication: {} }), expect.objectContaining({ authenticationType: "none", authentication: {} }), - expect.objectContaining({ authenticationType: "none", authentication: {} }), + ]); + }); + + test("Imports spec-level security as inherited workspace authentication", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Inherited Auth", version: "1.0.0" }, + security: [{ bearerAuth: [], queryKey: [], headerKey: [] }], + paths: { + "/inherits": { get: { responses: {} } }, + "/own-auth": { get: { security: [{ otherKey: [] }], responses: {} } }, + "/public": { get: { security: [], responses: {} } }, + }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + queryKey: { type: "apiKey", in: "query", name: "api_key" }, + headerKey: { type: "apiKey", in: "header", name: "X-Tenant" }, + otherKey: { type: "apiKey", in: "header", name: "X-Other" }, + }, + }, + }), + ); + + expect(imported?.resources.workspaces[0]).toEqual( + expect.objectContaining({ + authenticationType: "bearer", + authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" }, + }), + ); + expect(imported?.resources.httpRequests).toEqual([ + // No authenticationType means inherit; materialized API keys ride along + // on the request since headers or parameters at the workspace level + // would also reach operations that opted out + expect.objectContaining({ + authentication: {}, + headers: [{ enabled: true, name: "X-Tenant", value: "${[auth_header_key_key]}" }], + urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }], + }), + expect.objectContaining({ + authenticationType: "apikey", + authentication: expect.objectContaining({ key: "X-Other" }), + headers: [], + urlParameters: [], + }), + expect.objectContaining({ + authenticationType: "none", + headers: [], + urlParameters: [], + }), + ]); + expect(imported?.resources.httpRequests[0]?.authenticationType).toBeNull(); + }); + + test("Serializes array query parameters per style", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Array Params", version: "1.0.0" }, + paths: { + "/exploded": { + get: { + parameters: [ + { + name: "tags", + in: "query", + required: true, + schema: { type: "array", items: { type: "string" }, example: ["a", "b"] }, + }, + ], + responses: {}, + }, + }, + "/csv": { + get: { + parameters: [ + { + name: "ids", + in: "query", + required: true, + explode: false, + schema: { type: "array", example: [1, 2, 3] }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests.map((r) => r.urlParameters)).toEqual([ + [ + { enabled: true, name: "tags", value: "a" }, + { enabled: true, name: "tags", value: "b" }, + ], + [{ enabled: true, name: "ids", value: "1,2,3" }], + ]); + }); + + test("Serializes Swagger 2 array parameters per collectionFormat", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + swagger: "2.0", + info: { title: "Swagger Arrays", version: "1.0.0" }, + host: "example.com", + paths: { + "/a": { + get: { + parameters: [ + { + name: "tags", + in: "query", + required: true, + type: "array", + items: { type: "string", default: "x" }, + }, + { + name: "multi", + in: "query", + required: true, + type: "array", + collectionFormat: "multi", + items: { type: "string", enum: ["m1"] }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([ + // Swagger defaults to csv + { enabled: true, name: "tags", value: "x" }, + { enabled: true, name: "multi", value: "m1" }, ]); }); From 4e835cf7e8e01ce207877659c2e0e3bba147962c Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 07:44:15 -0700 Subject: [PATCH 10/13] Add OpenAPI import round-trip harness (#600) --- .github/workflows/ci.yml | 4 + plugins/importer-openapi/package.json | 3 +- plugins/importer-openapi/tests/roundtrip.mjs | 306 +++++++++++++++++++ 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 plugins/importer-openapi/tests/roundtrip.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecf835e9..dd4f9082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,3 +31,7 @@ jobs: run: vp test - name: Run Rust Tests run: cargo test --all --features yaak-app-client/wry + - name: OpenAPI import round-trip + run: | + cargo build -p yaak-cli + node plugins/importer-openapi/tests/roundtrip.mjs diff --git a/plugins/importer-openapi/package.json b/plugins/importer-openapi/package.json index e312db11..6d27c320 100644 --- a/plugins/importer-openapi/package.json +++ b/plugins/importer-openapi/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "yaakcli build", "dev": "yaakcli dev", - "test": "vp test --run tests" + "test": "vp test --run tests", + "test:roundtrip": "node tests/roundtrip.mjs" }, "dependencies": { "yaml": "^2.8.3" diff --git a/plugins/importer-openapi/tests/roundtrip.mjs b/plugins/importer-openapi/tests/roundtrip.mjs new file mode 100644 index 00000000..5b3637ce --- /dev/null +++ b/plugins/importer-openapi/tests/roundtrip.mjs @@ -0,0 +1,306 @@ +// Round-trip harness: import a spec with the local importer plugin, send every +// request through the real Yaak CLI pipeline against a Prism mock of the same +// spec, and report Prism's validation verdict for each request. +// +// Prism independently validates each incoming request against the spec (paths, +// required parameters, body schemas, security), so a violation here is an +// importer bug found by a second OpenAPI implementation rather than a snapshot +// of our own output. +// +// Usage: node tests/roundtrip.mjs [spec.yaml ...] +// YAAK_BIN=/path/to/yaak overrides the CLI (defaults to the repo debug build, +// which embeds the plugins vendored from this checkout). + +import { execFileSync, spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../.."); +const yaakBin = process.env.YAAK_BIN ?? path.join(repoRoot, "target/debug/yaak"); +const specs = + process.argv.length > 2 + ? process.argv.slice(2) + : [ + path.join(here, "fixtures/petstore.yaml"), + ...fs + .readdirSync(path.join(here, "fixtures/real-world")) + .filter((f) => f.endsWith(".yaml")) + .map((f) => path.join(here, "fixtures/real-world", f)), + ]; + +if (!fs.existsSync(yaakBin)) { + console.error(`Yaak CLI not found at ${yaakBin}. Build it with: cargo build -p yaak-cli`); + process.exit(2); +} + +// Pinned so local runs and CI judge against the same validator +const PRISM_PACKAGE = "@stoplight/prism-cli@5.15.11"; + +// Accepted spec-quality gray zones, not importer bugs. httpbin's required +// `url` query parameter has no example, and an empty value is preferable to +// inventing fake query data even though Prism counts it as missing. +const KNOWN_FLAGS = new Set(["httpbin.yaml GET ${[baseUrl]}/redirect-to"]); + +function yaak(dataDir, args) { + return execFileSync(yaakBin, ["--data-dir", dataDir, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function yaakJson(dataDir, args) { + return JSON.parse(yaak(dataDir, args)); +} + +function listIds(output) { + return output + .split("\n") + .map((line) => line.match(/^(\w+_\w+) - /)?.[1]) + .filter(Boolean); +} + +async function startPrism(spec, port) { + for (let attempt = 0; attempt < 20; attempt++, port++) { + const prism = spawn( + "npx", + ["-y", PRISM_PACKAGE, "mock", "--errors", "-p", String(port), "-h", "127.0.0.1", spec], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let log = ""; + prism.stdout.on("data", (d) => (log += d)); + prism.stderr.on("data", (d) => (log += d)); + // Generous: the first run downloads Prism through npx + const deadline = Date.now() + 120_000; + let failed = false; + while (Date.now() < deadline) { + if (log.includes("Prism is listening")) return { prism, port, getLog: () => log }; + if (log.includes("EADDRINUSE") || prism.exitCode != null) { + failed = true; + break; + } + await new Promise((r) => setTimeout(r, 200)); + } + prism.kill(); + if (failed) { + // The exit can be observed before its buffered stderr arrives; wait for + // the streams to close so the bind error is distinguishable + await Promise.race([ + new Promise((r) => prism.once("close", r)), + new Promise((r) => setTimeout(r, 2000)), + ]); + if (log.includes("EADDRINUSE")) continue; + throw new Error(`Prism exited:\n${log}`); + } + throw new Error(`Prism did not start in time:\n${log}`); + } + throw new Error("No free port found for Prism"); +} + +function pointVariablesAtPrism(variables, prismUrl) { + return variables.map((v) => { + if (v.name === "baseUrl" || v.name.startsWith("serverUrl")) return { ...v, value: prismUrl }; + if (v.name === "baseUrlOrigin") return { ...v, value: prismUrl }; + if (v.value === "") return { ...v, value: "test-value" }; + return v; + }); +} + +// Prism reports its verdict in the sl-violations response header. Violations +// located in the request are importer bugs; violations located in the response +// mean Prism could not fabricate a spec-valid mock response (the spec's own +// examples are broken), which says nothing about the import. +function classify(response, bodyText) { + if (response.error) return { verdict: "SEND ERROR", detail: response.error }; + + const violationsHeader = (response.headers ?? []).find( + (h) => h.name?.toLowerCase() === "sl-violations", + ); + let violations = []; + try { + violations = JSON.parse(violationsHeader?.value ?? "[]"); + } catch {} + const requestViolations = violations.filter((v) => v.location?.[0] === "request"); + const detail = requestViolations.map((v) => `${v.location.join(".")}: ${v.message}`).join("; "); + + if (requestViolations.length > 0) return { verdict: "VIOLATION", detail }; + + // A spec-defined error response (e.g. an operation whose only response is a + // 405) mocks as that status with no Prism error type; only Prism's own error + // bodies mark a request Prism could not accept. + let body = null; + try { + body = JSON.parse(bodyText); + } catch {} + const prismError = + typeof body?.type === "string" && body.type.includes("stoplight.io/prism/errors") + ? body.type.split("#")[1] + : null; + if (prismError == null) return { verdict: "ok", detail: "" }; + + // Failures to fabricate a mock response say nothing about the request we sent + if (prismError === "NO_COMPLEX_OBJECT_TEXT" || prismError === "NO_RESPONSE_DEFINED") { + return { verdict: "ok", detail: "" }; + } + + const bodyViolations = Array.isArray(body.validation) + ? body.validation.filter((v) => v.location?.[0] === "request") + : []; + if (prismError === "VIOLATIONS" && bodyViolations.length === 0) { + return { verdict: "ok", detail: "" }; // response-side only + } + return { + verdict: + prismError === "VIOLATIONS" || prismError === "UNPROCESSABLE_ENTITY" + ? "VIOLATION" + : prismError.includes("MATCHED") + ? "NO ROUTE" + : prismError === "UNAUTHORIZED" + ? "SECURITY" + : prismError, + detail: + bodyViolations.map((v) => `${v.location.join(".")}: ${v.message}`).join("; ") || + body.detail || + "", + }; +} + +let totalProblems = 0; +let port = 4010; + +for (const spec of specs) { + const name = path.basename(spec); + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "yaak-roundtrip-")); + console.log(`\n=== ${name} ===`); + + try { + yaak(dataDir, ["import", spec]); + const workspaceId = listIds(yaak(dataDir, ["workspace", "list"]))[0]; + if (workspaceId == null) { + console.log(" IMPORT PRODUCED NO WORKSPACE"); + totalProblems++; + continue; + } + // Prism mocks redirect responses without a Location header; following them + // would fail the send for a reason unrelated to the import. Workspace-level + // OAuth2 gets the same dummy-bearer treatment as request-level below. + const workspace = yaakJson(dataDir, ["workspace", "show", workspaceId]); + yaak(dataDir, [ + "workspace", + "update", + "--json", + JSON.stringify({ + id: workspaceId, + settingFollowRedirects: false, + ...(workspace.authenticationType === "oauth2" + ? { + authenticationType: "bearer", + authentication: { token: "test-token", prefix: "Bearer" }, + } + : {}), + }), + ]); + + const { prism, port: boundPort, getLog } = await startPrism(spec, port); + port = boundPort + 1; + try { + const prismUrl = `http://127.0.0.1:${boundPort}`; + const environmentIds = listIds(yaak(dataDir, ["environment", "list", workspaceId])); + let activeEnvironment = null; + for (const id of environmentIds) { + const environment = yaakJson(dataDir, ["environment", "show", id]); + yaak(dataDir, [ + "environment", + "update", + "--json", + JSON.stringify({ + id, + variables: pointVariablesAtPrism(environment.variables ?? [], prismUrl), + }), + ]); + if (environment.parentModel === "environment" && activeEnvironment == null) { + activeEnvironment = id; + } + } + + const requestIds = listIds(yaak(dataDir, ["request", "list", workspaceId])); + const requests = new Map(); + for (const id of requestIds) { + const request = yaakJson(dataDir, ["request", "show", id]); + requests.set(id, request); + // OAuth2 would try to fetch a real token; Prism only checks that the + // Authorization header is present, so a dummy bearer keeps it satisfied. + if (request.authenticationType === "oauth2") { + yaak(dataDir, [ + "request", + "update", + "--json", + JSON.stringify({ + id, + authenticationType: "bearer", + authentication: { token: "test-token", prefix: "Bearer" }, + }), + ]); + } + } + + const sendArgs = ["send", workspaceId]; + if (activeEnvironment != null) sendArgs.push("-e", activeEnvironment); + try { + yaak(dataDir, sendArgs); + } catch { + // Individual send failures surface per-request below. + } + + let problems = 0; + for (const id of requestIds) { + const request = requests.get(id); + const label = `${request.method} ${request.url}`; + let response = null; + let bodyText = ""; + try { + response = yaakJson(dataDir, ["response", "show", id]); + try { + bodyText = yaak(dataDir, ["response", "body", id]); + } catch {} + } catch { + console.log(` NEVER SENT ${label}`); + problems++; + continue; + } + const { verdict, detail } = classify(response, bodyText); + if (verdict === "ok") continue; + if (KNOWN_FLAGS.has(`${name} ${label}`)) { + console.log(` known ${label}`); + continue; + } + problems++; + console.log(` ${verdict.padEnd(12)} ${label}`); + console.log(` sent: ${response.url ?? "?"}`); + if (detail) console.log(` ${detail}`); + } + + const requestCount = requestIds.length; + if (problems === 0) { + console.log(` all ${requestCount} requests validated clean against the mock`); + } else { + console.log(` ${problems}/${requestCount} requests flagged`); + totalProblems += problems; + } + const inputWarnings = getLog() + .split("\n") + .filter((l) => l.includes("[VALIDATOR]") && !l.includes("output")); + if (inputWarnings.length > 0) { + console.log(` prism validator log lines: ${inputWarnings.length}`); + } + } finally { + prism.kill(); + } + } finally { + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +process.exit(totalProblems > 0 ? 1 : 0); From cb295b71024f3e6bfc1c578a6fc66a8fbecd31f5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Viet <123613986+NgoQuocViet2001@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:41:03 +0700 Subject: [PATCH 11/13] fix(importer-curl): keep --data-urlencode whole and survive a stray percent (#597) --- plugins/importer-curl/src/index.ts | 50 ++++++++++- plugins/importer-curl/tests/index.test.ts | 101 ++++++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index dec52c65..2ab6c5b0 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -506,7 +506,17 @@ function importCommand(parseEntries: string[], workspaceId: string) { form: multipartFormDataFromRaw, }; } else if (dataParameters.length > 0 && bodyAsGET) { - urlParameters.push(...dataParameters); + // `-G` moves the data into the query string, and Yaak encodes url + // parameters on send exactly as it encodes the form body below, so this + // needs the same decode -- otherwise a `--data-urlencode` value arrives + // here already encoded and goes out encoded twice. + urlParameters.push( + ...dataParameters.map((parameter) => ({ + ...parameter, + name: decodePercentEncoding(parameter.name), + value: decodePercentEncoding(parameter.value), + })), + ); } else if ( dataParameters.length > 0 && (mimeType == null || mimeType === "application/x-www-form-urlencoded") @@ -515,8 +525,8 @@ function importCommand(parseEntries: string[], workspaceId: string) { body = { form: dataParameters.map((parameter) => ({ ...parameter, - name: decodeURIComponent(parameter.name || ""), - value: decodeURIComponent(parameter.value || ""), + name: decodePercentEncoding(parameter.name), + value: decodePercentEncoding(parameter.value), })), }; filteredHeaders.push({ @@ -593,6 +603,34 @@ interface DataParameter { enabled?: boolean; } +/** + * Decode a percent-encoded form value, keeping it as-is when it is not one. + * + * Yaak's form editor holds decoded values and re-encodes them on send, so a + * `-d` value has to be decoded on the way in. But curl sends that value + * verbatim and does not require it to be valid percent-encoding: `a=100%` is + * an ordinary form value, and `decodeURIComponent` throws URIError on it, + * which failed the whole import rather than that one parameter. + */ +function decodePercentEncoding(value: string | undefined): string { + const text = value || ""; + try { + return decodeURIComponent(text); + } catch { + // Mixed: some of it is percent-encoded and some of it is a stray `%`. + // Returning the whole string untouched would leave the encoded part to be + // encoded a second time on send, so decode each valid run on its own and + // leave the stray byte alone. A run rather than a single escape, because a + // non-ASCII character is several escapes that only decode together. + return text.replace(/(%[0-9A-Fa-f]{2})+/g, (run) => { + try { + return decodeURIComponent(run); + } catch { + return run; + } + }); + } +} function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] { const dataParameters: DataParameter[] = []; @@ -605,7 +643,11 @@ function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] { for (const p of pairs) { if (typeof p !== "string") continue; - const params = p.split("&"); + // `-d` content really is `&`-separated, so splitting it is right. But + // `--data-urlencode` encodes its whole argument — an `&` inside it is + // data curl percent-encodes, not a separator, so splitting there turned + // one parameter into several and changed what the request sends. + const params = flagName === "data-urlencode" ? [p] : p.split("&"); for (const param of params) { const [name, value] = splitOnce(param, "="); if (param.startsWith("@")) { diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index 3c2d6dc0..79d31a07 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -244,6 +244,107 @@ describe("importer-curl", () => { }); }); + test("Keeps an --data-urlencode value whole", () => { + // curl encodes the whole argument, so the `&` and the second `=` are data it + // percent-encodes, not separators. Splitting on them made two parameters + // out of one, and Yaak then re-sent `q=a&b=c` where curl sends + // `q=a%26b%3Dc`. One parameter here re-encodes back to what curl sends. + expect(convertCurl(`curl --data-urlencode 'q=a&b=c' https://yaak.app`)).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + method: "POST", + url: "https://yaak.app", + bodyType: "application/x-www-form-urlencoded", + headers: [ + { + name: "Content-Type", + value: "application/x-www-form-urlencoded", + enabled: true, + }, + ], + body: { + form: [{ name: "q", value: "a&b=c", enabled: true }], + }, + }), + ], + }, + }); + }); + + test("Imports a data value that is not valid percent-encoding", () => { + // curl sends a `-d` value verbatim and does not require it to decode, so + // a lone `%` is an ordinary form value. decodeURIComponent threw URIError + // on it and failed the whole import. + expect(convertCurl(`curl -d 'a=100%' https://yaak.app`)).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + method: "POST", + url: "https://yaak.app", + bodyType: "application/x-www-form-urlencoded", + headers: [ + { + name: "Content-Type", + value: "application/x-www-form-urlencoded", + enabled: true, + }, + ], + body: { + form: [{ name: "a", value: "100%", enabled: true }], + }, + }), + ], + }, + }); + }); + + test("Keeps a valid escape decoded when the value also holds a stray percent", () => { + // Handing the whole value back untouched would leave `%25` to be encoded a + // second time on send, so each valid run decodes on its own. + expect(convertCurl(`curl -d 'a=50%25 and 100%' https://yaak.app`)).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + method: "POST", + url: "https://yaak.app", + bodyType: "application/x-www-form-urlencoded", + headers: [ + { + name: "Content-Type", + value: "application/x-www-form-urlencoded", + enabled: true, + }, + ], + body: { + form: [{ name: "a", value: "50% and 100%", enabled: true }], + }, + }), + ], + }, + }); + }); + + test("Decodes -G --data-urlencode into the query string", () => { + // `-G` puts the data in the query string, which is encoded on send just + // like the form body, so the value has to arrive here decoded or it goes + // out encoded twice. + expect(convertCurl(`curl -G --data-urlencode 'q=a&b' https://yaak.app`)).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + url: "https://yaak.app", + urlParameters: [{ name: "q", value: "a&b", enabled: true }], + }), + ], + }, + }); + }); + test("Imports data params as text", () => { expect( convertCurl("curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app"), From 388dd8815f5843fcba9b93587e4c15e8c9b1e5c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Viet <123613986+NgoQuocViet2001@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:41:47 +0700 Subject: [PATCH 12/13] fix(copy-as): emit shell-safe arguments in the curl and gRPCurl exporters (#593) --- plugins/action-copy-curl/src/index.ts | 9 +++- plugins/action-copy-curl/tests/index.test.ts | 41 ++++++++++++++++++- plugins/action-copy-grpcurl/src/index.ts | 5 ++- .../action-copy-grpcurl/tests/index.test.ts | 17 ++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/plugins/action-copy-curl/src/index.ts b/plugins/action-copy-curl/src/index.ts index 4f02afa7..1efbaf3c 100644 --- a/plugins/action-copy-curl/src/index.ts +++ b/plugins/action-copy-curl/src/index.ts @@ -83,7 +83,9 @@ export async function convertToCurl(request: Partial) { if (p.file) { let v = `${p.name}=@${p.file}`; v += p.contentType ? `;type=${p.contentType}` : ""; - xs.push(flag, v); + // A bare `;` separates commands and a path can hold spaces, so this + // argument needs quoting like every other one. + xs.push(flag, quote(v)); } else { xs.push(flag, quote(`${p.name}=${p.value}`)); } @@ -157,7 +159,10 @@ export async function convertToCurl(request: Partial) { } function quote(arg: string): string { - const escaped = arg.replace(/'/g, "\\'"); + // A single-quoted POSIX string takes no escapes, so `\'` does not close it: + // the string ends one character early and the rest of the command is left + // dangling. Step out of the quotes, emit an escaped quote, step back in. + const escaped = arg.replace(/'/g, `'\\''`); return `'${escaped}'`; } diff --git a/plugins/action-copy-curl/tests/index.test.ts b/plugins/action-copy-curl/tests/index.test.ts index 824cf718..3f2e167a 100644 --- a/plugins/action-copy-curl/tests/index.test.ts +++ b/plugins/action-copy-curl/tests/index.test.ts @@ -120,7 +120,7 @@ describe("exporter-curl", () => { `curl -X PUT 'https://yaak.app'`, `--form 'a=aaa'`, `--form 'b=bbb'`, - "--form f=@/foo/bar.png;type=image/png", + "--form 'f=@/foo/bar.png;type=image/png'", ].join(" \\\n "), ); }); @@ -140,11 +140,48 @@ describe("exporter-curl", () => { [ `curl -X POST 'https://yaak.app'`, `--header 'Content-Type: application/json'`, - `--data '{"foo":"bar\\'s"}'`, + `--data '{"foo":"bar'\\''s"}'`, ].join(" \\\n "), ); }); + test("Quotes an apostrophe so the command still parses", async () => { + // POSIX single quotes take no escapes: `\'` ends the string one + // character early and everything after it is left dangling, so the copied + // command is a syntax error rather than a request. + const command = await convertToCurl({ + url: "https://yaak.app/it's", + method: "POST", + bodyType: "application/json", + body: { text: `{"note":"don't stop"}` }, + headers: [{ name: "X-Note", value: "it's fine" }], + }); + + expect(command).toEqual( + [ + `curl -X POST 'https://yaak.app/it'\\''s'`, + `--header 'X-Note: it'\\''s fine'`, + `--data '{"note":"don'\\''t stop"}'`, + ].join(" \\\n "), + ); + }); + + test("Quotes a file form field so its type suffix survives", async () => { + // A bare `;` separates commands, so an unquoted `f=@x.png;type=image/png` + // reaches curl as `f=@x.png` and the rest runs as its own command. + expect( + await convertToCurl({ + url: "https://yaak.app", + method: "POST", + bodyType: "multipart/form-data", + body: { form: [{ name: "f", file: "/my files/a.png", contentType: "image/png" }] }, + }), + ).toEqual( + [`curl -X POST 'https://yaak.app'`, `--form 'f=@/my files/a.png;type=image/png'`].join( + " \\\n ", + ), + ); + }); test("Exports multi-line JSON body", async () => { expect( await convertToCurl({ diff --git a/plugins/action-copy-grpcurl/src/index.ts b/plugins/action-copy-grpcurl/src/index.ts index 1dd6378e..32232980 100644 --- a/plugins/action-copy-grpcurl/src/index.ts +++ b/plugins/action-copy-grpcurl/src/index.ts @@ -129,7 +129,10 @@ export async function convert(request: Partial, allProtoFiles: stri } function quote(arg: string): string { - const escaped = arg.replace(/'/g, "\\'"); + // A single-quoted POSIX string takes no escapes, so `\'` does not close it: + // the string ends one character early and the rest of the command is left + // dangling. Step out of the quotes, emit an escaped quote, step back in. + const escaped = arg.replace(/'/g, `'\\''`); return `'${escaped}'`; } diff --git a/plugins/action-copy-grpcurl/tests/index.test.ts b/plugins/action-copy-grpcurl/tests/index.test.ts index e4144a18..9cec88cc 100644 --- a/plugins/action-copy-grpcurl/tests/index.test.ts +++ b/plugins/action-copy-grpcurl/tests/index.test.ts @@ -175,4 +175,21 @@ describe("exporter-curl", () => { ].join(" \\\n "), ); }); + + test("Quotes an apostrophe so the command still parses", async () => { + // POSIX single quotes take no escapes: `\'` ends the string one + // character early and leaves the rest of the command dangling. + const command = await convert( + { + url: "https://yaak.app", + service: "Service", + method: "Method", + message: `{"note":"don't stop"}`, + metadata: [{ name: "x-note", value: "it's fine" }], + }, + [], + ); + expect(command).toContain(`'{"note":"don'\\''t stop"}'`); + expect(command).toContain(`'x-note: it'\\''s fine'`); + }); }); From 66c13d7e13e6bda6cd436a87ab254955dcd45937 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 08:42:42 -0700 Subject: [PATCH 13/13] Port OpenAPI improvements from the integration branch (#602) --- crates/yaak-http/src/types.rs | 147 ++- crates/yaak-models/src/queries/folders.rs | 6 +- .../yaak-models/src/queries/grpc_requests.rs | 6 +- .../yaak-models/src/queries/http_requests.rs | 47 +- crates/yaak-models/src/queries/mod.rs | 70 +- .../src/queries/websocket_requests.rs | 10 +- crates/yaak-models/src/queries/workspaces.rs | 5 +- plugins/importer-openapi/src/index.ts | 790 ++++++++++++---- plugins/importer-openapi/tests/index.test.ts | 865 +++++++++++++++++- 9 files changed, 1735 insertions(+), 211 deletions(-) diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index d7931684..8800c06a 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -78,8 +78,19 @@ impl SendableHttpRequest { } pub fn insert_header(&mut self, header: (String, String)) { + if header.0.eq_ignore_ascii_case("cookie") { + if let Some(existing) = + self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie")) + { + existing.1 = format!("{}; {}", existing.1, header.1); + } else { + self.headers.push(header); + } + return; + } + if let Some(existing) = - self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase()) + self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0)) { existing.1 = header.1; } else { @@ -205,16 +216,23 @@ fn append_graphql_query_params(url: &str, body: &BTreeMap Vec<(String, String)> { - r.headers - .iter() - .filter_map(|h| { - if h.enabled && !h.name.is_empty() { - Some((h.name.clone(), h.value.clone())) - } else { - None + // RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into + // the first one + let mut headers: Vec<(String, String)> = Vec::new(); + for h in &r.headers { + if !h.enabled || h.name.is_empty() { + continue; + } + if h.name.eq_ignore_ascii_case("cookie") { + if let Some(existing) = headers.iter_mut().find(|e| e.0.eq_ignore_ascii_case("cookie")) + { + existing.1 = format!("{}; {}", existing.1, h.value); + continue; } - }) - .collect() + } + headers.push((h.name.clone(), h.value.clone())); + } + headers } async fn build_body( @@ -494,7 +512,114 @@ mod tests { use bytes::Bytes; use serde_json::json; use std::collections::BTreeMap; - use yaak_models::models::{HttpRequest, HttpUrlParameter}; + use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; + + #[tokio::test] + async fn test_sendable_request_preserves_independent_cookie_enabled_states() { + let request = HttpRequest { + url: "https://example.com/api".to_string(), + headers: vec![ + HttpRequestHeader { + enabled: true, + name: "Cookie".to_string(), + value: "session=abc".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "debug=verbose".to_string(), + id: None, + }, + ], + ..Default::default() + }; + + let sendable = + SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default()) + .await + .unwrap(); + + assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]); + } + + #[tokio::test] + async fn test_sendable_request_merges_enabled_cookie_rows_into_one_field() { + let request = HttpRequest { + url: "https://example.com/api".to_string(), + headers: vec![ + HttpRequestHeader { + enabled: true, + name: "Cookie".to_string(), + value: "session=abc".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "debug=verbose".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: true, + name: "cookie".to_string(), + value: "theme=dark".to_string(), + id: None, + }, + ], + ..Default::default() + }; + + let sendable = + SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default()) + .await + .unwrap(); + + assert_eq!( + sendable.headers, + vec![("Cookie".to_string(), "session=abc; theme=dark".to_string())], + ); + } + + #[test] + fn test_insert_header_appends_authentication_cookie() { + let mut request = SendableHttpRequest { + headers: vec![ + ("Cookie".to_string(), "session=abc".to_string()), + ("Cookie".to_string(), "theme=dark".to_string()), + ], + ..Default::default() + }; + + request.insert_header(("cookie".to_string(), "api_key=secret".to_string())); + + assert_eq!( + request.headers, + vec![ + ("Cookie".to_string(), "session=abc; api_key=secret".to_string()), + ("Cookie".to_string(), "theme=dark".to_string()), + ], + ); + } + + #[tokio::test] + async fn test_sendable_request_preserves_serialized_path_delimiters() { + let request = HttpRequest { + url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2" + .to_string(), + ..Default::default() + }; + + let sendable = + SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default()) + .await + .unwrap(); + + assert_eq!( + sendable.url, + "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2", + ); + } #[test] fn test_build_url_no_params() { diff --git a/crates/yaak-models/src/queries/folders.rs b/crates/yaak-models/src/queries/folders.rs index a26e3379..bc5bd389 100644 --- a/crates/yaak-models/src/queries/folders.rs +++ b/crates/yaak-models/src/queries/folders.rs @@ -1,4 +1,4 @@ -use super::conflict_free_name; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::connection_or_tx::ConnectionOrTx; use crate::error::Result; @@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut folder.headers.clone()); - - Ok(headers) + Ok(merge_headers(headers, folder.headers.clone())) } pub fn resolve_settings_for_folder( diff --git a/crates/yaak-models/src/queries/grpc_requests.rs b/crates/yaak-models/src/queries/grpc_requests.rs index f54635b4..593e128f 100644 --- a/crates/yaak-models/src/queries/grpc_requests.rs +++ b/crates/yaak-models/src/queries/grpc_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> { metadata.append(&mut workspace_metadata); } - metadata.append(&mut grpc_request.metadata.clone()); - - Ok(dedupe_headers(metadata)) + Ok(merge_headers(metadata, grpc_request.metadata.clone())) } pub fn resolve_settings_for_grpc_request( diff --git a/crates/yaak-models/src/queries/http_requests.rs b/crates/yaak-models/src/queries/http_requests.rs index 6130ec67..d4e0d6d7 100644 --- a/crates/yaak-models/src/queries/http_requests.rs +++ b/crates/yaak-models/src/queries/http_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut http_request.headers.clone()); - - Ok(dedupe_headers(headers)) + Ok(merge_headers(headers, http_request.headers.clone())) } pub fn resolve_settings_for_http_request( @@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> { Ok(children) } } + +#[cfg(test)] +mod tests { + use crate::init_in_memory; + use crate::models::{HttpRequest, HttpRequestHeader}; + + #[test] + fn request_resolution_preserves_duplicate_request_headers() { + let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0); + let request = HttpRequest { + workspace_id: workspace.id, + headers: vec![ + HttpRequestHeader { + name: "Cookie".to_string(), + value: "required=1".to_string(), + ..Default::default() + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "optional=1".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve"); + let cookies = resolved + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("cookie")) + .collect::>(); + + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].value, "required=1"); + assert_eq!(cookies[1].value, "optional=1"); + assert!(!cookies[1].enabled); + } +} diff --git a/crates/yaak-models/src/queries/mod.rs b/crates/yaak-models/src/queries/mod.rs index 2b6bdc43..e2e8dabc 100644 --- a/crates/yaak-models/src/queries/mod.rs +++ b/crates/yaak-models/src/queries/mod.rs @@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name; const MAX_HISTORY_ITEMS: usize = 20; use crate::models::HttpRequestHeader; -use std::collections::HashMap; +use std::collections::HashSet; -/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value. -/// Preserves the order of first occurrence for each header name. -pub(crate) fn dedupe_headers(headers: Vec) -> Vec { - let mut index_by_name: HashMap = HashMap::new(); - let mut deduped: Vec = Vec::new(); - for header in headers { - let key = header.name.to_lowercase(); - if let Some(&idx) = index_by_name.get(&key) { - deduped[idx] = header; - } else { - index_by_name.insert(key, deduped.len()); - deduped.push(header); - } - } - deduped +/// Merge a more-specific header layer over its parent. Names in the child replace +/// inherited values case-insensitively, while duplicates declared together in +/// either layer remain independent entries. +pub(crate) fn merge_headers( + mut parent: Vec, + child: Vec, +) -> Vec { + let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::>(); + parent.retain(|header| !child_names.contains(&header.name.to_lowercase())); + parent.extend(child); + parent +} + +#[cfg(test)] +mod tests { + use super::merge_headers; + use crate::models::HttpRequestHeader; + + fn header(name: &str, value: &str) -> HttpRequestHeader { + HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() } + } + + #[test] + fn preserves_duplicate_headers_declared_in_one_layer() { + let merged = merge_headers( + vec![header("Cookie", "inherited=1")], + vec![ + header("Cookie", "required=1"), + header("cookie", "optional=1"), + ], + ); + + assert_eq!( + merged.iter().map(|header| header.value.as_str()).collect::>(), + vec!["required=1", "optional=1"], + ); + } + + #[test] + fn child_names_override_parent_names_without_affecting_other_headers() { + let merged = merge_headers( + vec![header("Accept", "*/*"), header("X-Parent", "kept")], + vec![header("accept", "application/json")], + ); + + assert_eq!( + merged + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(), + vec![("X-Parent", "kept"), ("accept", "application/json")], + ); + } } diff --git a/crates/yaak-models/src/queries/websocket_requests.rs b/crates/yaak-models/src/queries/websocket_requests.rs index 3ef4c16d..1dcf752f 100644 --- a/crates/yaak-models/src/queries/websocket_requests.rs +++ b/crates/yaak-models/src/queries/websocket_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> { &self, websocket_request: &WebsocketRequest, ) -> Result> { - let workspace = self.get_workspace(&websocket_request.workspace_id)?; - // Resolved headers should be from furthest to closest ancestor, to override logically. let mut headers = Vec::new(); - headers.append(&mut workspace.headers.clone()); - if let Some(folder_id) = websocket_request.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; @@ -120,9 +116,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut websocket_request.headers.clone()); - - Ok(dedupe_headers(headers)) + Ok(merge_headers(headers, websocket_request.headers.clone())) } pub fn resolve_settings_for_websocket_request( diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index 50deb620..6ed7a262 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -1,3 +1,4 @@ +use super::merge_headers; use crate::blob_manager::BlobManager; use crate::client_db::ClientDb; use crate::error::Result; @@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> { } pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec { - let mut headers = default_headers(); - headers.extend(workspace.headers.clone()); - headers + merge_headers(default_headers(), workspace.headers.clone()) } pub fn resolve_settings_for_workspace( diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 6fce18aa..124aa25d 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -37,6 +37,7 @@ const BODY_CONTENT_TYPE_PREFERENCE = [ "text/plain", ]; const MAX_EXAMPLE_DEPTH = 8; +const MAX_SCHEMA_RESOLUTION_DEPTH = MAX_EXAMPLE_DEPTH; const MAX_EXAMPLE_PROPERTIES = 25; const MAX_DESCRIPTION_ITEMS = 40; const MAX_NAME_LENGTH = 100; @@ -330,20 +331,14 @@ function importOperation({ headers: inheritedAuthentication.headers, urlParameters: inheritedAuthentication.urlParameters, }; - const pathExampleValues = new Map( - parameters - .map((p) => 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( + const url = buildOperationUrl( operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }), path, - pathExampleValues, + parameters, + importState, ); const urlParameters = [ - ...importUrlParameters({ importState, parameters, placeholderNames }), + ...importUrlParameters({ importState, parameters, path }), ...authentication.urlParameters, ]; const headers = mergeHeaders( @@ -700,25 +695,64 @@ function findOrCreateFolderId({ /** * 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. + * segment and hold a single plain value. Templates elsewhere in a segment + * (like `/report.{format}`), styled ones (label, matrix), and array or object + * values get their serialized example inlined instead — a placeholder row + * cannot express them, and its leftover parameter would leak into the query + * string. */ 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 }; + parameters: unknown[], + importState: ImportState, +): string { + let serializedPath = path; + for (const rawParameter of parameters) { + const parameter = importState.resolve(rawParameter); + if (!isRecord(parameter) || !shouldInlinePathParameter(parameter, importState, path)) continue; + + const name = stringAt(parameter, "name") ?? ""; + if (name.length === 0) continue; + const value = parameterExampleValue(parameter, importState); + const serialized = isRecord(parameter.content) + ? encodePathComponent(serializeContentParameter(parameter, importState)) + : serializePathParameter(name, value, parameter, encodePathComponent); + // A missing example stays a visible template rather than vanishing + if (serialized.length === 0) continue; + serializedPath = serializedPath.replaceAll(`{${name}}`, serialized); + } + return joinUrlParts(baseUrl, serializedPath.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, "$1:$2")); +} + +function shouldInlinePathParameter( + parameter: UnknownRecord, + importState: ImportState, + path: string, +): boolean { + if (stringAt(parameter, "in") !== "path") return false; + const name = stringAt(parameter, "name") ?? ""; + const template = `{${name}}`; + const matchingSegments = path.split("/").filter((segment) => segment.includes(template)); + // A `:name` placeholder matches from the segment start up to a literal `:`, + // so `{id}` and `{id}:cancel` stay placeholders while `report.{format}` can't + const placeholderExpressible = matchingSegments.every( + (segment) => + segment === template || + (segment.startsWith(template) && segment[template.length] === ":"), + ); + if (matchingSegments.length === 0 || !placeholderExpressible) return true; + if (isRecord(parameter.content)) return false; + const value = parameterExampleValue(parameter, importState); + const style = stringAt(parameter, "style"); + return style === "label" || style === "matrix" || Array.isArray(value) || isRecord(value); +} + +function encodePathComponent(value: unknown): string { + return encodeURIComponent(stringifyExampleValue(value)).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); } function importBaseUrl(spec: UnknownRecord): string { @@ -809,47 +843,84 @@ function trimTrailingSlashes(value: string): string { function importUrlParameters({ importState, parameters, - placeholderNames, + path, }: { importState: ImportState; parameters: unknown[]; - placeholderNames: Set; + path: string; }): HttpUrlParameter[] { return parameters .map((p) => importState.resolve(p)) .filter(isRecord) - .filter( - (p) => - stringAt(p, "in") === "query" || - (stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")), - ) - .flatMap((p) => { - const name = stringAt(p, "name") ?? ""; - if (name.length === 0) return []; + .filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path") + .flatMap((p) => serializeUrlParameter(p, importState, path)) + .filter(({ name }) => name.length > 0); +} - // 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` - const enabled = p.required === true || stringAt(p, "in") === "path"; - if (stringAt(p, "in") === "query") { - const raw = rawParameterExample(p, importState); - if (Array.isArray(raw)) { - const { separator } = queryArraySerialization(p); - if (separator == null) { - return raw.map((item) => ({ enabled, name, value: stringifyExampleValue(item) })); - } - return [{ enabled, name, value: raw.map(stringifyExampleValue).join(separator) }]; - } - } +function serializeUrlParameter( + parameter: UnknownRecord, + importState: ImportState, + path: string, +): HttpUrlParameter[] { + const name = stringAt(parameter, "name") ?? ""; + const location = stringAt(parameter, "in"); + // 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` + const enabled = parameter.required === true || location === "path"; + const value = parameterExampleValue(parameter, importState); + if (isRecord(parameter.content)) { + return [ + { + enabled, + name: location === "path" ? `:${name}` : name, + value: serializeContentParameter(parameter, importState), + }, + ]; + } + if (location === "path") { + if (shouldInlinePathParameter(parameter, importState, path)) return []; + const serialized = serializePathParameter(name, value, parameter); + // An empty path segment makes a URL that matches nothing, so the name at + // least keeps the request sendable and shows what belongs there + return [{ enabled, name: `:${name}`, value: serialized.length > 0 ? serialized : name }]; + } - return [ - { - enabled, - name: stringAt(p, "in") === "path" ? `:${name}` : name, - value: parameterExample(p, importState), - }, - ]; - }); + if (isRecord(value)) { + const entries = Object.entries(value); + const style = stringAt(parameter, "style") ?? "form"; + const explode = parameter.explode !== false; + if (style === "deepObject") { + return entries.map(([key, entryValue]) => ({ + enabled, + name: `${name}[${key}]`, + value: stringifyExampleValue(entryValue), + })); + } + if (style === "form" && explode) { + return entries.map(([key, entryValue]) => ({ + enabled, + name: key, + value: stringifyExampleValue(entryValue), + })); + } + const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; + return [{ enabled, name, value: entries.flat().map(stringifyExampleValue).join(separator) }]; + } + + if (Array.isArray(value)) { + const { separator } = queryArraySerialization(parameter); + if (separator == null) { + return value.map((entryValue) => ({ + enabled, + name, + value: stringifyExampleValue(entryValue), + })); + } + return [{ enabled, name, value: value.map(stringifyExampleValue).join(separator) }]; + } + + return [{ enabled, name, value: stringifyExampleValue(value) }]; } /** @@ -891,12 +962,16 @@ function importHeaderParameters({ .map((p) => ({ enabled: p.required === true, name: stringAt(p, "name") ?? "", - value: parameterExample(p, importState), + value: serializeParameterValue(p, importState), })) .filter(({ name }) => name.length > 0); } -/** Yaak has no cookie parameter row, so cookie parameters become the header they would produce */ +/** + * Yaak has no cookie parameter row, so each cookie parameter becomes its own + * Cookie header. Rows stay individually toggleable and the send path merges + * the enabled ones into a single header. + */ function importCookieHeader({ importState, parameters, @@ -904,47 +979,131 @@ function importCookieHeader({ importState: ImportState; parameters: unknown[]; }): HttpRequestHeader[] { - const cookieParameters = parameters + return 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), + .map((p) => ({ + enabled: p.required === true, name: "Cookie", - value: cookieParameters - .map((p) => `${stringAt(p, "name")}=${parameterExample(p, importState)}`) - .join("; "), - }, - ]; + value: serializeCookieParameter(p, importState), + })) + .filter(({ value }) => value.length > 0); } -function rawParameterExample(parameter: UnknownRecord, importState: ImportState): unknown { +function serializeCookieParameter(parameter: UnknownRecord, importState: ImportState): string { + const name = stringAt(parameter, "name") ?? ""; + if (name.length === 0) return ""; + if (isRecord(parameter.content)) { + return `${name}=${serializeContentParameter(parameter, importState)}`; + } + + const value = parameterExampleValue(parameter, importState); + const explode = parameter.explode !== false; + // Exploded pairs are cookie pairs, which RFC 6265 separates with "; " + if (Array.isArray(value)) { + return explode + ? value.map((entryValue) => `${name}=${stringifyExampleValue(entryValue)}`).join("; ") + : `${name}=${value.map(stringifyExampleValue).join(",")}`; + } + if (isRecord(value)) { + const entries = Object.entries(value); + return explode + ? entries + .map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`) + .join("; ") + : `${name}=${entries.flat().map(stringifyExampleValue).join(",")}`; + } + return `${name}=${stringifyExampleValue(value)}`; +} + +function serializeParameterValue(parameter: UnknownRecord, importState: ImportState): string { + if (isRecord(parameter.content)) return serializeContentParameter(parameter, importState); + return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter); +} + +/** A parameter described by a media type serializes as that media type */ +function serializeContentParameter(parameter: UnknownRecord, importState: ImportState): string { + const [contentType, rawMediaType] = Object.entries(toRecord(parameter.content))[0] ?? []; + const value = mediaTypeExample(toRecord(rawMediaType), importState); + return contentType?.toLowerCase().includes("json") + ? (JSON.stringify(value) ?? "") + : stringifyExampleValue(value); +} + +function serializePathParameter( + name: string, + value: unknown, + parameter: UnknownRecord, + serializeValue: (value: unknown) => string = stringifyExampleValue, +): string { + const style = stringAt(parameter, "style") ?? "simple"; + const explode = parameter.explode === true; + const values = Array.isArray(value) + ? value.map(serializeValue) + : isRecord(value) + ? Object.entries(value).flatMap(([key, entryValue]) => [ + serializeValue(key), + serializeValue(entryValue), + ]) + : [serializeValue(value)]; + + if (style === "label") { + if (explode && isRecord(value)) { + return `.${Object.entries(value) + .map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(".")}`; + } + return `.${values.join(explode ? "." : ",")}`; + } + if (style === "matrix") { + if (explode && Array.isArray(value)) { + return value.map((entryValue) => `;${name}=${serializeValue(entryValue)}`).join(""); + } + if (explode && isRecord(value)) { + return Object.entries(value) + .map(([key, entryValue]) => `;${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(""); + } + return `;${name}=${values.join(",")}`; + } + return serializeSimpleParameter(value, parameter, serializeValue); +} + +function serializeSimpleParameter( + value: unknown, + parameter: UnknownRecord, + serializeValue: (value: unknown) => string = stringifyExampleValue, +): string { + if (Array.isArray(value)) return value.map(serializeValue).join(","); + if (isRecord(value)) { + const entries = Object.entries(value); + return parameter.explode === true + ? entries + .map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(",") + : entries.flat().map(serializeValue).join(","); + } + return serializeValue(value); +} + +function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown { 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); + } // Swagger 2 parameters carry the schema keywords (type, items, default) // directly on the parameter object - return schemaToExample(importState.resolve(parameter.schema ?? parameter), importState); + return schemaToExample(parameter.schema ?? parameter, importState); } function parameterExample(parameter: UnknownRecord, importState: ImportState): string { - const raw = rawParameterExample(parameter, importState); - // Simple/csv style, the default everywhere but query strings - const example = Array.isArray(raw) - ? raw.map(stringifyExampleValue).join(",") - : stringifyExampleValue(raw); - // 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; + return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter); } function importBody({ @@ -975,14 +1134,21 @@ function importBody({ toArray(operation.consumes ?? spec.consumes).find( (c): c is string => typeof c === "string", ) ?? "application/json"; + const schema = importState.resolveSchema(bodyParameter.schema); + const isBinary = stringAt(schema, "format") === "binary"; return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], - bodyType: yaakBodyType(contentType), - body: { - text: formatBodyText( - schemaToExample(importState.resolve(bodyParameter.schema), importState), - ), - }, + bodyType: isBinary ? "binary" : yaakBodyType(contentType), + body: isBinary + ? {} + : { + text: formatMediaTypeBody( + contentType, + schemaToExample(schema, importState), + schema, + importState, + ), + }, }; } @@ -1000,11 +1166,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) }; + }), }, }; } @@ -1020,33 +1190,196 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) const bodyType = yaakBodyType(contentType); if (bodyType === "application/x-www-form-urlencoded" || bodyType === "multipart/form-data") { + const example = mediaTypeExample(mediaType, importState); return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], bodyType, body: { - form: schemaToFormParameters(importState.resolve(mediaType.schema), importState), + form: schemaToFormParameters( + mediaType.schema, + importState, + isRecord(example) ? example : undefined, + ), }, }; } + const schema = importState.resolveSchema(mediaType.schema); + const isBinary = bodyType === "binary" || stringAt(schema, "format") === "binary"; + return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], - bodyType, - body: - bodyType === "binary" - ? {} - : { text: formatBodyText(mediaTypeExample(mediaType, importState)) }, + bodyType: isBinary ? "binary" : bodyType, + body: isBinary + ? {} + : { + text: formatMediaTypeBody( + contentType, + mediaTypeExample(mediaType, importState), + schema, + importState, + ), + }, }; } function chooseContentType(contentTypes: string[]): string | null { + const jsonType = contentTypes.find((contentType) => mediaTypeOf(contentType).endsWith("+json")); for (const preference of BODY_CONTENT_TYPE_PREFERENCE) { const exact = contentTypes.find((c) => mediaTypeOf(c) === preference); if (exact != null) return exact; + // A +json suffix type ranks with JSON, ahead of the other preferences + if (preference === "application/json" && jsonType != null) return jsonType; } return contentTypes[0] ?? null; } +function formatMediaTypeBody( + contentType: string, + example: unknown, + schema: unknown, + importState: ImportState, +): string { + const mediaType = mediaTypeOf(contentType); + if (mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml")) { + return typeof example === "string" + ? example + : valueToXml(example, schema, importState, "root", true); + } + if (mediaType === "application/json" || mediaType.endsWith("+json")) { + // A string example may be pre-serialized JSON; otherwise it needs quoting + // to be a valid JSON document + if (typeof example === "string") { + try { + JSON.parse(example); + return example; + } catch { + return JSON.stringify(example); + } + } + return JSON.stringify(example, null, 2) ?? ""; + } + return formatBodyText(example); +} + +function valueToXml( + value: unknown, + schema: unknown, + importState: ImportState, + elementName: string, + isDocumentRoot = false, +): string { + const resolvedSchema = toRecord(importState.resolveSchema(schema)); + const schemaXml = toRecord(resolvedSchema.xml); + if (Array.isArray(value)) { + const itemSchema = importState.resolveSchema(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 entries = Object.entries(value).map(([name, propertyValue]) => { + const propertySchema = toRecord(importState.resolveSchema(properties[name])); + return { name, propertyValue, propertySchema, xml: toRecord(propertySchema.xml) }; + }); + const usedPrefixes = new Set(["xml", "xmlns"]); + const prefixesByNamespace = new Map(); + for (const xml of [schemaXml, ...entries.map(({ xml }) => xml)]) { + const namespace = stringAt(xml, "namespace"); + const prefix = stringAt(xml, "prefix"); + if (prefix == null || prefix.length === 0) continue; + usedPrefixes.add(prefix); + if (namespace != null && namespace.length > 0 && !prefixesByNamespace.has(namespace)) { + prefixesByNamespace.set(namespace, prefix); + } + } + const attributes: string[] = []; + const attributeNamespaces: UnknownRecord[] = []; + const children: string[] = []; + for (const { name, propertyValue, propertySchema, xml } of entries) { + if (xml.attribute === true) { + const attributeXml = qualifyXmlAttribute(xml, usedPrefixes, prefixesByNamespace); + attributes.push( + `${qualifiedXmlName(name, attributeXml)}="${escapeXml(stringifyExampleValue(propertyValue))}"`, + ); + attributeNamespaces.push(attributeXml); + } 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(); + for (const metadata of [xml, ...additionalNamespaces]) { + const namespace = stringAt(metadata, "namespace"); + if (namespace == null || namespace.length === 0) continue; + const prefix = stringAt(metadata, "prefix"); + namespaces.set(prefix == null || prefix.length === 0 ? "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}`; +} + +function qualifyXmlAttribute( + xml: UnknownRecord, + usedPrefixes: Set, + prefixesByNamespace: Map, +): UnknownRecord { + const namespace = stringAt(xml, "namespace"); + const declaredPrefix = stringAt(xml, "prefix"); + if (namespace != null && namespace.length === 0) { + const { prefix: _prefix, ...unqualifiedXml } = xml; + return unqualifiedXml; + } + if (namespace == null || (declaredPrefix != null && declaredPrefix.length > 0)) return xml; + + // Namespaced attributes need a prefix to be well-formed, so reuse the + // namespace's existing prefix or mint one + const existingPrefix = prefixesByNamespace.get(namespace); + if (existingPrefix != null) return { ...xml, prefix: existingPrefix }; + + let suffix = 1; + while (usedPrefixes.has(`ns${suffix}`)) suffix++; + const generatedPrefix = `ns${suffix}`; + usedPrefixes.add(generatedPrefix); + prefixesByNamespace.set(namespace, generatedPrefix); + return { ...xml, prefix: generatedPrefix }; +} + +function qualifiedXmlName(fallbackName: string, xml: UnknownRecord): string { + const name = stringAt(xml, "name") ?? fallbackName; + const prefix = stringAt(xml, "prefix"); + return prefix == null || prefix.length === 0 ? name : `${prefix}:${name}`; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + function mediaTypeOf(contentType: string): string { return contentType.toLowerCase().split(";")[0]?.trim() ?? ""; } @@ -1075,25 +1408,21 @@ function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): u firstExampleValue(mediaType.examples, importState), ); if (directExample != null) return directExample; - return schemaToExample(importState.resolve(mediaType.schema), importState); + return schemaToExample(mediaType.schema, importState); } -function schemaToFormParameters(schema: unknown, importState: ImportState) { - const resolvedSchema = toRecord(importState.resolve(schema)); - 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); +function schemaToFormParameters(schema: unknown, importState: ImportState, example?: UnknownRecord) { + 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.resolveSchema(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 resolvedProperty = toRecord(importState.resolveSchema(property)); + const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState); const base = { enabled: required.includes(name), name, @@ -1101,7 +1430,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) }; }); } @@ -1113,12 +1442,20 @@ function schemaToExample( ): unknown { 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 (closesReferenceCycle && Object.keys(resolved).length === 0) return {}; const explicitExample = firstPresent( resolved.example, firstExampleValue(resolved.examples, importState), + resolved.const, resolved.default, ); if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved); @@ -1126,34 +1463,78 @@ 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) { - const merged = allOf.reduce((merged, childSchema) => { - const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs); - return isRecord(childExample) ? { ...merged, ...childExample } : merged; + const compositionExample = allOf.reduce((merged, childSchema) => { + const childExample = schemaToExample(childSchema, importState, depth + 1, nextVisitedRefs); + return isRecord(childExample) ? mergeExampleRecords(merged, childExample) : merged; }, {}); - // Sibling properties are their own constraint alongside the allOf branches - return { ...merged, ...objectPropertiesExample(resolved, importState, depth, visitedRefs) }; + 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") { - return objectPropertiesExample(resolved, importState, depth, 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; return FORMAT_EXAMPLES[stringAt(resolved, "format") ?? ""] ?? ""; } +/** Request examples omit readOnly properties, which only appear in responses */ +function schemaPropertiesToExample( + 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)) + .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; +} + const FORMAT_EXAMPLES: Record = { "date-time": "2026-01-01T00:00:00Z", date: "2026-01-01", @@ -1194,30 +1575,6 @@ function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown 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 { const rawType = schema.type; @@ -1618,12 +1975,16 @@ function buildOAuthVariablesByScheme( ); } +/** Earlier groups win on a name collision; Cookie rows all pass through since the send path merges them */ function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] { const headers: HttpRequestHeader[] = []; - for (const header of headerGroups.flat()) { - const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase()); - if (existing == null) { - headers.push(header); + for (const group of headerGroups) { + const namesFromEarlierGroups = new Set(headers.map((header) => header.name.toLowerCase())); + for (const header of group) { + const name = header.name.toLowerCase(); + if (name === "cookie" || !namesFromEarlierGroups.has(name)) { + headers.push(header); + } } } return headers; @@ -1733,7 +2094,120 @@ class ImportState { return value; } - const resolved = value.$ref + return this.resolve(this.#resolveLocalReference(value.$ref), nextVisitedRefs); + } + + /** Schema Objects allow `$ref` siblings in OpenAPI 3.1 and later. */ + resolveSchema(value: unknown, visitedRefs = new Set(), 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; + + let merged: UnknownRecord = resolved; + for (let index = siblingLayers.length - 1; index >= 0; index--) { + merged = this.#mergeSchemaObjects(merged, siblingLayers[index] ?? {}); + } + return this.#mergeAllOfStructure(merged, structureVisitedRefs, compositionDepth); + } + + #mergeAllOfStructure( + schema: UnknownRecord, + visitedRefs: Set, + 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((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) .split("/") .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) @@ -1742,7 +2216,5 @@ class ImportState { Array.isArray(current) ? current[Number(part)] : toRecord(current)[part], this.#spec, ); - - return this.resolve(resolved, nextVisitedRefs); } } diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index a6399036..1aca27f9 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -1219,7 +1219,8 @@ describe("importer-openapi", () => { ); expect(imported?.resources.httpRequests[0]?.description).toContain("{{placeholders}}"); - expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: "Hi {{name}}" }); + // Quoted to be a valid JSON document, braces intact + expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: '"Hi {{name}}"' }); }); test("Ignores header parameters the spec reserves for other mechanisms", async () => { @@ -1269,8 +1270,10 @@ describe("importer-openapi", () => { }), ); + // One row per cookie so each stays toggleable; the send path merges them expect(imported?.resources.httpRequests[0]?.headers).toEqual([ - { enabled: true, name: "Cookie", value: "session=abc; theme=dark" }, + { enabled: true, name: "Cookie", value: "session=abc" }, + { enabled: false, name: "Cookie", value: "theme=dark" }, ]); }); @@ -1456,6 +1459,864 @@ 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: + '' + + "basesibling" + + "", + }, + { + 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 = { + [`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 cookie and content-based parameters", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Parameter Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + parameters: [ + { + name: "session", + in: "cookie", + required: true, + schema: { type: "string", example: "abc" }, + }, + { + name: "debug", + in: "cookie", + schema: { type: "string", example: "verbose" }, + }, + { + name: "prefs", + in: "cookie", + required: true, + schema: { type: "object", example: { theme: "dark", lang: "en" } }, + }, + { + name: "colors", + in: "cookie", + required: true, + explode: false, + schema: { type: "array", example: ["red", "blue"] }, + }, + { + name: "X-Filter", + in: "header", + required: true, + content: { "text/plain": { example: "active" } }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.headers).toEqual([ + { enabled: true, name: "X-Filter", value: "active" }, + { enabled: true, name: "Cookie", value: "session=abc" }, + { enabled: false, name: "Cookie", value: "debug=verbose" }, + // Cookie pairs separate with "; ", never "&" + { enabled: true, name: "Cookie", value: "theme=dark; lang=en" }, + { enabled: true, name: "Cookie", value: "colors=red,blue" }, + ]); + }); + + test("Preserves parameter cookies alongside cookie API-key authentication", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Authenticated Cookie Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + security: [{ basicAuth: [], cookieKey: [] }], + parameters: [ + { + name: "session", + in: "cookie", + required: true, + schema: { type: "string", example: "abc" }, + }, + { + name: "debug", + in: "cookie", + schema: { type: "string", example: "verbose" }, + }, + ], + responses: {}, + }, + }, + }, + components: { + securitySchemes: { + basicAuth: { type: "http", scheme: "basic" }, + cookieKey: { type: "apiKey", in: "cookie", name: "api_key" }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]).toEqual( + expect.objectContaining({ + authenticationType: "basic", + headers: [ + { enabled: true, name: "Cookie", value: "api_key=${[auth_cookie_key_key]}" }, + { enabled: true, name: "Cookie", value: "session=abc" }, + { enabled: false, name: "Cookie", value: "debug=verbose" }, + ], + }), + ); + }); + + test("Serializes structured query parameters according to style and explode", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Serialization Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + parameters: [ + { + name: "filter", + in: "query", + required: true, + style: "deepObject", + explode: true, + schema: { + type: "object", + properties: { + role: { example: "admin" }, + active: { example: true }, + }, + }, + }, + { + name: "tags", + in: "query", + style: "form", + explode: true, + schema: { type: "array", example: ["one", "two"] }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([ + { enabled: true, name: "filter[role]", value: "admin" }, + { enabled: true, name: "filter[active]", value: "true" }, + { enabled: false, name: "tags", value: "one" }, + { enabled: false, name: "tags", value: "two" }, + ]); + }); + + test("Emits executable label and matrix path serializations", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Path Serialization Test", version: "1.0.0" }, + paths: { + "/labels/{labels}/matrix/{coordinates}/scalar/{color}/report.{format}": { + get: { + parameters: [ + { + name: "labels", + in: "path", + required: true, + style: "label", + explode: true, + schema: { type: "array", example: ["one/two", "three"] }, + }, + { + name: "coordinates", + in: "path", + required: true, + style: "matrix", + explode: true, + schema: { type: "object", example: { x: "1;spoof=2", y: 2 } }, + }, + { + name: "format", + in: "path", + required: true, + schema: { type: "string", example: "json/evil" }, + }, + { + name: "color", + in: "path", + required: true, + style: "label", + schema: { type: "string", example: "blue" }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]).toEqual( + expect.objectContaining({ + url: "${[baseUrl]}/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2/scalar/.blue/report.json%2Fevil", + urlParameters: [], + }), + ); + }); + + 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: {}, + }, + }, + "/json-preserialized": { + post: { + requestBody: { + content: { + "application/json": { + schema: { type: "string", example: '{"already": "json"}' }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: "Ada", + }); + expect(imported?.resources.httpRequests[0]?.bodyType).toBe("text/xml"); + expect(imported?.resources.httpRequests[1]?.body).toEqual({ text: '"hello"' }); + expect(imported?.resources.httpRequests[2]?.body).toEqual({ text: '{"already": "json"}' }); + }); + + 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" }, + }, + externalId: { + type: "string", + example: "external", + xml: { + attribute: true, + name: "external-id", + namespace: "urn:external", + prefix: "ns1", + }, + }, + tenant: { + type: "string", + example: "acme", + xml: { attribute: true, namespace: "urn:tenant" }, + }, + region: { + type: "string", + example: "west", + xml: { attribute: true, namespace: "urn:tenant" }, + }, + legacy: { + type: "string", + example: "plain", + xml: { attribute: true, namespace: "", prefix: "unbound" }, + }, + 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: + '' + + 'onetwo' + + 'Ada' + + 'A' + + "", + }); + expect(imported?.resources.httpRequests[1]?.body).toEqual({ + text: "onetwo", + }); + }); + + 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", + }); + expect(imported?.resources.httpRequests[0]?.bodyType).toBe("text/xml"); + }); + test("Reports references that point outside the document", async () => { const imported = await convertOpenApi( JSON.stringify({