From e0dd79418b6ab97482ab493e4230ab22e920ef1f Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Tue, 3 Mar 2026 21:24:01 +0530 Subject: [PATCH] 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. --- .../Sidebar/ApiSpecs/CreateApiSpec/index.js | 7 +- .../src/utils/exporters/openapi-spec.js | 166 ++++- .../src/utils/exporters/openapi-spec.spec.js | 478 +++++++++++++ .../src/openapi/openapi-to-bruno.js | 152 ++++- .../openapi-path-parameters.spec.js | 644 ++++++++++++++++++ .../openapi-server-variables.spec.js | 343 ++++++++++ 6 files changed, 1742 insertions(+), 48 deletions(-) create mode 100644 packages/bruno-app/src/utils/exporters/openapi-spec.spec.js create mode 100644 packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-path-parameters.spec.js create mode 100644 packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-server-variables.spec.js diff --git a/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js b/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js index eeabf6e3d..c8100080e 100644 --- a/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js +++ b/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js @@ -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; } diff --git a/packages/bruno-app/src/utils/exporters/openapi-spec.js b/packages/bruno-app/src/utils/exporters/openapi-spec.js index bdb8150eb..063319783 100644 --- a/packages/bruno-app/src/utils/exporters/openapi-spec.js +++ b/packages/bruno-app/src/utils/exporters/openapi-spec.js @@ -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 = {}; diff --git a/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js b/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js new file mode 100644 index 000000000..92b7196d8 --- /dev/null +++ b/packages/bruno-app/src/utils/exporters/openapi-spec.spec.js @@ -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'); + }); +}); diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js index 7519a04a5..11c29919c 100644 --- a/packages/bruno-converters/src/openapi/openapi-to-bruno.js +++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js @@ -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 }; }); }) diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-path-parameters.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-path-parameters.spec.js new file mode 100644 index 000000000..f65fa6d1f --- /dev/null +++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-path-parameters.spec.js @@ -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); + }); +}); diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-server-variables.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-server-variables.spec.js new file mode 100644 index 000000000..c2273f84c --- /dev/null +++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-server-variables.spec.js @@ -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(); + }); +});