-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #578 from bplatta/feature/import-openapi-v3
feat: openapi v3 import
- Loading branch information
Showing
2 changed files
with
370 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
358 changes: 358 additions & 0 deletions
358
packages/bruno-app/src/utils/importers/openapi-collection.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |