feat: enhance API spec export with environment variables support (#7170)

* feat: enhance API spec export with environment variables support

- Updated `exportApiSpec` function to accept and process environment variables for multi-server exports.
- Added logic to convert environment variables into a structured format for OpenAPI server entries.
- Enhanced the `CreateApiSpec` component to include environments in the exported YAML content.
- Introduced unit tests to validate the handling of server variables and their integration into the exported API specifications.

* refactor: streamline API spec export logic and improve variable handling

- Simplified variable extraction in `exportApiSpec` by directly assigning capture groups.
- Updated URL interpolation to use request variables instead of global variables for better accuracy.
- Enhanced handling of request body types by replacing early returns with breaks for clearer flow control.
- Adjusted tests to ensure backward compatibility with OpenAPI specifications and server variable handling.

* refactor: improve variable handling and URL processing in OpenAPI exporters

- Streamlined server variable assignment in `exportApiSpec` to handle undefined values more gracefully.
- Enhanced URL path extraction to ensure leading slashes are preserved in `getDefaultUrl` and `extractServerVars`.
- Updated string replacement logic to use `replaceAll` for consistent variable substitution in URLs.
This commit is contained in:
Abhishek S Lal
2026-03-03 21:24:01 +05:30
committed by GitHub
parent 574324e784
commit e0dd79418b
6 changed files with 1742 additions and 48 deletions

View File

@@ -85,8 +85,13 @@ const CreateApiSpec = ({ onClose }) => {
...variables
};
}
// Convert envVariables (keyed by filename) to environments array for multi-server export
const environmentsList = Object.entries(envVariables || {}).map(([envFile, vars]) => ({
name: envFile.replace(/\.(bru|yml)$/, ''),
variables: vars
}));
// Create API spec yaml
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files, environments: environmentsList });
if (exportedYamlContentData?.content) {
yamlContent = exportedYamlContentData?.content;
}

View File

@@ -3,7 +3,7 @@ import { interpolate } from '@usebruno/common';
import { isValidUrl } from 'utils/url/index';
const xml2js = require('xml2js');
export const exportApiSpec = ({ variables, items, name }) => {
export const exportApiSpec = ({ variables, items, name, environments }) => {
items = items.filter((item) => !['grpc-request'].includes(item.type));
const components = {
@@ -22,12 +22,60 @@ export const exportApiSpec = ({ variables, items, name }) => {
});
};
const addUrlToServersList = (url) => {
if (!servers?.find((s) => s?.url === url)) {
servers.push({ url });
const templateVarRegex = /\{\{([^}]+)\}\}/g;
// Build an OpenAPI server entry from a URL (supports {{var}} templates)
const buildServerEntry = (url, vars, description) => {
const cleanedUrl = url.endsWith('/') ? url.slice(0, -1) : url;
const matches = [...cleanedUrl.matchAll(templateVarRegex)];
const entry = {};
if (matches.length > 0) {
entry.url = cleanedUrl.replace(templateVarRegex, '{$1}');
const serverVariables = {};
// each match m is an array where m[0] is the full match (e.g. {{protocol}}) and m[1] is the capture group (e.g. protocol).
matches.forEach((m) => {
const varName = m[1];
serverVariables[varName] = { default: vars[varName] !== undefined ? String(vars[varName]) : '' };
});
if (Object.keys(serverVariables).length > 0) {
entry.variables = serverVariables;
}
} else {
entry.url = cleanedUrl;
}
if (description) entry.description = description;
return entry;
};
// Collect all baseUrl sources: collection variables + each environment
// Each source becomes a self-contained server entry in the OpenAPI spec.
// On import, each server entry maps to a separate Bruno environment.
const baseUrlSources = [];
// Add collection-level baseUrl if present
const collectionBaseUrl = variables?.baseUrl || '';
if (collectionBaseUrl) {
baseUrlSources.push({ baseUrl: collectionBaseUrl, description: 'Base Server', vars: variables });
}
// Add each environment that defines its own baseUrl
if (environments && environments.length > 0) {
for (const env of environments) {
const envVars = getEnabledVarsAsObject(env.variables);
if (envVars.baseUrl) {
baseUrlSources.push({ baseUrl: envVars.baseUrl, description: env.name, vars: envVars });
}
}
}
// Build root server entries
for (const source of baseUrlSources) {
servers.push(buildServerEntry(source.baseUrl, source.vars, source.description));
}
const extractTagFromDepth = (item) => {
const { pathname, depth } = item;
if (!pathname) return;
@@ -41,14 +89,49 @@ export const exportApiSpec = ({ variables, items, name }) => {
return parts[tagIndex];
};
// Resolve a raw request URL to a path and optional operation-level server override.
// Checks for request-level baseUrl overrides (vars.req), then {{baseUrl}} placeholder,
// then known baseUrl sources. Falls back to full resolution for unknown URLs.
const resolveRequestUrl = (rawUrl, requestVars) => {
// Request has a baseUrl override in vars.req — export as operation-level server
if (rawUrl.startsWith('{{baseUrl}}') && requestVars) {
const baseUrlOverride = requestVars.find((v) => v.name === 'baseUrl' && v.enabled);
if (baseUrlOverride) {
const reqVarsMap = {};
requestVars.filter((v) => v.enabled).forEach((v) => { reqVarsMap[v.name] = v.value; });
const path = rawUrl.slice('{{baseUrl}}'.length) || '/';
return { url: interpolate(path, reqVarsMap), operationLevelServer: buildServerEntry(baseUrlOverride.value, reqVarsMap) };
}
}
// URL uses {{baseUrl}} placeholder — strip it and resolve remaining path
if (rawUrl.startsWith('{{baseUrl}}')) {
const path = rawUrl.slice('{{baseUrl}}'.length) || '/';
return { url: interpolate(path, {}), operationLevelServer: null };
}
// URL matches a known baseUrl value directly (e.g. user typed template vars inline)
for (const source of baseUrlSources) {
if (rawUrl.startsWith(source.baseUrl)) {
const rawPath = rawUrl.slice(source.baseUrl.length);
const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
return { url: interpolate(path, {}), operationLevelServer: null };
}
}
// Unknown URL — resolve fully and add operation-level server override
const resolvedUrl = interpolate(rawUrl, variables);
if (isValidUrl(resolvedUrl)) {
const urlDetails = new URL(resolvedUrl);
return { url: urlDetails.pathname, operationLevelServer: buildServerEntry(urlDetails.origin, variables) };
}
return { url: rawUrl, operationLevelServer: null };
};
const generatePaths = () => {
const _items = items.map((item) => {
let url = interpolate(item?.request?.url, variables);
if (isValidUrl(url)) {
let urlDetails = new URL(url);
urlDetails?.pathname && (url = urlDetails?.pathname);
urlDetails?.origin && addUrlToServersList(urlDetails?.origin);
}
const { url, operationLevelServer } = resolveRequestUrl(item?.request?.url || '', item?.request?.vars?.req);
const { request } = item;
const { method, params = [], headers = [], body, auth } = request || {};
@@ -58,8 +141,14 @@ export const exportApiSpec = ({ variables, items, name }) => {
const pathMatches = url.match(pathParamsRegex) || [];
// Build known path param names from the params array
const knownPathParamNames = new Set(
params?.filter((p) => p?.type === 'path').map((p) => p?.name) || []
);
const parameters = [
...params?.map((param) => ({
// Query params (exclude path-type params to avoid duplication)
...params?.filter((p) => p?.type !== 'path').map((param) => ({
name: param?.name,
in: 'query',
description: '',
@@ -73,11 +162,22 @@ export const exportApiSpec = ({ variables, items, name }) => {
required: header?.enabled,
example: header?.value
})),
...pathMatches?.map((path) => ({
name: path.slice(1, path.length - 1),
// Path params from the params array (have values from Bruno)
...params?.filter((p) => p?.type === 'path').map((param) => ({
name: param?.name,
in: 'path',
required: true
}))
required: true,
example: param?.value
})),
// Path params from URL regex that aren't already in the params array
...pathMatches
?.map((path) => path.slice(1, path.length - 1))
.filter((name) => !knownPathParamNames.has(name))
.map((name) => ({
name,
in: 'path',
required: true
}))
];
const pathBody = {
@@ -107,7 +207,9 @@ export const exportApiSpec = ({ variables, items, name }) => {
if (!body?.json) break;
try {
const parsedJson = JSON.parse(body.json);
components.schemas[schemaId] = generateProperyShape(parsedJson);
const schema = generateProperyShape(parsedJson);
schema.example = parsedJson;
components.schemas[schemaId] = schema;
components.requestBodies[requestBodyId] = {
content: {
'application/json': {
@@ -152,7 +254,9 @@ export const exportApiSpec = ({ variables, items, name }) => {
addWarning('Failed to parse XML in request body', item?.name);
break;
}
components.schemas[schemaId] = generateProperyShape(jsonResult);
const xmlSchema = generateProperyShape(jsonResult);
xmlSchema.example = jsonResult;
components.schemas[schemaId] = xmlSchema;
components.requestBodies[requestBodyId] = {
content: {
'application/xml': {
@@ -172,7 +276,7 @@ export const exportApiSpec = ({ variables, items, name }) => {
}
break;
case 'multipartForm':
if (!body?.multipartForm) return;
if (!body?.multipartForm) break;
let multipartFormToKeyValue = body?.multipartForm.reduce((acc, f) => {
acc[f?.name] = f.value;
return acc;
@@ -192,8 +296,9 @@ export const exportApiSpec = ({ variables, items, name }) => {
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${requestBodyId}`
};
break;
case 'formUrlEncoded':
if (!body?.formUrlEncoded) return;
if (!body?.formUrlEncoded) break;
let formUrlEncodedToKeyValue = body?.formUrlEncoded.reduce((acc, f) => {
acc[f?.name] = f.value;
return acc;
@@ -213,8 +318,9 @@ export const exportApiSpec = ({ variables, items, name }) => {
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${requestBodyId}`
};
break;
case 'text':
if (!body?.text) return;
if (!body?.text) break;
pathBody['requestBody'] = {
content: {
'text/plain': {
@@ -224,6 +330,7 @@ export const exportApiSpec = ({ variables, items, name }) => {
}
}
};
break;
default:
break;
}
@@ -347,7 +454,8 @@ export const exportApiSpec = ({ variables, items, name }) => {
return {
url,
method: method.toLowerCase(),
data: pathBody
data: pathBody,
operationLevelServer
};
});
@@ -356,6 +464,11 @@ export const exportApiSpec = ({ variables, items, name }) => {
acc[item?.url] = {};
}
acc[item?.url][item?.method] = item?.data;
// Add operation-level server override inside the operation object (not path-item level)
// so the import can read it back from operationObject.servers
if (item?.operationLevelServer) {
acc[item?.url][item?.method].servers = [item.operationLevelServer];
}
return acc;
}, {});
};
@@ -397,6 +510,17 @@ const generateInfoSection = (name) => {
};
};
// Convert env variable array to { name: value } object (only enabled vars)
const getEnabledVarsAsObject = (variables = []) => {
const result = {};
variables.forEach((v) => {
if (v.name && v.enabled) {
result[v.name] = v.value;
}
});
return result;
};
const generateProperyShape = (obj) => {
let data = {};

View File

@@ -0,0 +1,478 @@
import { exportApiSpec } from './openapi-spec';
// Mock @usebruno/common to provide a working interpolate function
jest.mock('@usebruno/common', () => ({
interpolate: (str, vars) => {
if (!str || typeof str !== 'string') return str;
let result = str;
// Simple recursive interpolation for tests
let changed = true;
while (changed) {
changed = false;
result = result.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
if (vars && vars[key] !== undefined) {
changed = true;
return String(vars[key]);
}
return match;
});
}
return result;
}
}));
describe('exportApiSpec - server variables reconstruction', () => {
const makeItems = (urls) =>
urls.map((url, i) => ({
name: `Request ${i + 1}`,
type: 'http-request',
request: {
url,
method: 'GET',
params: [],
headers: [],
body: {},
auth: {}
}
}));
it('should reconstruct server URL template and variables map when baseUrl has template vars', () => {
const variables = {
baseUrl: '{{protocol}}://{{host}}:{{port}}/v1',
protocol: 'https',
host: 'api.example.com',
port: '443'
};
const items = makeItems(['{{baseUrl}}/users', '{{baseUrl}}/items']);
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
// Should contain the template server URL
expect(content).toContain('url: \'{protocol}://{host}:{port}/v1\'');
// Should contain the variables with defaults (js-yaml may or may not quote values)
expect(content).toContain('protocol:');
expect(content).toMatch(/default:\s*'?https'?/);
expect(content).toContain('host:');
expect(content).toMatch(/default:\s*api\.example\.com/);
expect(content).toContain('port:');
expect(content).toMatch(/default:\s*'443'/);
// Should NOT contain the resolved origin as a separate server
expect(content).not.toMatch(/url:\s*'?https:\/\/api\.example\.com:443'?/);
});
it('should strip base path from request pathnames to avoid duplication', () => {
const variables = {
baseUrl: '{{protocol}}://{{host}}/v1',
protocol: 'https',
host: 'api.example.com'
};
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
// Path should be /users, not /v1/users (since server URL already has /v1)
expect(content).toContain('/users:');
expect(content).not.toMatch(/\/v1\/users:/);
});
it('should use plain resolved URL when baseUrl has no template vars', () => {
const variables = {
baseUrl: 'https://api.example.com/v1'
};
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
// Should contain the resolved origin as a plain server
expect(content).toMatch(/url:\s*'?https:\/\/api\.example\.com'?/);
// Should NOT contain template syntax
expect(content).not.toContain('{protocol}');
expect(content).not.toContain('variables:');
});
it('should add non-baseUrl servers as operation-level overrides, not root servers', () => {
const variables = {
baseUrl: '{{protocol}}://{{host}}/v1',
protocol: 'https',
host: 'api.example.com'
};
// Mix of baseUrl requests and a direct URL request
const items = makeItems(['{{baseUrl}}/users', 'https://other-api.com/data']);
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
// Template server should be in root servers
expect(content).toContain('url: \'{protocol}://{host}/v1\'');
// Other server should appear as an operation-level override under /data
expect(content).toContain('/data:');
// Root servers should only contain the baseUrl server
const parsed = require('js-yaml').load(content);
expect(parsed.servers).toHaveLength(1);
expect(parsed.servers[0].url).toBe('{protocol}://{host}/v1');
// The operation-level server should be inside the GET operation
expect(parsed.paths['/data'].get.servers).toHaveLength(1);
expect(parsed.paths['/data'].get.servers[0].url).toBe('https://other-api.com');
});
it('should export request-level baseUrl override as a path-level server', () => {
const variables = {
baseUrl: 'https://api.example.com/v1'
};
const items = [
{
name: 'Get users',
type: 'http-request',
request: {
url: '{{baseUrl}}/users',
method: 'GET',
params: [],
headers: [],
body: {},
auth: {}
}
},
{
name: 'Get files',
type: 'http-request',
request: {
url: '{{baseUrl}}/files',
method: 'GET',
params: [],
headers: [],
body: {},
auth: {},
vars: {
req: [
{ name: 'baseUrl', value: 'https://files.example.com', enabled: true }
]
}
}
}
];
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
const parsed = require('js-yaml').load(content);
// Root servers should only have the collection baseUrl
expect(parsed.servers).toHaveLength(1);
expect(parsed.servers[0].url).toBe('https://api.example.com/v1');
// /users GET should NOT have an operation-level server override
expect(parsed.paths['/users'].get.servers).toBeUndefined();
// /files GET should have an operation-level server override
expect(parsed.paths['/files'].get.servers).toHaveLength(1);
expect(parsed.paths['/files'].get.servers[0].url).toBe('https://files.example.com');
});
it('should export request-level baseUrl override with template variables', () => {
const variables = {
baseUrl: 'https://api.example.com/v1'
};
const items = [
{
name: 'Regional data',
type: 'http-request',
request: {
url: '{{baseUrl}}/data',
method: 'GET',
params: [],
headers: [],
body: {},
auth: {},
vars: {
req: [
{ name: 'baseUrl', value: '{{protocol}}://{{region}}.example.com/v2', enabled: true },
{ name: 'protocol', value: 'https', enabled: true },
{ name: 'region', value: 'us-east', enabled: true }
]
}
}
}
];
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
const parsed = require('js-yaml').load(content);
// Operation-level server should have template URL with variables
const pathServers = parsed.paths['/data'].get.servers;
expect(pathServers).toHaveLength(1);
expect(pathServers[0].url).toBe('{protocol}://{region}.example.com/v2');
expect(pathServers[0].variables.protocol.default).toBe('https');
expect(pathServers[0].variables.region.default).toBe('us-east');
});
});
describe('exportApiSpec - parameter and body value preservation', () => {
it('should export path parameter values from params array', () => {
const variables = { baseUrl: 'https://api.example.com' };
const items = [{
name: 'Get user',
type: 'http-request',
request: {
url: '{{baseUrl}}/users/{userId}',
method: 'GET',
params: [
{ name: 'userId', value: '123', type: 'path', enabled: true },
{ name: 'include', value: 'profile', type: 'query', enabled: true }
],
headers: [], body: {}, auth: {}
}
}];
const { content } = exportApiSpec({ variables, items, name: 'Test' });
const parsed = require('js-yaml').load(content);
const params = parsed.paths['/users/{userId}'].get.parameters;
const pathParam = params.find((p) => p.in === 'path');
expect(pathParam.name).toBe('userId');
expect(pathParam.example).toBe('123');
const queryParam = params.find((p) => p.in === 'query');
expect(queryParam.name).toBe('include');
expect(queryParam.example).toBe('profile');
});
it('should not export path-type params as query params', () => {
const variables = { baseUrl: 'https://api.example.com' };
const items = [{
name: 'Get user',
type: 'http-request',
request: {
url: '{{baseUrl}}/users/{userId}',
method: 'GET',
params: [
{ name: 'userId', value: '123', type: 'path', enabled: true }
],
headers: [], body: {}, auth: {}
}
}];
const { content } = exportApiSpec({ variables, items, name: 'Test' });
const parsed = require('js-yaml').load(content);
const params = parsed.paths['/users/{userId}'].get.parameters;
const queryParams = params.filter((p) => p.in === 'query');
expect(queryParams).toHaveLength(0);
const pathParams = params.filter((p) => p.in === 'path');
expect(pathParams).toHaveLength(1);
expect(pathParams[0].name).toBe('userId');
});
it('should fall back to URL regex for path params not in params array', () => {
const variables = { baseUrl: 'https://api.example.com' };
const items = [{
name: 'Get user',
type: 'http-request',
request: {
url: '{{baseUrl}}/users/{userId}',
method: 'GET',
params: [],
headers: [], body: {}, auth: {}
}
}];
const { content } = exportApiSpec({ variables, items, name: 'Test' });
const parsed = require('js-yaml').load(content);
const params = parsed.paths['/users/{userId}'].get.parameters;
const pathParams = params.filter((p) => p.in === 'path');
expect(pathParams).toHaveLength(1);
expect(pathParams[0].name).toBe('userId');
expect(pathParams[0].example).toBeUndefined();
});
it('should preserve JSON body example for round-trip', () => {
const variables = { baseUrl: 'https://api.example.com' };
const items = [{
name: 'Create user',
type: 'http-request',
request: {
url: '{{baseUrl}}/users',
method: 'POST',
params: [], headers: [],
body: { mode: 'json', json: '{"name":"John","age":30}' },
auth: {}
}
}];
const { content } = exportApiSpec({ variables, items, name: 'Test' });
const parsed = require('js-yaml').load(content);
const schema = parsed.components.schemas.create_user;
expect(schema.example).toEqual({ name: 'John', age: 30 });
expect(schema.properties.name.type).toBe('string');
expect(schema.properties.age.type).toBe('number');
});
});
describe('exportApiSpec - multi-environment servers', () => {
const makeItems = (urls) =>
urls.map((url, i) => ({
name: `Request ${i + 1}`,
type: 'http-request',
request: {
url,
method: 'GET',
params: [],
headers: [],
body: {},
auth: {}
}
}));
const makeEnv = (name, vars) => ({
uid: name.toLowerCase(),
name,
variables: Object.entries(vars).map(([k, v]) => ({
uid: `${name}-${k}`,
name: k,
value: v,
enabled: true,
type: 'text',
secret: false
}))
});
it('should create a server entry per environment when baseUrl has template vars', () => {
const variables = {};
const environments = [
makeEnv('Production', { baseUrl: '{{protocol}}://{{host}}/v1', protocol: 'https', host: 'api.prod.com' }),
makeEnv('Staging', { baseUrl: '{{protocol}}://{{host}}/v1', protocol: 'https', host: 'api.staging.com' })
];
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API', environments });
// Both environments should appear as servers
expect(content).toContain('description: Production');
expect(content).toContain('description: Staging');
// Template URL should be used
expect(content).toContain('url: \'{protocol}://{host}/v1\'');
// Production vars
expect(content).toContain('api.prod.com');
// Staging vars
expect(content).toContain('api.staging.com');
});
it('should create a server entry per environment when baseUrl is a plain URL in each env', () => {
const variables = {};
const environments = [
makeEnv('Production', { baseUrl: 'https://api.prod.com/v1' }),
makeEnv('Staging', { baseUrl: 'https://api.staging.com/v1' })
];
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API', environments });
// Both servers should appear
expect(content).toMatch(/url:\s*'?https:\/\/api\.prod\.com\/v1'?/);
expect(content).toMatch(/url:\s*'?https:\/\/api\.staging\.com\/v1'?/);
// Descriptions should match env names
expect(content).toContain('description: Production');
expect(content).toContain('description: Staging');
// No OpenAPI template variable syntax in servers section
expect(content).not.toMatch(/url:\s*'?\{baseUrl\}'?/);
});
it('should use collection variables baseUrl when no environments are passed', () => {
const variables = {
baseUrl: '{{protocol}}://{{host}}/v1',
protocol: 'https',
host: 'api.example.com'
};
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API' });
// Should use collection baseUrl as template
expect(content).toContain('url: \'{protocol}://{host}/v1\'');
expect(content).toMatch(/default:\s*'?https'?/);
expect(content).toContain('api.example.com');
expect(content).toContain('description: Base Server');
});
it('should include both collection and env baseUrl as separate servers', () => {
const variables = {
baseUrl: '{{protocol}}://{{host}}/v1',
protocol: 'https',
host: 'api.default.com'
};
const environments = [
makeEnv('Production', { baseUrl: 'https://api.prod.com/v1' }),
makeEnv('Staging', { protocol: 'http', host: 'localhost' })
];
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API', environments });
// Collection template server should be present
expect(content).toContain('url: \'{protocol}://{host}/v1\'');
expect(content).toContain('description: Base Server');
// Production's plain URL override should also be present
expect(content).toMatch(/url:\s*'?https:\/\/api\.prod\.com\/v1'?/);
expect(content).toContain('description: Production');
// Staging doesn't define baseUrl — no separate server entry
expect(content).not.toContain('description: Staging');
});
it('should export both collection and env even when baseUrl resolves to the same value', () => {
const variables = {
baseUrl: 'https://api.example.com/v1'
};
const environments = [
makeEnv('Production', { baseUrl: 'https://api.example.com/v1' })
];
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API', environments });
// Both should appear as separate server entries
expect(content).toContain('description: Base Server');
expect(content).toContain('description: Production');
});
it('should skip environments that do not define baseUrl', () => {
const variables = {
baseUrl: '{{host}}/api'
};
const environments = [
makeEnv('Production', { host: 'https://api.prod.com', baseUrl: 'https://api.prod.com/api' }),
{ uid: 'empty', name: 'Empty', variables: [] }
];
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API', environments });
// Collection template and Production should have server entries
expect(content).toContain('description: Base Server');
expect(content).toContain('description: Production');
// Empty env has no baseUrl — no server entry
expect(content).not.toContain('description: Empty');
});
it('should export both envs even when baseUrl is identical', () => {
const variables = {};
const environments = [
makeEnv('Production', { baseUrl: 'https://api.example.com/v1' }),
makeEnv('Staging', { baseUrl: 'https://api.example.com/v1' })
];
const items = makeItems(['{{baseUrl}}/users']);
const { content } = exportApiSpec({ variables, items, name: 'Test API', environments });
// Both should appear as separate server entries (each maps to a Bruno environment on import)
expect(content).toContain('description: Production');
expect(content).toContain('description: Staging');
});
});

View File

@@ -497,6 +497,50 @@ const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, examp
return brunoExample;
};
// Extract a representative value from a schema property (used for request body properties)
// Priority: prop.example > parentExample[propName] > prop.default > prop.enum[0] > ''
const getSchemaPropertyExampleValue = (prop, propName, parentExample = {}) => {
if (prop.example !== undefined) return String(prop.example);
if (parentExample[propName] !== undefined) return String(parentExample[propName]);
if (prop.default !== undefined) return String(prop.default);
if (prop.enum && prop.enum.length > 0) return String(prop.enum[0]);
return '';
};
// Extract a representative value from an OpenAPI parameter object (query/path/header)
// Priority: param.example > param.examples > array items > schema.default > schema.example > schema.enum > schema.examples > schema.minimum > ''
const getParameterExampleValue = (param) => {
// Top-level param examples (mutually exclusive per spec)
if (param.example !== undefined) return String(param.example);
if (param.examples) {
const firstExample = Object.values(param.examples)[0];
if (firstExample?.value !== undefined) return String(firstExample.value);
}
// Array type - return first item as representative value
if (param.schema?.type === 'array' && param.schema?.items) {
const itemExample = param.schema.items.example
?? param.schema.items.enum?.[0]
?? '';
return String(itemExample);
}
// Schema-level fallback values
if (param.schema?.default !== undefined) return String(param.schema.default);
if (param.schema?.example !== undefined) return String(param.schema.example);
if (param.schema?.enum && param.schema.enum.length > 0) return String(param.schema.enum[0]);
// schema.examples is a plain JSON Schema array of values (OAS 3.1+)
if (Array.isArray(param.schema?.examples) && param.schema.examples.length > 0) {
return String(param.schema.examples[0]);
}
// Use minimum as a sensible fallback for numeric types
if (param.schema?.minimum !== undefined) return String(param.schema.minimum);
return '';
};
const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {}) => {
let _operationObject = request.operationObject;
@@ -562,6 +606,22 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
}
};
// If the operation has its own servers, override baseUrl via request vars
// Only the first server is used; Bruno supports a single baseUrl per request
if (request.servers && request.servers.length > 0) {
const serverVarPairs = extractServerVars(request.servers[0]);
brunoRequestItem.request.vars = {
req: serverVarPairs.map((sv) => ({
uid: uuid(),
name: sv.name,
value: sv.value,
enabled: true,
local: false
})),
res: []
};
}
each(_operationObject.parameters || [], (param) => {
// Check if parameter schema is an object type with properties
// If so, expand the properties into individual parameters
@@ -569,14 +629,18 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
if (isObjectSchema) {
// Expand object schema properties into individual parameters
const schemaExample = param.schema.example || {};
each(param.schema.properties, (prop, propName) => {
const isRequired = Array.isArray(param.schema.required) && param.schema.required.includes(propName);
if (param.in === 'query') {
const propValue = getSchemaPropertyExampleValue(prop, propName, schemaExample);
if (param.in === 'query' || param.in === 'querystring') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: '',
value: propValue,
description: prop.description || '',
enabled: isRequired,
type: 'query'
@@ -585,7 +649,7 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: '',
value: propValue,
description: prop.description || '',
enabled: isRequired,
type: 'path'
@@ -594,18 +658,20 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: propName,
value: '',
value: propValue,
description: prop.description || '',
enabled: isRequired
});
}
});
} else {
if (param.in === 'query') {
const paramValue = getParameterExampleValue(param);
if (param.in === 'query' || param.in === 'querystring') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
value: paramValue,
description: param.description || '',
enabled: param.required,
type: 'query'
@@ -614,7 +680,7 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
value: paramValue,
description: param.description || '',
enabled: param.required,
type: 'path'
@@ -623,7 +689,7 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: param.name,
value: '',
value: paramValue,
description: param.description || '',
enabled: param.required
});
@@ -1148,13 +1214,34 @@ const getDefaultUrl = (serverObject) => {
let url = serverObject.url;
if (serverObject.variables) {
each(serverObject.variables, (variable, variableName) => {
let sub = variable.default || (variable.enum ? variable.enum[0] : `{{${variableName}}}`);
url = url.replace(`{${variableName}}`, sub);
let sub = variable.default !== undefined ? variable.default : (variable.enum ? variable.enum[0] : `{{${variableName}}}`);
url = url.replaceAll(`{${variableName}}`, sub);
});
}
return url.endsWith('/') ? url.slice(0, -1) : url;
};
// Extract { name, value } pairs from an OpenAPI server object.
// Converts {varName} to {{varName}} for template URLs and includes variable defaults.
const extractServerVars = (server) => {
const vars = [];
if (server.variables && Object.keys(server.variables).length > 0) {
let baseUrlTemplate = server.url;
each(server.variables, (variable, variableName) => {
baseUrlTemplate = baseUrlTemplate.replaceAll(`{${variableName}}`, `{{${variableName}}}`);
});
baseUrlTemplate = baseUrlTemplate.endsWith('/') ? baseUrlTemplate.slice(0, -1) : baseUrlTemplate;
vars.push({ name: 'baseUrl', value: baseUrlTemplate });
each(server.variables, (variable, variableName) => {
let value = variable.default !== undefined ? variable.default : (variable.enum ? variable.enum[0] : '');
vars.push({ name: variableName, value: String(value) });
});
} else {
vars.push({ name: 'baseUrl', value: getDefaultUrl(server) });
}
return vars;
};
const getSecurity = (apiSpec) => {
let defaultSchemes = apiSpec.security || [];
let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
@@ -1215,45 +1302,58 @@ export const parseOpenApiCollection = (data, options = {}) => {
// Create environments based on the servers
servers.forEach((server, index) => {
let baseUrl = getDefaultUrl(server);
let environmentName = server.description ? server.description : `Environment ${index + 1}`;
let environmentName = server.name || server.description || `Environment ${index + 1}`;
const serverVars = extractServerVars(server);
const variables = serverVars.map((sv) => ({
uid: uuid(),
name: sv.name,
value: sv.value,
type: 'text',
enabled: true,
secret: false
}));
brunoCollection.environments.push({
uid: uuid(),
name: environmentName,
variables: [
{
uid: uuid(),
name: 'baseUrl',
value: baseUrl,
type: 'text',
enabled: true,
secret: false
}
]
variables
});
});
let securityConfig = getSecurity(collectionData);
// Merge path-item parameters with operation parameters.
// Operation parameters override path-item parameters with the same name+in combination.
const mergeParams = (pathParams, operationParams) => {
const overrides = new Set(operationParams.map((p) => `${p.name}:${p.in}`));
const inheritedParams = pathParams.filter((p) => !overrides.has(`${p.name}:${p.in}`));
return [...inheritedParams, ...operationParams];
};
let allRequests = Object.entries(collectionData.paths)
.map(([path, methods]) => {
return Object.entries(methods)
.map(([path, pathItemObject]) => {
// Extract path-item level parameters (per OpenAPI spec, these apply to all operations under this path)
const pathItemParams = pathItemObject.parameters || [];
return Object.entries(pathItemObject)
.filter(([method, op]) => {
return ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(
method.toLowerCase()
);
})
.map(([method, operationObject]) => {
const mergedParams = mergeParams(pathItemParams, operationObject.parameters || []);
return {
method: method,
path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
originalPath: path, // Keep original path for grouping
operationObject: operationObject,
operationObject: { ...operationObject, parameters: mergedParams },
global: {
server: '{{baseUrl}}',
security: securityConfig
}
},
servers: operationObject.servers || pathItemObject.servers || null
};
});
})

View File

@@ -0,0 +1,644 @@
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('openapi path-item level parameters', () => {
it('should apply path-item parameters to all operations when no operation params exist', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Path Params API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/items/{itemId}:
parameters:
- name: itemId
in: path
required: true
schema:
type: string
description: 'The item ID'
get:
summary: 'Get item'
operationId: 'getItem'
responses:
'200':
description: 'OK'
put:
summary: 'Update item'
operationId: 'updateItem'
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
// Both GET and PUT should have the itemId path parameter
const getItem = result.items.find((i) => i.name === 'Get item');
const putItem = result.items.find((i) => i.name === 'Update item');
expect(getItem.request.params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'itemId', type: 'path', enabled: true })])
);
expect(putItem.request.params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'itemId', type: 'path', enabled: true })])
);
});
it('should preserve operation-only parameters unchanged', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Op Only Params API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/search:
get:
summary: 'Search'
operationId: 'search'
parameters:
- name: q
in: query
required: true
schema:
type: string
description: 'Search query'
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const search = result.items.find((i) => i.name === 'Search');
const queryParams = search.request.params.filter((p) => p.type === 'query');
expect(queryParams).toHaveLength(1);
expect(queryParams[0].name).toBe('q');
});
it('should merge path-item and operation params with no overlap', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Merge No Overlap API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/items/{itemId}:
parameters:
- name: itemId
in: path
required: true
schema:
type: string
get:
summary: 'Get item'
operationId: 'getItem'
parameters:
- name: fields
in: query
required: false
schema:
type: string
description: 'Fields to include'
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const getItem = result.items.find((i) => i.name === 'Get item');
// Should have both the path param from path-item and the query param from operation
const pathParams = getItem.request.params.filter((p) => p.type === 'path');
const queryParams = getItem.request.params.filter((p) => p.type === 'query');
expect(pathParams).toHaveLength(1);
expect(pathParams[0].name).toBe('itemId');
expect(queryParams).toHaveLength(1);
expect(queryParams[0].name).toBe('fields');
});
it('should let operation param override path-item param with same name and in', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Override API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/items:
parameters:
- name: limit
in: query
required: false
schema:
type: integer
description: 'Default limit from path-item'
get:
summary: 'List items'
operationId: 'listItems'
parameters:
- name: limit
in: query
required: true
schema:
type: integer
maximum: 50
description: 'Override limit for list operation'
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
// Should have exactly one 'limit' query param -- the operation-level one
const limitParams = listItems.request.params.filter((p) => p.name === 'limit');
expect(limitParams).toHaveLength(1);
expect(limitParams[0].description).toBe('Override limit for list operation');
expect(limitParams[0].enabled).toBe(true); // required=true from operation
});
it('should handle path-item params with different in values (query, path, header)', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Mixed In Values API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/resources/{resourceId}:
parameters:
- name: resourceId
in: path
required: true
schema:
type: string
- name: format
in: query
required: false
schema:
type: string
description: 'Response format'
- name: X-Request-ID
in: header
required: false
schema:
type: string
description: 'Request tracking ID'
get:
summary: 'Get resource'
operationId: 'getResource'
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const getResource = result.items.find((i) => i.name === 'Get resource');
const pathParams = getResource.request.params.filter((p) => p.type === 'path');
const queryParams = getResource.request.params.filter((p) => p.type === 'query');
const headers = getResource.request.headers;
expect(pathParams).toHaveLength(1);
expect(pathParams[0].name).toBe('resourceId');
expect(queryParams).toHaveLength(1);
expect(queryParams[0].name).toBe('format');
// Header params end up in request.headers, not request.params
const trackingHeader = headers.find((h) => h.name === 'X-Request-ID');
expect(trackingHeader).toBeDefined();
expect(trackingHeader.description).toBe('Request tracking ID');
});
});
describe('openapi parameter default and example values', () => {
it('should use param.example as the value when present', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Param Example API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/search:
get:
summary: 'Search'
operationId: 'search'
parameters:
- name: q
in: query
required: true
example: 'hello world'
schema:
type: string
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const search = result.items.find((i) => i.name === 'Search');
const qParam = search.request.params.find((p) => p.name === 'q');
expect(qParam.value).toBe('hello world');
});
it('should use schema.default when param.example is not present', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Schema Default API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/items:
get:
summary: 'List items'
operationId: 'listItems'
parameters:
- name: limit
in: query
required: false
schema:
type: integer
default: 20
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
const limitParam = listItems.request.params.find((p) => p.name === 'limit');
expect(limitParam.value).toBe('20');
});
it('should use schema.example when no param.example or schema.default exists', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Schema Example API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/users/{userId}:
get:
summary: 'Get user'
operationId: 'getUser'
parameters:
- name: userId
in: path
required: true
schema:
type: string
example: 'user-123'
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const getUser = result.items.find((i) => i.name === 'Get user');
const userIdParam = getUser.request.params.find((p) => p.name === 'userId');
expect(userIdParam.value).toBe('user-123');
});
it('should fall back to empty string when no example or default is present', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'No Default API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/items:
get:
summary: 'List items'
operationId: 'listItems'
parameters:
- name: filter
in: query
required: false
schema:
type: string
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
const filterParam = listItems.request.params.find((p) => p.name === 'filter');
expect(filterParam.value).toBe('');
});
it('should use property example/default for object schema expansion', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Object Schema API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/items': {
get: {
summary: 'List items',
operationId: 'listItems',
parameters: [
{
name: 'pagination',
in: 'query',
schema: {
type: 'object',
properties: {
page: { type: 'integer', example: 1 },
size: { type: 'integer', default: 25 },
sort: { type: 'string' }
},
required: ['page']
}
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
const queryParams = listItems.request.params.filter((p) => p.type === 'query');
const pageParam = queryParams.find((p) => p.name === 'page');
expect(pageParam.value).toBe('1'); // from prop.example
const sizeParam = queryParams.find((p) => p.name === 'size');
expect(sizeParam.value).toBe('25'); // from prop.default
const sortParam = queryParams.find((p) => p.name === 'sort');
expect(sortParam.value).toBe(''); // no example or default
});
it('should use top-level schema.example object for property values when no prop-level example', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Schema Example API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/books': {
get: {
summary: 'List books',
operationId: 'listBooks',
parameters: [
{
name: 'filter',
in: 'query',
schema: {
type: 'object',
example: {
title: 'The Great Gatsby',
author: 'F. Scott Fitzgerald'
},
properties: {
title: { type: 'string' },
author: { type: 'string' },
genre: { type: 'string' }
}
}
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const listBooks = result.items.find((i) => i.name === 'List books');
const queryParams = listBooks.request.params.filter((p) => p.type === 'query');
const titleParam = queryParams.find((p) => p.name === 'title');
expect(titleParam.value).toBe('The Great Gatsby'); // from schema.example.title
const authorParam = queryParams.find((p) => p.name === 'author');
expect(authorParam.value).toBe('F. Scott Fitzgerald'); // from schema.example.author
const genreParam = queryParams.find((p) => p.name === 'genre');
expect(genreParam.value).toBe(''); // not in schema.example, no prop example/default
});
it('should use first enum value as fallback when no example or default', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Enum Fallback API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/books': {
get: {
summary: 'List books',
operationId: 'listBooks',
parameters: [
{
name: 'filter',
in: 'query',
schema: {
type: 'object',
properties: {
genre: { type: 'string', enum: ['Fiction', 'Non-Fiction', 'Science'] },
format: { type: 'string', enum: ['hardcover', 'paperback'] },
sort: { type: 'string' }
}
}
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const listBooks = result.items.find((i) => i.name === 'List books');
const queryParams = listBooks.request.params.filter((p) => p.type === 'query');
const genreParam = queryParams.find((p) => p.name === 'genre');
expect(genreParam.value).toBe('Fiction'); // first enum value
const formatParam = queryParams.find((p) => p.name === 'format');
expect(formatParam.value).toBe('hardcover'); // first enum value
const sortParam = queryParams.find((p) => p.name === 'sort');
expect(sortParam.value).toBe(''); // no enum, no example, no default
});
it('should prefer prop.example over schema.example[propName]', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Priority API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/books': {
get: {
summary: 'List books',
operationId: 'listBooks',
parameters: [
{
name: 'filter',
in: 'query',
schema: {
type: 'object',
example: {
title: 'Schema-level title',
author: 'Schema-level author'
},
properties: {
title: { type: 'string', example: 'Property-level title' },
author: { type: 'string' }
}
}
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const listBooks = result.items.find((i) => i.name === 'List books');
const queryParams = listBooks.request.params.filter((p) => p.type === 'query');
const titleParam = queryParams.find((p) => p.name === 'title');
expect(titleParam.value).toBe('Property-level title'); // prop.example wins over schema.example
const authorParam = queryParams.find((p) => p.name === 'author');
expect(authorParam.value).toBe('Schema-level author'); // falls back to schema.example
});
it('should prefer param.example over schema.default', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Priority API'
version: '1.0.0'
servers:
- url: 'https://api.example.com'
paths:
/items:
get:
summary: 'List items'
operationId: 'listItems'
parameters:
- name: limit
in: query
required: false
example: 50
schema:
type: integer
default: 20
example: 10
responses:
'200':
description: 'OK'
`;
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
const limitParam = listItems.request.params.find((p) => p.name === 'limit');
expect(limitParam.value).toBe('50'); // param.example wins over schema.default and schema.example
});
it('should use schema.examples (plural) when no other example or default exists', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Schema Examples API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/items': {
get: {
summary: 'List items',
operationId: 'listItems',
parameters: [
{
name: 'status',
in: 'query',
schema: {
type: 'string',
examples: ['active', 'archived']
}
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
const statusParam = listItems.request.params.find((p) => p.name === 'status');
expect(statusParam.value).toBe('active'); // first schema.examples value
});
it('should use schema.minimum as fallback when no example, default, or enum exists', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Minimum API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/items': {
get: {
summary: 'List items',
operationId: 'listItems',
parameters: [
{
name: 'page',
in: 'query',
schema: {
type: 'integer',
minimum: 1,
maximum: 100
}
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const listItems = result.items.find((i) => i.name === 'List items');
const pageParam = listItems.request.params.find((p) => p.name === 'page');
expect(pageParam.value).toBe('1'); // schema.minimum as fallback
});
});
// Tests backward-compat handling of non-standard in: 'querystring' (some importers emit this instead of 'query')
describe('openapi querystring parameter location', () => {
it('should map in: "querystring" to query type', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Querystring API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/search': {
get: {
summary: 'Search',
operationId: 'search',
parameters: [
{
name: 'q',
in: 'querystring',
required: true,
schema: { type: 'string' }
}
],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const search = result.items.find((i) => i.name === 'Search');
const queryParams = search.request.params.filter((p) => p.type === 'query');
expect(queryParams).toHaveLength(1);
expect(queryParams[0].name).toBe('q');
expect(queryParams[0].enabled).toBe(true);
});
});

View File

@@ -0,0 +1,343 @@
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('openapi server variables to environment variables', () => {
it('should create individual environment variables from server variables', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Server Variables API'
version: '1.0.0'
paths:
/test:
get:
summary: 'Test'
responses:
'200':
description: 'OK'
servers:
- url: '{protocol}://{host}:{port}/v1'
description: 'Main Server'
variables:
protocol:
default: 'https'
host:
default: 'api.example.com'
port:
default: '443'
`;
const result = openApiToBruno(spec);
const env = result.environments[0];
expect(env.name).toBe('Main Server');
expect(env.variables).toHaveLength(4); // baseUrl + 3 variables
const baseUrl = env.variables.find((v) => v.name === 'baseUrl');
expect(baseUrl.value).toBe('{{protocol}}://{{host}}:{{port}}/v1');
expect(baseUrl.enabled).toBe(true);
expect(baseUrl.type).toBe('text');
const protocol = env.variables.find((v) => v.name === 'protocol');
expect(protocol.value).toBe('https');
expect(protocol.enabled).toBe(true);
expect(protocol.type).toBe('text');
const host = env.variables.find((v) => v.name === 'host');
expect(host.value).toBe('api.example.com');
const port = env.variables.find((v) => v.name === 'port');
expect(port.value).toBe('443');
});
it('should use plain resolved URL when server has no variables', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'No Variables API'
version: '1.0.0'
paths:
/test:
get:
summary: 'Test'
responses:
'200':
description: 'OK'
servers:
- url: 'https://api.example.com/v1'
`;
const result = openApiToBruno(spec);
const env = result.environments[0];
expect(env.variables).toHaveLength(1);
expect(env.variables[0].name).toBe('baseUrl');
expect(env.variables[0].value).toBe('https://api.example.com/v1');
});
it('should handle multiple servers with different variables', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Multi Server API'
version: '1.0.0'
paths:
/test:
get:
summary: 'Test'
responses:
'200':
description: 'OK'
servers:
- url: '{protocol}://{host}/v1'
description: 'Production'
variables:
protocol:
default: 'https'
host:
default: 'api.prod.com'
- url: 'https://staging.example.com/v1'
description: 'Staging'
`;
const result = openApiToBruno(spec);
// Production env: template + variables
const prodEnv = result.environments[0];
expect(prodEnv.name).toBe('Production');
expect(prodEnv.variables).toHaveLength(3); // baseUrl + protocol + host
expect(prodEnv.variables.find((v) => v.name === 'baseUrl').value).toBe('{{protocol}}://{{host}}/v1');
expect(prodEnv.variables.find((v) => v.name === 'protocol').value).toBe('https');
expect(prodEnv.variables.find((v) => v.name === 'host').value).toBe('api.prod.com');
// Staging env: plain URL, no extra variables
const stagingEnv = result.environments[1];
expect(stagingEnv.name).toBe('Staging');
expect(stagingEnv.variables).toHaveLength(1);
expect(stagingEnv.variables[0].value).toBe('https://staging.example.com/v1');
});
it('should use first enum value when no default is provided', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Enum Only API'
version: '1.0.0'
paths:
/test:
get:
summary: 'Test'
responses:
'200':
description: 'OK'
servers:
- url: '{protocol}://example.com'
variables:
protocol:
enum:
- https
- http
`;
const result = openApiToBruno(spec);
const env = result.environments[0];
const protocolVar = env.variables.find((v) => v.name === 'protocol');
expect(protocolVar.value).toBe('https');
});
it('should use empty string when variable has neither default nor enum', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'No Default No Enum API'
version: '1.0.0'
paths:
/test:
get:
summary: 'Test'
responses:
'200':
description: 'OK'
servers:
- url: '{basePath}/v1'
variables:
basePath: {}
`;
const result = openApiToBruno(spec);
const env = result.environments[0];
const basePathVar = env.variables.find((v) => v.name === 'basePath');
expect(basePathVar.value).toBe('');
});
it('should strip trailing slash from template baseUrl', () => {
const spec = `
openapi: '3.0.0'
info:
title: 'Trailing Slash API'
version: '1.0.0'
paths:
/test:
get:
summary: 'Test'
responses:
'200':
description: 'OK'
servers:
- url: '{protocol}://{host}/'
variables:
protocol:
default: 'https'
host:
default: 'api.example.com'
`;
const result = openApiToBruno(spec);
const env = result.environments[0];
const baseUrl = env.variables.find((v) => v.name === 'baseUrl');
expect(baseUrl.value).toBe('{{protocol}}://{{host}}');
});
it('should use server.name for environment name when present', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Named Server API', version: '1.0.0' },
paths: {
'/test': {
get: {
summary: 'Test',
responses: { 200: { description: 'OK' } }
}
}
},
servers: [
{
url: 'https://api.example.com',
name: 'Production',
description: 'Production server'
},
{
url: 'https://staging.example.com',
description: 'Staging server'
},
{
url: 'https://dev.example.com'
}
]
};
const result = openApiToBruno(spec);
expect(result.environments[0].name).toBe('Production'); // prefers name over description
expect(result.environments[1].name).toBe('Staging server'); // falls back to description
expect(result.environments[2].name).toBe('Environment 3'); // falls back to index
});
});
describe('operation-level servers to request vars', () => {
it('should set request vars.req with baseUrl when operation has its own servers', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Op Server API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/files': {
get: {
summary: 'Get files',
operationId: 'getFiles',
servers: [{ url: 'https://files.example.com' }],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const getFiles = result.items.find((i) => i.name === 'Get files');
expect(getFiles.request.vars).toBeDefined();
expect(getFiles.request.vars.req).toHaveLength(1);
expect(getFiles.request.vars.req[0]).toMatchObject({
name: 'baseUrl',
value: 'https://files.example.com',
enabled: true
});
expect(getFiles.request.vars.res).toEqual([]);
});
it('should create template baseUrl and variable entries for server with variables', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Var Server API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/regional': {
get: {
summary: 'Regional data',
servers: [{
url: '{protocol}://{region}.example.com/v2',
variables: {
protocol: { default: 'https' },
region: { default: 'us-east' }
}
}],
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const regional = result.items.find((i) => i.name === 'Regional data');
expect(regional.request.vars.req).toHaveLength(3); // baseUrl + protocol + region
const baseUrlVar = regional.request.vars.req.find((v) => v.name === 'baseUrl');
expect(baseUrlVar.value).toBe('{{protocol}}://{{region}}.example.com/v2');
const protocolVar = regional.request.vars.req.find((v) => v.name === 'protocol');
expect(protocolVar.value).toBe('https');
const regionVar = regional.request.vars.req.find((v) => v.name === 'region');
expect(regionVar.value).toBe('us-east');
});
it('should NOT set request vars when no operation servers exist', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'No Override API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/test': {
get: {
summary: 'Test',
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const test = result.items.find((i) => i.name === 'Test');
expect(test.request.vars).toBeUndefined();
});
it('should only set vars on the operation that defines servers', () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Selective API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/data': {
get: {
summary: 'Get data',
servers: [{ url: 'https://data-server.example.com' }],
responses: { 200: { description: 'OK' } }
},
post: {
summary: 'Post data',
responses: { 200: { description: 'OK' } }
}
}
}
};
const result = openApiToBruno(spec);
const getData = result.items.find((i) => i.name === 'Get data');
const postData = result.items.find((i) => i.name === 'Post data');
// GET has operation-level servers — should have vars
expect(getData.request.vars).toBeDefined();
expect(getData.request.vars.req[0].value).toBe('https://data-server.example.com');
// POST has no operation-level servers — should NOT have vars
expect(postData.request.vars).toBeUndefined();
});
});