Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: openapi v3 import #578

Merged
merged 3 commits into from
Oct 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';

Expand Down Expand Up @@ -30,6 +31,14 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
};

const handleImportOpenapiCollection = () => {
importOpenapiCollection()
.then((collection) => {
handleSubmit(collection);
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
};

return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<div>
Expand All @@ -42,6 +51,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
<div className="text-link hover:underline cursor-pointer mt-2" onClick={handleImportInsomniaCollection}>
Insomnia Collection
</div>
<div className="text-link hover:underline cursor-pointer mt-2" onClick={handleImportOpenapiCollection}>
OpenAPI Collection
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More precise it is OpenAPI Specification

Copy link
Contributor Author

@bplatta bplatta Oct 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to add v3 here if that is the consensus! The question here is, should there be separate options for v2 and v3 openapi specs in the Import list?

Pros for current approach:

  • unburden user with version selection, as it can be read from the spec. let Bruno just handle it (eventually will support v2 as well)
  • Fewer list options in Import modal, especially as future spec versions are released

Cons:

  • perhaps unclear to the user whether their Openapi spec is supported
  • Less visibly declarative as to version being imported (reliant on error reporting on import validation)

Will defer to decision from community/product owners but thought I'd raise this point first.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's not to ugly maybe we could show which version are supported:
"OpenAPI Specification V3"
and then later on:
"OpenAPI Specification V2 & V3"

That way we could have Bruno handle it and we tell the users early on if they will have success in importing their spec fil.

</div>
</div>
</Modal>
);
Expand Down
358 changes: 358 additions & 0 deletions packages/bruno-app/src/utils/importers/openapi-collection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
import each from 'lodash/each';
import get from 'lodash/get';
import fileDialog from 'file-dialog';
import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';

const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};

const ensureUrl = (url) => {
let protUrl = url.startsWith('http') ? url : `http://${url}`;
// replace any double or triple slashes
return protUrl.replace(/([^:]\/)\/+/g, '$1');
};

const buildEmptyJsonBody = (bodySchema) => {
let _jsonBody = {};
each(bodySchema.properties || {}, (prop, name) => {
if (prop.type === 'object') {
_jsonBody[name] = buildEmptyJsonBody(prop);
// handle arrays
} else if (prop.type === 'array') {
_jsonBody[name] = [];
} else {
_jsonBody[name] = '';
}
});
return _jsonBody;
};

const transformOpenapiRequestItem = (request) => {
let _operationObject = request.operationObject;
const brunoRequestItem = {
uid: uuid(),
name: _operationObject.operationId,
type: 'http-request',
request: {
url: ensureUrl(request.global.server + '/' + request.path),
method: request.method.toUpperCase(),
auth: {
mode: 'none',
basic: null,
bearer: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
}
};

each(_operationObject.parameters || [], (param) => {
if (param.in === 'query') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required
});
} 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) {
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);
}
} 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(),
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 = '';
}
}

return brunoRequestItem;
};

const resolveRefs = (spec, components = spec.components) => {
if (!spec || typeof spec !== 'object') {
return spec;
}

if (Array.isArray(spec)) {
return spec.map((item) => resolveRefs(item, components));
}

if ('$ref' in spec) {
const refPath = spec.$ref;

if (refPath.startsWith('#/components/')) {
// Local reference within components
const refKeys = refPath.replace('#/components/', '').split('/');
let ref = components;

for (const key of refKeys) {
if (ref[key]) {
ref = ref[key];
} else {
// Handle invalid references gracefully?
return spec;
}
}

return resolveRefs(ref, components);
} else {
// Handle external references (not implemented here)
// You would need to fetch the external reference and resolve it.
// Example: Fetch and resolve an external reference from a URL.
}
}

// Recursively resolve references in nested objects
for (const prop in spec) {
spec[prop] = resolveRefs(spec[prop], components);
}

return spec;
};

const groupRequestsByTags = (requests) => {
let _groups = {};
let ungrouped = [];
each(requests, (request) => {
let tags = request.operationObject.tags || [];
if (tags.length > 0) {
let tag = tags[0]; // take first tag
if (!_groups[tag]) {
_groups[tag] = [];
}
_groups[tag].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;
};

const getSecurity = (apiSpec) => {
let supportedSchemes = apiSpec.security || [];
if (supportedSchemes.length === 0) {
return {
supported: []
};
}

let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
if (Object.keys(securitySchemes) === 0) {
return {
supported: []
};
}

return {
supported: supportedSchemes.map((scheme) => {
var schemeName = Object.keys(scheme)[0];
return securitySchemes[schemeName];
}),
schemes: securitySchemes,
getScheme: (schemeName) => {
return securitySchemes[schemeName];
}
};
};

const parseOpenapiCollection = (data) => {
const brunoCollection = {
name: '',
uid: uuid(),
version: '1',
items: [],
environments: []
};

return new Promise((resolve, reject) => {
try {
const collectionData = resolveRefs(JSON.parse(data));
if (!collectionData) {
reject(new BrunoError('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 no supported yet
if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
reject(new BrunoError('Only OpenAPI v3 is supported currently.'));
return;
}

// TODO what if info.title not defined?
brunoCollection.name = collectionData.info.title;
let servers = collectionData.servers || [];
let baseUrl = servers[0] ? getDefaultUrl(servers[0]) : '';
let securityConfig = getSecurity(collectionData);

let allRequests = Object.entries(collectionData.paths)
.map(([path, methods]) => {
return Object.entries(methods).map(([method, operationObject]) => {
return {
method: method,
path: path,
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;
resolve(brunoCollection);
} catch (err) {
console.error(err);
reject(new BrunoError('An error occurred while parsing the OpenAPI collection'));
}
});
};

const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parseOpenapiCollection)
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => resolve(collection))
.catch((err) => {
console.error(err);
reject(new BrunoError('Import collection failed: ' + err.message));
});
});
};

export default importCollection;