From c4f96f3f11440409cf028221ac05438e21c417c9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 12:45:32 -0700 Subject: [PATCH] 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]}" }),