mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-26 22:25:40 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
478
packages/bruno-app/src/utils/exporters/openapi-spec.spec.js
Normal file
478
packages/bruno-app/src/utils/exporters/openapi-spec.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user