@usebruno/converters
Version:
The converters package is responsible for converting collections from one format to a Bruno collection. It can be used as a standalone package or as a part of the Bruno framework.
437 lines (392 loc) • 13.3 kB
JavaScript
import each from 'lodash/each';
import get from 'lodash/get';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
const ensureUrl = (url) => {
// removing multiple slashes after the protocol if it exists, or after the beginning of the string otherwise
return url.replace(/([^:])\/{2,}/g, '$1/');
};
const buildEmptyJsonBody = (bodySchema) => {
let _jsonBody = {};
each(bodySchema.properties || {}, (prop, name) => {
if (prop.type === 'object') {
_jsonBody[name] = buildEmptyJsonBody(prop);
} else if (prop.type === 'array') {
if (prop.items && prop.items.type === 'object') {
_jsonBody[name] = [buildEmptyJsonBody(prop.items)];
} else {
_jsonBody[name] = [];
}
} else {
_jsonBody[name] = '';
}
});
return _jsonBody;
};
const transformOpenapiRequestItem = (request) => {
let _operationObject = request.operationObject;
let operationName = _operationObject.summary || _operationObject.operationId || _operationObject.description;
if (!operationName) {
operationName = `${request.method} ${request.path}`;
}
// replace OpenAPI links in path by Bruno variables
let path = request.path.replace(/{([a-zA-Z]+)}/g, `{{${_operationObject.operationId}_$1}}`);
const brunoRequestItem = {
uid: uuid(),
name: operationName,
type: 'http-request',
request: {
url: ensureUrl(request.global.server + path),
method: request.method.toUpperCase(),
auth: {
mode: 'none',
basic: null,
bearer: null,
digest: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
script: {
res: null
}
}
};
each(_operationObject.parameters || [], (param) => {
if (param.in === 'query') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required
});
}
});
let auth;
// allow operation override
if (_operationObject.security && _operationObject.security.length > 0) {
let schemeName = Object.keys(_operationObject.security[0])[0];
auth = request.global.security.getScheme(schemeName);
} else if (request.global.security.supported.length > 0) {
auth = request.global.security.supported[0];
}
if (auth) {
if (auth.type === 'http' && auth.scheme === 'basic') {
brunoRequestItem.request.auth.mode = 'basic';
brunoRequestItem.request.auth.basic = {
username: '{{username}}',
password: '{{password}}'
};
} else if (auth.type === 'http' && auth.scheme === 'bearer') {
brunoRequestItem.request.auth.mode = 'bearer';
brunoRequestItem.request.auth.bearer = {
token: '{{token}}'
};
} else if (auth.type === 'apiKey' && auth.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: auth.name,
value: '{{apiKey}}',
description: 'Authentication header',
enabled: true
});
}
}
// TODO: handle allOf/anyOf/oneOf
if (_operationObject.requestBody) {
let content = get(_operationObject, 'requestBody.content', {});
let mimeType = Object.keys(content)[0];
let body = content[mimeType] || {};
let bodySchema = body.schema;
if (mimeType === 'application/json') {
brunoRequestItem.request.body.mode = 'json';
if (bodySchema && bodySchema.type === 'object') {
let _jsonBody = buildEmptyJsonBody(bodySchema);
brunoRequestItem.request.body.json = JSON.stringify(_jsonBody, null, 2);
}
if (bodySchema && bodySchema.type === 'array') {
brunoRequestItem.request.body.json = JSON.stringify([buildEmptyJsonBody(bodySchema.items)], null, 2);
}
} else if (mimeType === 'application/x-www-form-urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
if (bodySchema && bodySchema.type === 'object') {
each(bodySchema.properties || {}, (prop, name) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: name,
value: '',
description: prop.description || '',
enabled: true
});
});
}
} else if (mimeType === 'multipart/form-data') {
brunoRequestItem.request.body.mode = 'multipartForm';
if (bodySchema && bodySchema.type === 'object') {
each(bodySchema.properties || {}, (prop, name) => {
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: 'text',
name: name,
value: '',
description: prop.description || '',
enabled: true
});
});
}
} else if (mimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = '';
} else if (mimeType === 'text/xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = '';
}
}
// build the extraction scripts from responses that have links
// https://swagger.io/docs/specification/links/
let script = [];
each(_operationObject.responses || [], (response, responseStatus) => {
if (Object.hasOwn(response, 'links')) {
// only extract if the status code matches the response
script.push(`if (res.status === ${responseStatus}) {`);
each(response.links, (link) => {
each(link.parameters || [], (expression, parameter) => {
let value = openAPIRuntimeExpressionToScript(expression);
script.push(` bru.setVar('${link.operationId}_${parameter}', ${value});`);
});
});
script.push(`}`);
}
});
if (script.length > 0) {
brunoRequestItem.request.script.res = script.join('\n');
}
return brunoRequestItem;
};
const resolveRefs = (spec, components = spec?.components, cache = new Map()) => {
if (!spec || typeof spec !== 'object') {
return spec;
}
if (cache.has(spec)) {
return cache.get(spec);
}
if (Array.isArray(spec)) {
return spec.map(item => resolveRefs(item, components, cache));
}
if ('$ref' in spec) {
const refPath = spec.$ref;
if (cache.has(refPath)) {
return cache.get(refPath);
}
if (refPath.startsWith('#/components/')) {
const refKeys = refPath.replace('#/components/', '').split('/');
let ref = components;
for (const key of refKeys) {
if (ref && ref[key]) {
ref = ref[key];
} else {
return spec;
}
}
cache.set(refPath, {});
const resolved = resolveRefs(ref, components, cache);
cache.set(refPath, resolved);
return resolved;
}
return spec;
}
const resolved = {};
cache.set(spec, resolved);
for (const [key, value] of Object.entries(spec)) {
resolved[key] = resolveRefs(value, components, cache);
}
return resolved;
};
const groupRequestsByTags = (requests) => {
let _groups = {};
let ungrouped = [];
each(requests, (request) => {
let tags = request.operationObject.tags || [];
if (tags.length > 0) {
let tag = tags[0].trim(); // take first tag and trim whitespace
if (tag) {
if (!_groups[tag]) {
_groups[tag] = [];
}
_groups[tag].push(request);
} else {
ungrouped.push(request);
}
} else {
ungrouped.push(request);
}
});
let groups = Object.keys(_groups).map((groupName) => {
return {
name: groupName,
requests: _groups[groupName]
};
});
return [groups, ungrouped];
};
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);
});
}
return url.endsWith('/') ? url.slice(0, -1) : url;
};
const getSecurity = (apiSpec) => {
let defaultSchemes = apiSpec.security || [];
let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
if (Object.keys(securitySchemes) === 0) {
return {
supported: []
};
}
return {
supported: defaultSchemes.map((scheme) => {
var schemeName = Object.keys(scheme)[0];
return securitySchemes[schemeName];
}),
schemes: securitySchemes,
getScheme: (schemeName) => {
return securitySchemes[schemeName];
}
};
};
const openAPIRuntimeExpressionToScript = (expression) => {
// see https://swagger.io/docs/specification/links/#runtime-expressions
if (expression === '$response.body') {
return 'res.body';
} else if (expression.startsWith('$response.body#')) {
let pointer = expression.substring(15);
// could use https://www.npmjs.com/package/json-pointer for better support
return `res.body${pointer.replace('/', '.')}`;
}
return expression;
};
export const parseOpenApiCollection = (data) => {
const brunoCollection = {
name: '',
uid: uuid(),
version: '1',
items: [],
environments: []
};
try {
const collectionData = resolveRefs(data);
if (!collectionData) {
throw new Error('Invalid OpenAPI collection. Failed to resolve refs.');
return;
}
// Currently parsing of openapi spec is "do your best", that is
// allows "invalid" openapi spec
// Assumes v3 if not defined. v2 is not supported yet
if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
throw new Error('Only OpenAPI v3 is supported currently.');
return;
}
// TODO what if info.title not defined?
brunoCollection.name = collectionData.info.title;
let servers = collectionData.servers || [];
// Create environments based on the servers
servers.forEach((server, index) => {
let baseUrl = getDefaultUrl(server);
let environmentName = server.description ? server.description : `Environment ${index + 1}`;
brunoCollection.environments.push({
uid: uuid(),
name: environmentName,
variables: [
{
uid: uuid(),
name: 'baseUrl',
value: baseUrl,
type: 'text',
enabled: true,
secret: false
},
]
});
});
let securityConfig = getSecurity(collectionData);
let allRequests = Object.entries(collectionData.paths)
.map(([path, methods]) => {
return Object.entries(methods)
.filter(([method, op]) => {
return ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(
method.toLowerCase()
);
})
.map(([method, operationObject]) => {
return {
method: method,
path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
operationObject: operationObject,
global: {
server: '{{baseUrl}}',
security: securityConfig
}
};
});
})
.reduce((acc, val) => acc.concat(val), []); // flatten
let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
let brunoFolders = groups.map((group) => {
return {
uid: uuid(),
name: group.name,
type: 'folder',
items: group.requests.map(transformOpenapiRequestItem)
};
});
let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
brunoCollection.items = brunoCollectionItems;
return brunoCollection;
} catch (err) {
console.error(err);
throw new Error('An error occurred while parsing the OpenAPI collection');
}
};
export const openApiToBruno = (openApiSpecification) => {
try {
const collection = parseOpenApiCollection(openApiSpecification);
const transformedCollection = transformItemsInCollection(collection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);
const validatedCollection = validateSchema(hydratedCollection);
return validatedCollection
} catch (err) {
console.error(err);
throw new Error('Import collection failed');
}
};
export default openApiToBruno;