diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index c6ad51c0..fb676bb1 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -20,6 +20,7 @@ type ImportResources = { folders: AtLeast[]; httpRequests: AtLeast[]; }; +type ServerOverrideVariable = { name: string; value: string }; const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"]; const BODY_CONTENT_TYPE_PREFERENCE = [ @@ -62,6 +63,7 @@ export async function convertOpenApi(contents: string): Promise(); const baseUrl = importBaseUrl(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 @@ -120,6 +122,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(...serverOverrides.values()); + } + resources.environments.push( + ...importServerEnvironments(spec).map(({ name, url }) => ({ + model: "environment" as const, + id: importState.generateId("environment"), + workspaceId: workspace.id, + name, + variables: [{ name: "baseUrl", value: url }], + parentModel: "environment", + parentId: null, + sortPriority: importState.nextSortPriority(), + })), + ); + if (resources.httpRequests.length === 0) return undefined; disambiguateNames(resources.httpRequests, routeLabels); @@ -197,6 +230,7 @@ function importOperation({ pathItem, pathParameters, requestBaseUrl, + serverOverrides, spec, workspaceId, folderId, @@ -208,6 +242,7 @@ function importOperation({ pathItem: UnknownRecord; pathParameters: unknown[]; requestBaseUrl: string; + serverOverrides: Map; spec: UnknownRecord; workspaceId: string; folderId: string | null; @@ -243,7 +278,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, @@ -298,18 +336,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; } @@ -562,6 +608,24 @@ 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 < 2) return []; + + 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 interpolateServerUrl(server: UnknownRecord): string { let url = stringAt(server, "url") ?? ""; for (const [name, variable] of Object.entries(toRecord(server.variables))) { diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index 10458055..fadc316a 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -2620,6 +2620,36 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = ` ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, + { + "id": "GENERATE_ID::ENVIRONMENT_1", + "model": "environment", + "name": "Server 1", + "parentId": null, + "parentModel": "environment", + "sortPriority": 3, + "variables": [ + { + "name": "baseUrl", + "value": "https://api.nasa.gov/planetary", + }, + ], + "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", + }, + ], + "workspaceId": "GENERATE_ID::WORKSPACE_0", + }, ], "folders": [ { diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index da2c688c..2626c7ba 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -301,8 +301,70 @@ 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[0]?.variables).toEqual([ + { name: "baseUrl", value: "https://root.example.com" }, + { name: "serverUrl", value: "https://path.example.com" }, + { name: "serverUrl2", value: "https://operation.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: { "/items": { get: { responses: {} } } }, + }), + ); + + expect(imported?.resources.environments).toEqual([ + expect.objectContaining({ + name: "Global Variables", + variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }], + }), + expect.objectContaining({ + name: "Production", + parentModel: "environment", + variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }], + }), + expect.objectContaining({ + name: "Sandbox", + parentModel: "environment", + variables: [{ name: "baseUrl", value: "https://sandbox.example.com/v1" }], + }), + ]); + }); + + 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: "baseUrl", value: "" }, + { name: "serverUrl", value: "https://path.example.com" }, + ], + }), ]); });