Skip to content

Commit 49ea7f3

Browse files
authored
Merge pull request #578 from bplatta/feature/import-openapi-v3
feat: openapi v3 import
2 parents 59c5c24 + 36ee1f5 commit 49ea7f3

File tree

2 files changed

+370
-0
lines changed

2 files changed

+370
-0
lines changed

Diff for: packages/bruno-app/src/components/Sidebar/ImportCollection/index.js

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import importBrunoCollection from 'utils/importers/bruno-collection';
33
import importPostmanCollection from 'utils/importers/postman-collection';
44
import importInsomniaCollection from 'utils/importers/insomnia-collection';
5+
import importOpenapiCollection from 'utils/importers/openapi-collection';
56
import { toastError } from 'utils/common/error';
67
import Modal from 'components/Modal';
78

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

34+
const handleImportOpenapiCollection = () => {
35+
importOpenapiCollection()
36+
.then((collection) => {
37+
handleSubmit(collection);
38+
})
39+
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
40+
};
41+
3342
return (
3443
<Modal size="sm" title="Import Collection" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
3544
<div>
@@ -42,6 +51,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
4251
<div className="text-link hover:underline cursor-pointer mt-2" onClick={handleImportInsomniaCollection}>
4352
Insomnia Collection
4453
</div>
54+
<div className="text-link hover:underline cursor-pointer mt-2" onClick={handleImportOpenapiCollection}>
55+
OpenAPI Collection
56+
</div>
4557
</div>
4658
</Modal>
4759
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import each from 'lodash/each';
2+
import get from 'lodash/get';
3+
import fileDialog from 'file-dialog';
4+
import { uuid } from 'utils/common';
5+
import { BrunoError } from 'utils/common/error';
6+
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
7+
8+
const readFile = (files) => {
9+
return new Promise((resolve, reject) => {
10+
const fileReader = new FileReader();
11+
fileReader.onload = (e) => resolve(e.target.result);
12+
fileReader.onerror = (err) => reject(err);
13+
fileReader.readAsText(files[0]);
14+
});
15+
};
16+
17+
const ensureUrl = (url) => {
18+
let protUrl = url.startsWith('http') ? url : `http://${url}`;
19+
// replace any double or triple slashes
20+
return protUrl.replace(/([^:]\/)\/+/g, '$1');
21+
};
22+
23+
const buildEmptyJsonBody = (bodySchema) => {
24+
let _jsonBody = {};
25+
each(bodySchema.properties || {}, (prop, name) => {
26+
if (prop.type === 'object') {
27+
_jsonBody[name] = buildEmptyJsonBody(prop);
28+
// handle arrays
29+
} else if (prop.type === 'array') {
30+
_jsonBody[name] = [];
31+
} else {
32+
_jsonBody[name] = '';
33+
}
34+
});
35+
return _jsonBody;
36+
};
37+
38+
const transformOpenapiRequestItem = (request) => {
39+
let _operationObject = request.operationObject;
40+
const brunoRequestItem = {
41+
uid: uuid(),
42+
name: _operationObject.operationId,
43+
type: 'http-request',
44+
request: {
45+
url: ensureUrl(request.global.server + '/' + request.path),
46+
method: request.method.toUpperCase(),
47+
auth: {
48+
mode: 'none',
49+
basic: null,
50+
bearer: null
51+
},
52+
headers: [],
53+
params: [],
54+
body: {
55+
mode: 'none',
56+
json: null,
57+
text: null,
58+
xml: null,
59+
formUrlEncoded: [],
60+
multipartForm: []
61+
}
62+
}
63+
};
64+
65+
each(_operationObject.parameters || [], (param) => {
66+
if (param.in === 'query') {
67+
brunoRequestItem.request.params.push({
68+
uid: uuid(),
69+
name: param.name,
70+
value: '',
71+
description: param.description || '',
72+
enabled: param.required
73+
});
74+
} else if (param.in === 'header') {
75+
brunoRequestItem.request.headers.push({
76+
uid: uuid(),
77+
name: param.name,
78+
value: '',
79+
description: param.description || '',
80+
enabled: param.required
81+
});
82+
}
83+
});
84+
85+
let auth;
86+
// allow operation override
87+
if (_operationObject.security) {
88+
let schemeName = Object.keys(_operationObject.security[0])[0];
89+
auth = request.global.security.getScheme(schemeName);
90+
} else if (request.global.security.supported.length > 0) {
91+
auth = request.global.security.supported[0];
92+
}
93+
94+
if (auth) {
95+
if (auth.type === 'http' && auth.scheme === 'basic') {
96+
brunoRequestItem.request.auth.mode = 'basic';
97+
brunoRequestItem.request.auth.basic = {
98+
username: '{{username}}',
99+
password: '{{password}}'
100+
};
101+
} else if (auth.type === 'http' && auth.scheme === 'bearer') {
102+
brunoRequestItem.request.auth.mode = 'bearer';
103+
brunoRequestItem.request.auth.bearer = {
104+
token: '{{token}}'
105+
};
106+
} else if (auth.type === 'apiKey' && auth.in === 'header') {
107+
brunoRequestItem.request.headers.push({
108+
uid: uuid(),
109+
name: auth.name,
110+
value: '{{apiKey}}',
111+
description: 'Authentication header',
112+
enabled: true
113+
});
114+
}
115+
}
116+
117+
// TODO: handle allOf/anyOf/oneOf
118+
if (_operationObject.requestBody) {
119+
let content = get(_operationObject, 'requestBody.content', {});
120+
let mimeType = Object.keys(content)[0];
121+
let body = content[mimeType] || {};
122+
let bodySchema = body.schema;
123+
if (mimeType === 'application/json') {
124+
brunoRequestItem.request.body.mode = 'json';
125+
if (bodySchema && bodySchema.type === 'object') {
126+
let _jsonBody = buildEmptyJsonBody(bodySchema);
127+
brunoRequestItem.request.body.json = JSON.stringify(_jsonBody, null, 2);
128+
}
129+
} else if (mimeType === 'application/x-www-form-urlencoded') {
130+
brunoRequestItem.request.body.mode = 'formUrlEncoded';
131+
if (bodySchema && bodySchema.type === 'object') {
132+
each(bodySchema.properties || {}, (prop, name) => {
133+
brunoRequestItem.request.body.formUrlEncoded.push({
134+
uid: uuid(),
135+
name: name,
136+
value: '',
137+
description: prop.description || '',
138+
enabled: true
139+
});
140+
});
141+
}
142+
} else if (mimeType === 'multipart/form-data') {
143+
brunoRequestItem.request.body.mode = 'multipartForm';
144+
if (bodySchema && bodySchema.type === 'object') {
145+
each(bodySchema.properties || {}, (prop, name) => {
146+
brunoRequestItem.request.body.multipartForm.push({
147+
uid: uuid(),
148+
name: name,
149+
value: '',
150+
description: prop.description || '',
151+
enabled: true
152+
});
153+
});
154+
}
155+
} else if (mimeType === 'text/plain') {
156+
brunoRequestItem.request.body.mode = 'text';
157+
brunoRequestItem.request.body.text = '';
158+
} else if (mimeType === 'text/xml') {
159+
brunoRequestItem.request.body.mode = 'xml';
160+
brunoRequestItem.request.body.xml = '';
161+
}
162+
}
163+
164+
return brunoRequestItem;
165+
};
166+
167+
const resolveRefs = (spec, components = spec.components) => {
168+
if (!spec || typeof spec !== 'object') {
169+
return spec;
170+
}
171+
172+
if (Array.isArray(spec)) {
173+
return spec.map((item) => resolveRefs(item, components));
174+
}
175+
176+
if ('$ref' in spec) {
177+
const refPath = spec.$ref;
178+
179+
if (refPath.startsWith('#/components/')) {
180+
// Local reference within components
181+
const refKeys = refPath.replace('#/components/', '').split('/');
182+
let ref = components;
183+
184+
for (const key of refKeys) {
185+
if (ref[key]) {
186+
ref = ref[key];
187+
} else {
188+
// Handle invalid references gracefully?
189+
return spec;
190+
}
191+
}
192+
193+
return resolveRefs(ref, components);
194+
} else {
195+
// Handle external references (not implemented here)
196+
// You would need to fetch the external reference and resolve it.
197+
// Example: Fetch and resolve an external reference from a URL.
198+
}
199+
}
200+
201+
// Recursively resolve references in nested objects
202+
for (const prop in spec) {
203+
spec[prop] = resolveRefs(spec[prop], components);
204+
}
205+
206+
return spec;
207+
};
208+
209+
const groupRequestsByTags = (requests) => {
210+
let _groups = {};
211+
let ungrouped = [];
212+
each(requests, (request) => {
213+
let tags = request.operationObject.tags || [];
214+
if (tags.length > 0) {
215+
let tag = tags[0]; // take first tag
216+
if (!_groups[tag]) {
217+
_groups[tag] = [];
218+
}
219+
_groups[tag].push(request);
220+
} else {
221+
ungrouped.push(request);
222+
}
223+
});
224+
225+
let groups = Object.keys(_groups).map((groupName) => {
226+
return {
227+
name: groupName,
228+
requests: _groups[groupName]
229+
};
230+
});
231+
232+
return [groups, ungrouped];
233+
};
234+
235+
const getDefaultUrl = (serverObject) => {
236+
let url = serverObject.url;
237+
if (serverObject.variables) {
238+
each(serverObject.variables, (variable, variableName) => {
239+
let sub = variable.default || (variable.enum ? variable.enum[0] : `{{${variableName}}}`);
240+
url = url.replace(`{${variableName}}`, sub);
241+
});
242+
}
243+
return url;
244+
};
245+
246+
const getSecurity = (apiSpec) => {
247+
let supportedSchemes = apiSpec.security || [];
248+
if (supportedSchemes.length === 0) {
249+
return {
250+
supported: []
251+
};
252+
}
253+
254+
let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
255+
if (Object.keys(securitySchemes) === 0) {
256+
return {
257+
supported: []
258+
};
259+
}
260+
261+
return {
262+
supported: supportedSchemes.map((scheme) => {
263+
var schemeName = Object.keys(scheme)[0];
264+
return securitySchemes[schemeName];
265+
}),
266+
schemes: securitySchemes,
267+
getScheme: (schemeName) => {
268+
return securitySchemes[schemeName];
269+
}
270+
};
271+
};
272+
273+
const parseOpenapiCollection = (data) => {
274+
const brunoCollection = {
275+
name: '',
276+
uid: uuid(),
277+
version: '1',
278+
items: [],
279+
environments: []
280+
};
281+
282+
return new Promise((resolve, reject) => {
283+
try {
284+
const collectionData = resolveRefs(JSON.parse(data));
285+
if (!collectionData) {
286+
reject(new BrunoError('Invalid OpenAPI collection. Failed to resolve refs.'));
287+
return;
288+
}
289+
290+
// Currently parsing of openapi spec is "do your best", that is
291+
// allows "invalid" openapi spec
292+
293+
// assumes v3 if not defined. v2 no supported yet
294+
if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
295+
reject(new BrunoError('Only OpenAPI v3 is supported currently.'));
296+
return;
297+
}
298+
299+
// TODO what if info.title not defined?
300+
brunoCollection.name = collectionData.info.title;
301+
let servers = collectionData.servers || [];
302+
let baseUrl = servers[0] ? getDefaultUrl(servers[0]) : '';
303+
let securityConfig = getSecurity(collectionData);
304+
305+
let allRequests = Object.entries(collectionData.paths)
306+
.map(([path, methods]) => {
307+
return Object.entries(methods).map(([method, operationObject]) => {
308+
return {
309+
method: method,
310+
path: path,
311+
operationObject: operationObject,
312+
global: {
313+
server: baseUrl,
314+
security: securityConfig
315+
}
316+
};
317+
});
318+
})
319+
.reduce((acc, val) => acc.concat(val), []); // flatten
320+
321+
let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
322+
let brunoFolders = groups.map((group) => {
323+
return {
324+
uid: uuid(),
325+
name: group.name,
326+
type: 'folder',
327+
items: group.requests.map(transformOpenapiRequestItem)
328+
};
329+
});
330+
331+
let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
332+
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
333+
brunoCollection.items = brunoCollectionItems;
334+
resolve(brunoCollection);
335+
} catch (err) {
336+
console.error(err);
337+
reject(new BrunoError('An error occurred while parsing the OpenAPI collection'));
338+
}
339+
});
340+
};
341+
342+
const importCollection = () => {
343+
return new Promise((resolve, reject) => {
344+
fileDialog({ accept: 'application/json' })
345+
.then(readFile)
346+
.then(parseOpenapiCollection)
347+
.then(transformItemsInCollection)
348+
.then(hydrateSeqInCollection)
349+
.then(validateSchema)
350+
.then((collection) => resolve(collection))
351+
.catch((err) => {
352+
console.error(err);
353+
reject(new BrunoError('Import collection failed: ' + err.message));
354+
});
355+
});
356+
};
357+
358+
export default importCollection;

0 commit comments

Comments
 (0)