mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 22:45:25 +00:00
* 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>
583 lines
18 KiB
JavaScript
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;
|
|
};
|