-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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; |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
Cons:
Will defer to decision from community/product owners but thought I'd raise this point first.
There was a problem hiding this comment.
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.