Files
bruno/packages/bruno-app/src/utils/exporters/openapi-spec.js
prateek-bruno b8804afade fix: openapi spec export crash on websocket request (#8132)
* fix: only accept http and graphql for openapi spec

* chore: add test

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

---------

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>
2026-06-15 18:45:49 +05:30

583 lines
18 KiB
JavaScript

import jsyaml from 'js-yaml';
import { interpolate } from '@usebruno/common';
import { isValidUrl } from 'utils/url/index';
const xml2js = require('xml2js');
export const exportApiSpec = ({ variables, items, name, environments }) => {
// Filter only include http-request and graphql-request items that aren't transient
items = items.filter((item) => ['http-request', 'graphql-request'].includes(item.type) && !item.isTransient);
const components = {
schemas: {},
requestBodies: {},
securitySchemes: {}
};
const servers = [];
const warnings = [];
const addWarning = (message, itemName) => {
warnings.push({
message,
itemName
});
};
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;
const parts = pathname.split(/[\\/]/);
const baseDepth = parts.length - depth;
if (depth === 1) return '';
const tagIndex = Math.max(baseDepth, 0);
return parts[tagIndex];
};
const componentIds = new Set();
const getComponentId = (item) => {
const baseId = String(item?.name || 'request')
.replace(/[^a-zA-Z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase() || 'request';
let componentId = baseId;
let suffix = 1;
while (componentIds.has(componentId)) {
componentId = `${baseId}_${suffix}`;
suffix += 1;
}
componentIds.add(componentId);
return componentId;
};
// 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) => {
const { url, operationLevelServer } = resolveRequestUrl(item?.request?.url || '', item?.request?.vars?.req);
const { request } = item;
const { method, params = [], headers = [], body, auth } = request || {};
// PARAMS
const pathParamsRegex = /(?<!{){([^{}]+)}(?!})/g;
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 = [
// Query params (exclude path-type params to avoid duplication)
...params?.filter((p) => p?.type !== 'path').map((param) => ({
name: param?.name,
in: 'query',
description: '',
required: param?.enabled,
example: param?.value
})),
...headers?.map((header) => ({
name: header?.name,
in: 'header',
description: '',
required: header?.enabled,
example: header?.value
})),
// 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,
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 = {
summary: item?.name,
operationId: item?.name,
description: '',
tags: [extractTagFromDepth(item)],
responses: {
200: {
description: ''
}
}
};
if (parameters?.length) {
pathBody['parameters'] = parameters;
}
// BODY
let componentId;
const getItemComponentId = () => {
if (!componentId) {
componentId = getComponentId(item);
}
return componentId;
};
if (body?.mode) {
switch (body?.mode) {
case 'json':
if (!body?.json) break;
try {
const componentId = getItemComponentId();
const parsedJson = JSON.parse(body.json);
const schema = generateProperyShape(parsedJson);
schema.example = parsedJson;
components.schemas[componentId] = schema;
components.requestBodies[componentId] = {
content: {
'application/json': {
schema: {
$ref: `#/components/schemas/${componentId}`
}
}
},
description: '',
required: true
};
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${componentId}`
};
} catch (error) {
addWarning(`Failed to parse JSON in request body: ${error.message}`, item?.name);
const componentId = getItemComponentId();
components.schemas[componentId] = {
type: 'object',
properties: {}
};
components.requestBodies[componentId] = {
content: {
'application/json': {
schema: {
$ref: `#/components/schemas/${componentId}`
}
}
},
description: '',
required: true
};
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${componentId}`
};
}
break;
case 'xml':
if (!body?.xml) break;
try {
const jsonResult = xmlToJson(body?.xml);
if (!jsonResult) {
addWarning('Failed to parse XML in request body', item?.name);
break;
}
const componentId = getItemComponentId();
const xmlSchema = generateProperyShape(jsonResult);
xmlSchema.example = jsonResult;
components.schemas[componentId] = xmlSchema;
components.requestBodies[componentId] = {
content: {
'application/xml': {
schema: {
$ref: `#/components/schemas/${componentId}`
}
}
},
description: '',
required: true
};
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${componentId}`
};
} catch (error) {
addWarning(`Failed to parse XML in request body: ${error.message}`, item?.name);
}
break;
case 'multipartForm':
if (!body?.multipartForm) break;
const multipartFormComponentId = getItemComponentId();
let multipartFormToKeyValue = body?.multipartForm.reduce((acc, f) => {
acc[f?.name] = f.value;
return acc;
}, {});
components.schemas[multipartFormComponentId] = generateProperyShape(multipartFormToKeyValue);
components.requestBodies[multipartFormComponentId] = {
content: {
'multipart/form-data': {
schema: {
$ref: `#/components/schemas/${multipartFormComponentId}`
}
}
},
description: '',
required: true
};
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${multipartFormComponentId}`
};
break;
case 'formUrlEncoded':
if (!body?.formUrlEncoded) break;
const formUrlEncodedComponentId = getItemComponentId();
let formUrlEncodedToKeyValue = body?.formUrlEncoded.reduce((acc, f) => {
acc[f?.name] = f.value;
return acc;
}, {});
components.schemas[formUrlEncodedComponentId] = generateProperyShape(formUrlEncodedToKeyValue);
components.requestBodies[formUrlEncodedComponentId] = {
content: {
'application/x-www-form-urlencoded': {
schema: {
$ref: `#/components/schemas/${formUrlEncodedComponentId}`
}
}
},
description: '',
required: true
};
pathBody['requestBody'] = {
$ref: `#/components/requestBodies/${formUrlEncodedComponentId}`
};
break;
case 'text':
if (!body?.text) break;
pathBody['requestBody'] = {
content: {
'text/plain': {
schema: {
type: 'string'
}
}
}
};
break;
default:
break;
}
}
// AUTH
if (auth?.mode) {
switch (auth?.mode) {
case 'basic':
componentId = getItemComponentId();
components.securitySchemes[componentId] = {
type: 'http',
scheme: 'basic'
};
pathBody['security'] = {
[componentId]: []
};
break;
case 'bearer':
componentId = getItemComponentId();
components.securitySchemes[componentId] = {
type: 'http',
scheme: 'bearer'
};
pathBody['security'] = {
[componentId]: []
};
break;
case 'oauth2': {
if (!auth?.oauth2?.grantType) break;
componentId = getItemComponentId();
const { authorizationUrl, accessTokenUrl, callbackUrl, scope } = auth?.oauth2;
const scopes = scope ? { [scope]: '' } : {};
switch (auth?.oauth2?.grantType) {
case 'authorization_code':
components.securitySchemes[componentId] = {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl,
tokenUrl: accessTokenUrl,
scopes
}
}
};
pathBody['security'] = {
[componentId]: []
};
break;
case 'password':
components.securitySchemes[componentId] = {
type: 'oauth2',
flows: {
password: {
tokenUrl: accessTokenUrl,
scopes
}
}
};
pathBody['security'] = {
[componentId]: []
};
break;
case 'client_credentials':
components.securitySchemes[componentId] = {
type: 'oauth2',
flows: {
password: {
tokenUrl: accessTokenUrl,
scopes
}
}
};
pathBody['security'] = {
[componentId]: []
};
break;
}
break;
}
case 'awsv4':
componentId = getItemComponentId();
components.securitySchemes[componentId] = {
'type': 'apiKey',
'name': 'Authorization',
'in': 'header',
'x-amazon-apigateway-authtype': 'awsSigv4'
};
pathBody['security'] = {
[componentId]: []
};
break;
case 'digest':
componentId = getItemComponentId();
components.securitySchemes[componentId] = {
type: 'digest',
scheme: 'digest',
description: 'Digest Authentication'
};
pathBody['security'] = {
[componentId]: []
};
break;
default:
break;
}
}
return {
url,
method: method.toLowerCase(),
data: pathBody,
operationLevelServer
};
});
return _items.reduce((acc, item) => {
if (!acc[item?.url]) {
acc[item?.url] = {};
}
const operation = item?.data;
if (item?.operationLevelServer) {
// Add operation-level server override inside the operation object (not path-item level)
// so the import can read it back from operationObject.servers
operation.servers = [item.operationLevelServer];
}
let operationObject = acc[item?.url][item?.method];
if (operationObject) {
operationObject['x-bruno-variants'] = [
...(operationObject['x-bruno-variants'] || []),
operation
];
} else {
acc[item?.url][item?.method] = operation;
}
return acc;
}, {});
};
const collectionToExport = {};
collectionToExport.openapi = '3.0.0';
collectionToExport.info = generateInfoSection(name);
collectionToExport.paths = generatePaths();
collectionToExport.servers = servers;
collectionToExport.components = components;
let yaml = jsyaml.dump(collectionToExport);
return {
content: yaml,
warnings
};
};
const xmlToJson = (xmlString) => {
const parser = new xml2js.Parser({ explicitArray: false, trim: true });
let jsonResult = null;
parser.parseString(xmlString, (err, result) => {
if (err) {
throw err;
} else {
jsonResult = result;
}
});
return jsonResult;
};
const generateInfoSection = (name) => {
return {
title: name,
version: '1.0.0'
};
};
// 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 = {};
// add 'type'
if (Array.isArray(obj)) {
data['type'] = 'array';
data['items'] = {
type: 'string'
};
} else {
data['type'] = typeof obj;
}
// add 'properties'
let properties = null;
if (obj && typeof obj == 'object') {
properties = {};
let keys = Object.keys(obj);
keys.forEach((key) => {
let value = obj[key];
properties[key] = generateProperyShape(value);
});
if (keys.length) {
data['properties'] = properties;
}
}
return data;
};