From 2350689d7a7583a0d87d213ffbefc6c8ff8da6f8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 07:39:18 -0700 Subject: [PATCH] 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" }, ]); });