Compare commits

..
Author SHA1 Message Date
Gregory Schier 12755ff0a8 fix(openapi): import server environments 2026-08-19 07:33:54 -07:00
3 changed files with 162 additions and 6 deletions
+68 -4
View File
@@ -20,6 +20,7 @@ type ImportResources = {
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
};
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<ImportPluginResp
folders: [],
httpRequests: [],
};
const serverOverrides = new Map<string, ServerOverrideVariable>();
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<ImportPluginResp
pathItem,
pathParameters,
requestBaseUrl,
serverOverrides,
spec,
workspaceId: workspace.id,
folderId,
@@ -129,6 +132,36 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
}
}
if (serverOverrides.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(...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<string, ServerOverrideVariable>;
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, ServerOverrideVariable>;
}): 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<string, number>();
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))) {
@@ -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": [
{
+64 -2
View File
@@ -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" },
],
}),
]);
});