Compare commits

..

28 Commits

Author SHA1 Message Date
lohxt1
3d0dd60f56 added build step for converters package in the tests' gh workflow script 2025-04-08 20:38:38 +05:30
lohxt1
9bb9a914ac postman-to-bruno converter package fixes 2025-04-08 20:38:38 +05:30
lohxt1
44cef9999c clear stored token when refresh call returns an error 2025-04-08 18:49:04 +05:30
lohxt1
3a792a021c oauth2 refresh token under request pane creates dup network logs 2025-04-08 18:49:04 +05:30
lohit
2e5c63cfb9 improve network error handling, oauth2 logic cleanup, tls settings, and ui/test updates (#4444)
~ axios error interceptor fixes and timeline network logs ui updates
~ axios instance error interceptor now returns promise rejects instead of plain objects
~ fixed digest_auth regression
~ removed the interceptor logic for the oauth2 token url calls
~ timeline network logs ui updates
~ updated oauth2 test collections

* ssl/tls fixes and error handling
~ set the min allowed tls version to 1.0 (TLSv1)
~ proxy/certs/tls setup error handling

* enhance JSON stringification with circular reference handling
- Add getCircularReplacer to safely handle circular references in objects
- Update safeStringifyJSON to support indentation and handle undefined values
~ we currently support digest auth for bruno-cli

---------

Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-04-07 23:03:49 +05:30
Thim
9845363349 Feat: Standalone Package to convert to Bruno collection(Part 2)
This contains the bulk of the changes apart from renaming files.
This is a continuation of #2341.

Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: pooja-bruno <pooja@usebruno.com>
2025-04-07 22:24:57 +05:30
Thim
1a6fa7a799 Feat: Standalone Package to convert to Bruno collection(Part 1)
This commit just moves the required files to the new destination
using `git mv` to force Git to recognise it as `Renamed`.
This is a continuation of #2341.

Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: pooja-bruno <pooja@usebruno.com>
Co-authored-by: ramki-bruno <ramki@usebruno.com>
2025-04-07 22:24:57 +05:30
lohxt1
6cd44662a8 removed the dup refresh token checkbox field 2025-04-07 19:14:34 +05:30
lohxt1
9daf418886 pass global env vars to the fetch and refresh oauth2 requests 2025-04-07 19:14:34 +05:30
therealrinku
37ee13353d fix: user agent header 2025-04-07 17:40:07 +05:30
Daniel Roberto
8439e8871f fix: Oauth2 toast typo 2025-04-07 13:52:25 +05:30
ramki-bruno
4c1d3b4f3a Added Playwright-codegen setup 2025-04-04 20:19:26 +05:30
S.M.TALHA
cd3c66cb14 Fix: Matching Brackets pair not highlighting (#4440)
Co-authored-by: smtalha682 <smtalha682@gmail.com>
2025-04-04 20:17:55 +05:30
sreelakshmi-bruno
265b0114e4 Updating issue template for github to track regression bugs (#4437)
---------
Co-authored-by: Sreelakshmi Jayarajan <sreelakshmi@Sreelakshmis-MacBook-Air.local>
2025-04-04 20:11:56 +05:30
ganesh-bruno
17a63d599d capitalize custom and default to follow same theme 2025-04-04 12:59:52 +05:30
ganesh-bruno
d9e87fcd82 updated readme file 2025-04-04 12:59:22 +05:30
Harry.Tao
78c4cb11eb fix unsupport symbolic link folders 2025-04-03 17:18:13 +05:30
tlaloc911
6feea75e45 fix console error Invalid DOM property stroke-width
Invalid DOM property `stroke-width`. Did you mean `strokeWidth`? Error Component Stack
2025-04-03 17:16:09 +05:30
ganesh
2d1f7d0f33 Update contributing.md with contribution guidelines and setup instructions (#4377)
---------
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-04-03 14:50:57 +05:30
Anoop M D
841facc853 chore: fixed indentation 2025-04-03 12:47:39 +05:30
Pragadesh-45
0e60bd3da7 fix: handle empty script.exec cases in postman collection importer
Updated the `importScriptsFromEvents` and `importPostmanV2CollectionItem` functions to properly handle cases where `event.script.exec` is an empty array. Now, if `exec` is empty, `requestObject.script.req` and `requestObject.tests` are set to an empty string instead of being undefined.
2025-04-03 12:47:39 +05:30
sanjai0py
5dc7f1ae2f Refactoring and fixes in _Mock Variables Interpolation_ feature 2025-04-02 14:24:29 +05:30
Raghav Sethi
6862cb4e58 Feature: Mock Variables Interpolation (#3609)
Former title: Feature: adding dynamic variable support (#3609)
2025-04-02 14:24:29 +05:30
Carlos Florêncio
0591530d44 add scripts context to response scripts 2025-04-02 13:22:21 +05:30
sanish-bruno
592679538b Fix: res.setBody fails for Object in Developer-mode
vm2 returns a recursive Proxy for accessing the return value which
cannot be serialized for IPC using `structuredClone`.

Co-authored-by: ramki-bruno <ramki@usebruno.com>
2025-04-02 13:18:58 +05:30
ramki-bruno
9ef2699372 Update default collection name to 'Untitled Collection' 2025-04-02 13:15:41 +05:30
Pragadesh-45
e4c37b916a feat: set default names for folders and requests in Postman collection importer 2025-04-02 13:15:41 +05:30
ramki-bruno
7a8a0ae37e Fix: Remove unwanted transitive devDependencies of electron-store 2025-04-02 13:13:40 +05:30
79 changed files with 4028 additions and 2312 deletions

View File

@@ -27,7 +27,9 @@ body:
required: false
- label: annoying
required: false
- label: this feature was working in a previous version but is broken in the current release.
required: false
- type: input
attributes:
label: Bruno version

View File

@@ -28,6 +28,7 @@ jobs:
npm run build --workspace=packages/bruno-common
npm run build --workspace=packages/bruno-query
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
# tests
- name: Test Package bruno-js
@@ -71,6 +72,7 @@ jobs:
npm run build --workspace=packages/bruno-query
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
- name: Run tests
run: |

View File

@@ -37,15 +37,15 @@ Libraries we use
- Filesystem Watcher - chokidar
- i18n - i18next
### Dependencies
You would need [Node v20.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
> [!IMPORTANT]
> You would need [Node v22.x or the latest LTS version](https://nodejs.org/en/). We use npm workspaces in the project
## Development
Bruno is being developed as a desktop app. You need to load the app by running the Next.js app in one terminal and then run the electron app in another terminal.
### Local Development
## Install Dependencies
```bash
# use nodejs 20 version
@@ -53,7 +53,11 @@ nvm use
# install deps
npm i --legacy-peer-deps
```
### Local Development (Option 1)
```bash
# build packages
npm run build:graphql-docs
npm run build:bruno-query
@@ -62,13 +66,23 @@ npm run build:bruno-common
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
# run next app (terminal 1)
# run react app (terminal 1)
npm run dev:web
# run electron app (terminal 2)
npm run dev:electron
```
### Local Development (Option 2)
```bash
# install dependencies and setup
npm run setup
# run electron and react app concurrently
npm run dev
```
### Troubleshooting
You might encounter a `Unsupported platform` error when you run `npm install`. To fix this, you will need to delete `node_modules` and `package-lock.json` and run `npm install`. This should install all the necessary packages needed to run the app.

171
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"packages/bruno-electron",
"packages/bruno-cli",
"packages/bruno-common",
"packages/bruno-converters",
"packages/bruno-schema",
"packages/bruno-query",
"packages/bruno-js",
@@ -6343,6 +6344,24 @@
}
}
},
"node_modules/@rollup/plugin-alias": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz",
"integrity": "sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs": {
"version": "23.0.7",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-23.0.7.tgz",
@@ -7937,6 +7956,10 @@
"resolved": "packages/bruno-common",
"link": true
},
"node_modules/@usebruno/converters": {
"resolved": "packages/bruno-converters",
"link": true
},
"node_modules/@usebruno/crypto-js": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/@usebruno/crypto-js/-/crypto-js-3.1.9.tgz",
@@ -16507,9 +16530,9 @@
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz",
"integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.1.tgz",
"integrity": "sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==",
"license": "BSD-2-Clause"
},
"node_modules/json-stringify-safe": {
@@ -26274,11 +26297,136 @@
"typescript": "^4.8.4"
}
},
"packages/bruno-converters": {
"name": "@usebruno/converters",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"nanoid": "3.3.8"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"babel-jest": "^29.7.0",
"rimraf": "^5.0.7",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
}
},
"packages/bruno-converters/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-converters/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-converters/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"packages/bruno-converters/node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"packages/bruno-converters/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-converters/node_modules/rollup": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.2.5.tgz",
"integrity": "sha512-/Ha7HhVVofduy+RKWOQJrxe4Qb3xyZo+chcpYiD8SoQa4AG7llhupUtyfKSSrdBM2mWJjhM8wZwmbY23NmlIYw==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=14.18.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"packages/bruno-electron": {
"name": "bruno",
"version": "2.0.0",
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@faker-js/faker": "^9.5.1",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
@@ -26786,6 +26934,23 @@
}
}
},
"packages/bruno-electron/node_modules/@faker-js/faker": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.1.tgz",
"integrity": "sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==",
"deprecated": "Please update to a newer version",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
},
"packages/bruno-electron/node_modules/@smithy/abort-controller": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.1.tgz",

View File

@@ -6,6 +6,7 @@
"packages/bruno-electron",
"packages/bruno-cli",
"packages/bruno-common",
"packages/bruno-converters",
"packages/bruno-schema",
"packages/bruno-query",
"packages/bruno-js",
@@ -38,6 +39,7 @@
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-converters": "npm run build --workspace=packages/bruno-converters",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
"build:electron": "node ./scripts/build-electron.js",
@@ -54,6 +56,11 @@
"prepare": "husky install"
},
"overrides": {
"rollup": "3.29.5"
"rollup": "3.29.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"
}
}
}
}

View File

@@ -102,6 +102,13 @@ const StyledWrapper = styled.div`
.cm-s-default span.cm-variable {
color: #397d13 !important;
}
//matching bracket fix
.CodeMirror-matchingbracket {
background: #5cc0b48c !important;
text-decoration:unset;
}
`;
export default StyledWrapper;

View File

@@ -35,6 +35,7 @@ if (!SERVER_RENDERED) {
'res.getHeader(name)',
'res.getHeaders()',
'res.getBody()',
'res.setBody(data)',
'res.getResponseTime()',
'req',
'req.url',

View File

@@ -8,7 +8,7 @@ const DotIcon = ({ width }) => {
className='inline-block'
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" stroke-width="0" fill="currentColor" />
<path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" strokeWidth="0" fill="currentColor" />
</svg>
);
};

View File

@@ -125,7 +125,7 @@ const General = ({ close }) => {
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="customCaCertificateEnabled">
Use custom CA Certificate
Use Custom CA Certificate
</label>
</div>
{formik.values.customCaCertificate.filePath ? (
@@ -183,7 +183,7 @@ const General = ({ close }) => {
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
htmlFor="keepDefaultCaCertificatesEnabled"
>
Keep default CA Certificates
Keep Default CA Certificates
</label>
</div>
<div className="flex items-center mt-2">

View File

@@ -239,17 +239,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
</div>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Auto-refresh token</label>
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />

View File

@@ -35,14 +35,14 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
toast.success('token fetched successfully!');
}
else {
toast.error('An error occured while fetching token!');
toast.error('An error occurred while fetching token!');
}
}
catch (error) {
console.error('could not fetch the token!');
console.error(error);
toggleFetchingToken(false);
toast.error('An error occured while fetching token!');
toast.error('An error occurred while fetching token!');
}
}
@@ -58,13 +58,13 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
toast.success('token refreshed successfully!');
}
else {
toast.error('An error occured while refreshing token!');
toast.error('An error occurred while refreshing token!');
}
}
catch(error) {
console.error(error);
toggleRefreshingToken(false);
toast.error('An error occured while refreshing token!');
toast.error('An error occurred while refreshing token!');
}
};

View File

@@ -242,17 +242,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
</div>
</div>
<div className="flex items-center gap-4 w-full mb-4">
<label className="block min-w-[140px]">Auto-refresh token</label>
<input
type="checkbox"
className="cursor-pointer w-4 h-4 accent-indigo-600"
checked={get(request, 'auth.oauth2.autoRefreshToken', false)}
onChange={(e) => handleChange('autoRefreshToken', e.target.checked)}
/>
<span className="text-xs text-gray-500">Automatically refresh the token when it expires</span>
</div>
<div className="flex items-center gap-2.5 mt-4">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />

View File

@@ -2,9 +2,17 @@ const Network = ({ logs }) => {
return (
<div className="bg-black/5 text-white network-logs rounded overflow-auto h-96">
<pre className="whitespace-pre-wrap">
{logs.map((entry, index) => (
<NetworkLogsEntry key={index} entry={entry} />
))}
{logs.map((currentLog, index) => {
if (index > 0 && currentLog?.type === 'separator') {
return <div className="border-t-2 border-gray-500 w-full my-2" key={index} />;
}
const nextLog = logs[index + 1];
const isSameLogType = nextLog?.type === currentLog?.type;
return <>
<NetworkLogsEntry key={index} entry={currentLog} />
{!isSameLogType && <div className="mt-4"/>}
</>;
})}
</pre>
</div>
)

View File

@@ -141,7 +141,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
)}
{focusedTab?.responsePaneTab === "timeline" ? (
<ClearTimeline item={item} collection={collection} />
) : item?.response ? (
) : (item?.response && !item?.response?.error) ? (
<>
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />

View File

@@ -1,7 +1,6 @@
import React from 'react';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import { toastError } from 'utils/common/error';
import cloneDeep from 'lodash/cloneDeep';
import Modal from 'components/Modal';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
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';
@@ -7,14 +7,6 @@ import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [options, setOptions] = useState({
enablePostmanTranslations: {
enabled: true,
label: 'Auto translate postman scripts',
subLabel:
"When enabled, Bruno will try as best to translate the scripts from the imported collection to Bruno's format."
}
});
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
@@ -24,9 +16,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
};
const handleImportPostmanCollection = () => {
importPostmanCollection(options)
.then(({ collection, translationLog }) => {
handleSubmit({ collection, translationLog });
importPostmanCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
@@ -46,15 +38,6 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
};
const toggleOptions = (event, optionKey) => {
setOptions({
...options,
[optionKey]: {
...options[optionKey],
enabled: !options[optionKey].enabled
}
});
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
@@ -77,31 +60,6 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
</div>
<div className="flex justify-start w-full mt-4 max-w-[450px]">
{Object.entries(options || {}).map(([key, option]) => (
<div key={key} className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id="comments"
aria-describedby="comments-description"
name="comments"
type="checkbox"
checked={option.enabled}
onChange={(e) => toggleOptions(e, key)}
className="h-3.5 w-3.5 rounded border-zinc-300 dark:ring-offset-zinc-800 bg-transparent text-indigo-600 dark:text-indigo-500 focus:ring-indigo-600 dark:focus:ring-indigo-500"
/>
</div>
<div className="ml-2 text-sm leading-6">
<label htmlFor="comments" className="font-medium text-gray-900 dark:text-zinc-50">
{option.label}
</label>
<p id="comments-description" className="text-zinc-500 text-xs dark:text-zinc-400">
{option.subLabel}
</p>
</div>
</div>
))}
</div>
</div>
</Modal>
);

View File

@@ -4,105 +4,8 @@ import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import { IconAlertTriangle, IconArrowRight, IconCaretDown, IconCaretRight, IconCopy } from '@tabler/icons';
import toast from 'react-hot-toast';
const TranslationLog = ({ translationLog }) => {
const [showDetails, setShowDetails] = useState(false);
const preventSetShowDetails = (e) => {
e.stopPropagation();
e.preventDefault();
setShowDetails(!showDetails);
};
const copyClipboard = (e, value) => {
e.stopPropagation();
e.preventDefault();
navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
};
return (
<div className="flex flex-col mt-2">
<div className="border-l-2 border-amber-500 dark:border-amber-300 bg-amber-50 dark:bg-amber-50/10 p-1.5 rounded-r">
<div className="flex items-center">
<div className="flex-shrink-0">
<IconAlertTriangle className="h-4 w-4 text-amber-500 dark:text-amber-300" aria-hidden="true" />
</div>
<div className="ml-2">
<p className="text-xs text-amber-700 dark:text-amber-300">
<span className="font-semibold">Warning:</span> Some commands were not translated.{' '}
</p>
</div>
</div>
</div>
<button
onClick={(e) => preventSetShowDetails(e)}
className="flex w-fit items-center rounded px-2.5 py-1 mt-2 text-xs font-semibold ring-1 ring-inset bg-slate-50 dark:bg-slate-400/10 text-slate-700 dark:text-slate-300 ring-slate-600/10 dark:ring-slate-400/20"
>
See details
{showDetails ? <IconCaretDown size={16} className="ml-1" /> : <IconCaretRight size={16} className="ml-1" />}
</button>
{showDetails && (
<div className="flex relative flex-col text-xs max-w-[364px] max-h-[300px] overflow-scroll mt-2 p-2 bg-slate-50 dark:bg-slate-400/10 ring-1 ring-inset rounded text-slate-700 dark:text-slate-300 ring-slate-600/20 dark:ring-slate-400/20">
<span className="font-semibold flex items-center">
Impacted Collections: {Object.keys(translationLog || {}).length}
</span>
<span className="font-semibold flex items-center">
Impacted Lines:{' '}
{Object.values(translationLog || {}).reduce(
(acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
0
)}
</span>
<span className="my-1">
The numbers after 'script' and 'test' indicate the line numbers of incomplete translations.
</span>
<ul>
{Object.entries(translationLog || {}).map(([name, value]) => (
<li key={name} className="list-none text-xs font-semibold">
<div className="font-semibold flex items-center text-xs whitespace-nowrap">
<IconCaretRight className="min-w-4 max-w-4 -ml-1" />
{name}
</div>
<div className="flex flex-col">
{value.script && (
<div className="flex items-center text-xs font-light mb-1 flex-wrap">
<span className="mr-2">script :</span>
{value.script.map((scriptValue, index) => (
<span className="flex items-center" key={`script_${name}_${index}`}>
<span className="text-xs font-light">{scriptValue}</span>
{index < value.script.length - 1 && <> - </>}
</span>
))}
</div>
)}
{value.test && (
<div className="flex items-center text-xs font-light mb-1 flex-wrap">
<span className="mr-2">test :</span>
{value.test.map((testValue, index) => (
<div className="flex items-center" key={`test_${name}_${index}`}>
<span className="text-xs font-light">{testValue}</span>
{index < value.test.length - 1 && <> - </>}
</div>
))}
</div>
)}
</div>
</li>
))}
</ul>
<button
className="absolute top-1 right-1 flex w-fit items-center rounded p-2 text-xs font-semibold ring-1 ring-inset bg-slate-50 dark:bg-slate-400/10 text-slate-700 dark:text-slate-300 ring-slate-600/10 dark:ring-slate-400/20"
onClick={(e) => copyClipboard(e, JSON.stringify(translationLog))}
>
<IconCopy size={16} />
</button>
</div>
)}
</div>
);
};
const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, translationLog }) => {
const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => {
const inputRef = useRef();
const dispatch = useDispatch();
@@ -150,9 +53,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
Name
</label>
<div className="mt-2">{collectionName}</div>
{translationLog && Object.keys(translationLog).length > 0 && (
<TranslationLog translationLog={translationLog} />
)}
<>
<label htmlFor="collectionLocation" className="block font-semibold mt-3">
Location

View File

@@ -14,18 +14,14 @@ import StyledWrapper from './StyledWrapper';
const TitleBar = () => {
const [importedCollection, setImportedCollection] = useState(null);
const [importedTranslationLog, setImportedTranslationLog] = useState({});
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const dispatch = useDispatch();
const { ipcRenderer } = window;
const handleImportCollection = ({ collection, translationLog }) => {
const handleImportCollection = ({ collection }) => {
setImportedCollection(collection);
if (translationLog) {
setImportedTranslationLog(translationLog);
}
setImportCollectionModalOpen(false);
setImportCollectionLocationModalOpen(true);
};
@@ -75,7 +71,6 @@ const TitleBar = () => {
{importCollectionLocationModalOpen ? (
<ImportCollectionLocation
collectionName={importedCollection.name}
translationLog={importedTranslationLog}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>

View File

@@ -15,7 +15,6 @@ const Welcome = () => {
const dispatch = useDispatch();
const { t } = useTranslation();
const [importedCollection, setImportedCollection] = useState(null);
const [importedTranslationLog, setImportedTranslationLog] = useState({});
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
@@ -24,11 +23,8 @@ const Welcome = () => {
dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
};
const handleImportCollection = ({ collection, translationLog }) => {
const handleImportCollection = ({ collection }) => {
setImportedCollection(collection);
if (translationLog) {
setImportedTranslationLog(translationLog);
}
setImportCollectionModalOpen(false);
setImportCollectionLocationModalOpen(true);
};
@@ -55,7 +51,6 @@ const Welcome = () => {
) : null}
{importCollectionLocationModalOpen ? (
<ImportCollectionLocation
translationLog={importedTranslationLog}
collectionName={importedCollection.name}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}

View File

@@ -1272,6 +1272,10 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
export const fetchOauth2Credentials = (payload) => async (dispatch, getState) => {
const { request, collection, itemUid, folderUid } = payload;
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
request.globalEnvironmentVariables = globalEnvironmentVariables;
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:fetch-oauth2-credentials', { itemUid, request, collection })
@@ -1295,6 +1299,10 @@ export const fetchOauth2Credentials = (payload) => async (dispatch, getState) =>
export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => {
const { request, collection, folderUid, itemUid } = payload;
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
request.globalEnvironmentVariables = globalEnvironmentVariables;
return new Promise((resolve, reject) => {
window.ipcRenderer
.invoke('renderer:refresh-oauth2-credentials', { request, collection })

View File

@@ -1,340 +1,10 @@
import map from 'lodash/map';
import * as FileSaver from 'file-saver';
import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from '../collections/export';
/**
* Transforms a given URL string into an object representing the protocol, host, path, query, and variables.
*
* @param {string} url - The raw URL to be transformed.
* @param {Object} params - The params object.
* @returns {Object|null} An object containing the URL's protocol, host, path, query, and variables, or {} if an error occurs.
*/
export const transformUrl = (url, params) => {
if (typeof url !== 'string' || !url.trim()) {
url = "";
console.error("Invalid URL input:", url);
}
const urlRegexPatterns = {
protocolAndRestSeparator: /:\/\//,
hostAndPathSeparator: /\/(.+)/,
domainSegmentSeparator: /\./,
pathSegmentSeparator: /\//,
queryStringSeparator: /\?/
};
const postmanUrl = { raw: url };
/**
* Splits a URL into its protocol, host and path.
*
* @param {string} url - The URL to be split.
* @returns {Object} An object containing the protocol and the raw host/path string.
*/
const splitUrl = (url) => {
const urlParts = url.split(urlRegexPatterns.protocolAndRestSeparator);
if (urlParts.length === 1) {
return { protocol: '', rawHostAndPath: urlParts[0] };
} else if (urlParts.length === 2) {
const [hostAndPath, _] = urlParts[1].split(urlRegexPatterns.queryStringSeparator);
return { protocol: urlParts[0], rawHostAndPath: hostAndPath };
} else {
throw new Error(`Invalid URL format: ${url}`);
}
};
/**
* Splits the host and path from a raw host/path string.
*
* @param {string} rawHostAndPath - The raw host and path string to be split.
* @returns {Object} An object containing the host and path.
*/
const splitHostAndPath = (rawHostAndPath) => {
const [host, path = ''] = rawHostAndPath.split(urlRegexPatterns.hostAndPathSeparator);
return { host, path };
};
try {
const { protocol, rawHostAndPath } = splitUrl(url);
postmanUrl.protocol = protocol;
const { host, path } = splitHostAndPath(rawHostAndPath);
postmanUrl.host = host ? host.split(urlRegexPatterns.domainSegmentSeparator) : [];
postmanUrl.path = path ? path.split(urlRegexPatterns.pathSegmentSeparator) : [];
} catch (error) {
console.error(error.message);
return {};
}
// Construct query params.
postmanUrl.query = params
.filter((param) => param.type === 'query')
.map(({ name, value, description }) => ({ key: name, value, description }));
// Construct path params.
postmanUrl.variable = params
.filter((param) => param.type === 'path')
.map(({ name, value, description }) => ({ key: name, value, description }));
return postmanUrl;
};
/**
* Collapses multiple consecutive slashes (`//`) into a single slash, while skipping the protocol (e.g., `http://` or `https://`).
*
* @param {String} url - A URL string
* @returns {String} The sanitized URL
*
*/
const collapseDuplicateSlashes = (url) => {
return url.replace(/(?<!:)\/{2,}/g, '/');
};
/**
* Replaces all `\\` (backslashes) with `//` (forward slashes) and collapses multiple slashes into one.
*
* @param {string} url - The URL to sanitize.
* @returns {string} The sanitized URL.
*
*/
export const sanitizeUrl = (url) => {
let sanitizedUrl = collapseDuplicateSlashes(url.replace(/\\/g, '//'));
return sanitizedUrl;
};
import brunoConverters from '@usebruno/converters';
const { brunoToPostman } = brunoConverters;
export const exportCollection = (collection) => {
delete collection.uid;
delete collection.processEnvVariables;
deleteUidsInItems(collection.items);
deleteUidsInEnvs(collection.environments);
deleteSecretsInEnvs(collection.environments);
const generateInfoSection = () => {
return {
name: collection.name,
description: collection.root?.docs,
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
};
};
const generateCollectionVars = (collection) => {
const pattern = /{{[^{}]+}}/g;
let listOfVars = [];
const findOccurrences = (obj, results) => {
if (typeof obj === 'object') {
if (Array.isArray(obj)) {
obj.forEach((item) => findOccurrences(item, results));
} else {
for (const key in obj) {
findOccurrences(obj[key], results);
}
}
} else if (typeof obj === 'string') {
obj.replace(pattern, (match) => {
results.push(match.replace(/{{|}}/g, ''));
});
}
};
findOccurrences(collection, listOfVars);
const finalArrayOfVars = [...new Set(listOfVars)];
return finalArrayOfVars.map((variable) => ({
key: variable,
value: '',
type: 'default'
}));
};
const generateEventSection = (item) => {
const eventArray = [];
if (item?.request?.tests?.length) {
eventArray.push({
listen: 'test',
script: {
exec: item.request.tests.split('\n')
// type: 'text/javascript'
}
});
}
if (item?.request?.script?.req) {
eventArray.push({
listen: 'prerequest',
script: {
exec: item.request.script.req.split('\n')
// type: 'text/javascript'
}
});
}
return eventArray;
};
const generateHeaders = (headersArray) => {
return map(headersArray, (item) => {
return {
key: item.name,
value: item.value,
disabled: !item.enabled,
type: 'default'
};
});
};
const generateBody = (body) => {
switch (body.mode) {
case 'formUrlEncoded':
return {
mode: 'urlencoded',
urlencoded: map(body.formUrlEncoded, (bodyItem) => {
return {
key: bodyItem.name,
value: bodyItem.value,
disabled: !bodyItem.enabled,
type: 'default'
};
})
};
case 'multipartForm':
return {
mode: 'formdata',
formdata: map(body.multipartForm, (bodyItem) => {
return {
key: bodyItem.name,
value: bodyItem.value,
disabled: !bodyItem.enabled,
type: 'default'
};
})
};
case 'json':
return {
mode: 'raw',
raw: body.json,
options: {
raw: {
language: 'json'
}
}
};
case 'xml':
return {
mode: 'raw',
raw: body.xml,
options: {
raw: {
language: 'xml'
}
}
};
case 'text':
return {
mode: 'raw',
raw: body.text,
options: {
raw: {
language: 'text'
}
}
};
case 'graphql':
return {
mode: 'graphql',
graphql: body.graphql
};
}
};
const generateAuth = (itemAuth) => {
switch (itemAuth?.mode) {
case 'bearer':
return {
type: 'bearer',
bearer: {
key: 'token',
value: itemAuth.bearer.token,
type: 'string'
}
};
case 'basic': {
return {
type: 'basic',
basic: [
{
key: 'password',
value: itemAuth.basic.password,
type: 'string'
},
{
key: 'username',
value: itemAuth.basic.username,
type: 'string'
}
]
};
}
case 'apikey': {
return {
type: 'apikey',
apikey: [
{
key: 'key',
value: itemAuth.apikey.key,
type: 'string'
},
{
key: 'value',
value: itemAuth.apikey.value,
type: 'string'
}
]
};
}
default: {
return {
type: 'noauth'
};
}
}
};
const generateRequestSection = (itemRequest) => {
const requestObject = {
method: itemRequest.method,
header: generateHeaders(itemRequest.headers),
auth: generateAuth(itemRequest.auth),
description: itemRequest.docs,
// We sanitize the URL to make sure it's in the right format before passing it to the transformUrl func. This means changing backslashes to forward slashes and reducing multiple slashes to a single one, except in the protocol part.
url: transformUrl(sanitizeUrl(itemRequest.url), itemRequest.params)
};
if (itemRequest.body.mode !== 'none') {
requestObject.body = generateBody(itemRequest.body);
}
return requestObject;
};
const generateItemSection = (itemsArray) => {
return map(itemsArray, (item) => {
if (item.type === 'folder') {
return {
name: item.name,
item: item.items.length ? generateItemSection(item.items) : []
};
} else {
return {
name: item.name,
event: generateEventSection(item),
request: generateRequestSection(item.request)
};
}
});
};
const collectionToExport = {};
collectionToExport.info = generateInfoSection();
collectionToExport.item = generateItemSection(collection.items);
collectionToExport.variable = generateCollectionVars(collection);
const collectionToExport = brunoToPostman(collection);
const fileName = `${collection.name}.json`;
const fileBlob = new Blob([JSON.stringify(collectionToExport, null, 2)], { type: 'application/json' });

View File

@@ -2,7 +2,7 @@ import each from 'lodash/each';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { uuid, normalizeFileName } from 'utils/common';
import { uuid } from 'utils/common';
import { isItemARequest } from 'utils/collections';
import { collectionSchema } from '@usebruno/schema';
import { BrunoError } from 'utils/common/error';

View File

@@ -1,10 +1,8 @@
import jsyaml from 'js-yaml';
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';
import brunoConverters from '@usebruno/converters';
const { insomniaToBruno } = brunoConverters;
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -30,226 +28,11 @@ const readFile = (files) => {
});
};
const parseGraphQL = (text) => {
try {
const graphql = JSON.parse(text);
return {
query: graphql.query,
variables: JSON.stringify(graphql.variables, null, 2)
};
} catch (e) {
return {
query: '',
variables: ''
};
}
};
const addSuffixToDuplicateName = (item, index, allItems) => {
// Check if the request name already exist and if so add a number suffix
const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
if (otherItem.name === item.name && otherIndex < index) {
nameSuffix++;
}
return nameSuffix;
}, 0);
return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
};
const regexVariable = new RegExp('{{.*?}}', 'g');
const normalizeVariables = (value) => {
value = value || '';
const variables = value.match(regexVariable) || [];
each(variables, (variable) => {
value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));
});
return value;
};
const transformInsomniaRequestItem = (request, index, allRequests) => {
const name = addSuffixToDuplicateName(request, index, allRequests);
const brunoRequestItem = {
uid: uuid(),
name,
type: 'http-request',
request: {
url: request.url,
method: request.method,
auth: {
mode: 'none',
basic: null,
bearer: null,
digest: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
}
};
each(request.headers, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.name,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
each(request.parameters, (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(request.pathParameters, (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: param.value,
description: '',
type: 'path',
enabled: true
});
});
const authType = get(request, 'authentication.type', '');
if (authType === 'basic') {
brunoRequestItem.request.auth.mode = 'basic';
brunoRequestItem.request.auth.basic = {
username: normalizeVariables(get(request, 'authentication.username', '')),
password: normalizeVariables(get(request, 'authentication.password', ''))
};
} else if (authType === 'bearer') {
brunoRequestItem.request.auth.mode = 'bearer';
brunoRequestItem.request.auth.bearer = {
token: normalizeVariables(get(request, 'authentication.token', ''))
};
}
const mimeType = get(request, 'body.mimeType', '').split(';')[0];
if (mimeType === 'application/json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = request.body.text;
} else if (mimeType === 'application/x-www-form-urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(request.body.params, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
} else if (mimeType === 'multipart/form-data') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(request.body.params, (param) => {
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: 'text',
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
} else if (mimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = request.body.text;
} else if (mimeType === 'text/xml' || mimeType === 'application/xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = request.body.text;
} else if (mimeType === 'application/graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
}
return brunoRequestItem;
};
const parseInsomniaCollection = (data) => {
const brunoCollection = {
name: '',
uid: uuid(),
version: '1',
items: [],
environments: []
};
return new Promise((resolve, reject) => {
try {
const insomniaExport = data;
const insomniaResources = get(insomniaExport, 'resources', []);
const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
if (!insomniaCollection) {
reject(new BrunoError('Collection not found inside Insomnia export'));
}
brunoCollection.name = insomniaCollection.name;
const requestsAndFolders =
insomniaResources.filter((resource) => resource._type === 'request' || resource._type === 'request_group') ||
[];
function createFolderStructure(resources, parentId = null) {
const requestGroups =
resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
const folders = requestGroups.map((folder, index, allFolder) => {
const name = addSuffixToDuplicateName(folder, index, allFolder);
const requests = resources.filter(
(resource) => resource._type === 'request' && resource.parentId === folder._id
);
return {
uid: uuid(),
name,
type: 'folder',
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
};
});
return folders.concat(requests.map(transformInsomniaRequestItem));
}
(brunoCollection.items = createFolderStructure(requestsAndFolders, insomniaCollection._id)),
resolve(brunoCollection);
} catch (err) {
reject(new BrunoError('An error occurred while parsing the Insomnia collection'));
}
});
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
.then(parseInsomniaCollection)
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => insomniaToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);

View File

@@ -1,10 +1,8 @@
import jsyaml from 'js-yaml';
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';
import brunoConverters from '@usebruno/converters';
const { openApiToBruno } = brunoConverters;
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -30,435 +28,11 @@ const readFile = (files) => {
});
};
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: []
};
return new Promise((resolve, reject) => {
try {
const collectionData = resolveRefs(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 is not 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 || [];
// 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;
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: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
.then(parseOpenApiCollection)
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => openApiToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);

View File

@@ -1,67 +0,0 @@
import { parseOpenApiCollection } from './openapi-collection';
import { uuid } from 'utils/common';
jest.mock('utils/common');
describe('openapi importer util functions', () => {
afterEach(jest.clearAllMocks);
it('should convert openapi object to bruno collection correctly', async () => {
const input = {
openapi: '3.0.3',
info: {
title: 'Sample API with Multiple Servers',
description: 'API spec with multiple servers.',
version: '1.0.0'
},
servers: [
{ url: 'https://api.example.com/v1', description: 'Production Server' },
{ url: 'https://staging-api.example.com/v1', description: 'Staging Server' },
{ url: 'http://localhost:3000/v1', description: 'Local Server' }
],
paths: {
'/users': {
get: {
summary: 'Get a list of users',
parameters: [
{ name: 'page', in: 'query', required: false, schema: { type: 'integer' } },
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }
],
responses: {
'200': { description: 'A list of users' }
}
}
}
}
};
const expectedOutput = {
name: 'Sample API with Multiple Servers',
version: '1',
items: [
{
name: 'Get a list of users',
type: 'http-request',
request: {
url: '{{baseUrl}}/users',
method: 'GET',
params: [
{ name: 'page', value: '', enabled: false, type: 'query' },
{ name: 'limit', value: '', enabled: false, type: 'query' }
]
}
}
],
environments: [
{ name: 'Production Server', variables: [{ name: 'baseUrl', value: 'https://api.example.com/v1' }] },
{ name: 'Staging Server', variables: [{ name: 'baseUrl', value: 'https://staging-api.example.com/v1' }] },
{ name: 'Local Server', variables: [{ name: 'baseUrl', value: 'http://localhost:3000/v1' }] }
]
};
const result = await parseOpenApiCollection(input);
expect(result).toMatchObject(expectedOutput);
expect(uuid).toHaveBeenCalledTimes(10);
});
});

View File

@@ -1,650 +1,29 @@
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';
import { postmanTranslation } from 'utils/importers/translators/postman_translation';
import each from 'lodash/each';
import brunoConverters from '@usebruno/converters';
import { safeParseJSON } from 'utils/common/index';
const { postmanToBruno } = brunoConverters;
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onload = (e) => resolve(safeParseJSON(e.target.result));
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const parseGraphQLRequest = (graphqlSource) => {
try {
let queryResultObject = {
query: '',
variables: ''
};
if (typeof graphqlSource === 'string') {
graphqlSource = JSON.parse(graphqlSource);
}
if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') {
queryResultObject.variables = graphqlSource.variables;
}
if (graphqlSource.hasOwnProperty('query') && graphqlSource.query !== '') {
queryResultObject.query = graphqlSource.query;
}
return queryResultObject;
} catch (e) {
return {
query: '',
variables: ''
};
}
};
const isItemAFolder = (item) => {
return !item.request;
};
const convertV21Auth = (array) => {
return array.reduce((accumulator, currentValue) => {
accumulator[currentValue.key] = currentValue.value;
return accumulator;
}, {});
};
const constructUrlFromParts = (url) => {
const { protocol = 'http', host, path, port, query, hash } = url || {};
const hostStr = Array.isArray(host) ? host.filter(Boolean).join('.') : host || '';
const pathStr = Array.isArray(path) ? path.filter(Boolean).join('/') : path || '';
const portStr = port ? `:${port}` : '';
const queryStr =
query && Array.isArray(query) && query.length > 0
? `?${query
.filter((q) => q.key)
.map((q) => `${q.key}=${q.value || ''}`)
.join('&')}`
: '';
const urlStr = `${protocol}://${hostStr}${portStr}${pathStr ? `/${pathStr}` : ''}${queryStr}`;
return urlStr;
};
const constructUrl = (url) => {
if (!url) return '';
if (typeof url === 'string') {
return url;
}
if (typeof url === 'object') {
const { raw } = url;
if (raw && typeof raw === 'string') {
// If the raw URL contains url-fragments remove it
if (raw.includes('#')) {
return raw.split('#')[0]; // Returns the part of raw URL without the url-fragment part.
}
return raw;
}
// If no raw value exists, construct the URL from parts
return constructUrlFromParts(url);
}
return '';
};
let translationLog = {};
/* struct of translation log
{
[collectionName]: {
script: [index1, index2],
test: [index1, index2]
}
}
*/
const pushTranslationLog = (type, index) => {
if (!translationLog[i.name]) {
translationLog[i.name] = {};
}
if (!translationLog[i.name][type]) {
translationLog[i.name][type] = [];
}
translationLog[i.name][type].push(index + 1);
};
const importScriptsFromEvents = (events, requestObject, options, pushTranslationLog) => {
events.forEach((event) => {
if (event.script && event.script.exec) {
if (event.listen === 'prerequest') {
if (!requestObject.script) {
requestObject.script = {};
}
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
requestObject.script.req = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
? postmanTranslation(line, () => pushTranslationLog('script', index))
: `// ${line}`
)
.join('\n');
} else if (typeof event.script.exec === 'string') {
requestObject.script.req = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec, () => pushTranslationLog('script', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test') {
if (!requestObject.tests) {
requestObject.tests = {};
}
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
requestObject.tests = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
? postmanTranslation(line, () => pushTranslationLog('test', index))
: `// ${line}`
)
.join('\n');
} else if (typeof event.script.exec === 'string') {
requestObject.tests = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec, () => pushTranslationLog('test', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
}
});
};
const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.map((v) => ({
uid: uuid(),
name: v.key,
value: v.value,
enabled: true
}));
requestObject.vars.req = vars;
};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
const requestMap = {};
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
each(item, (i) => {
if (isItemAFolder(i)) {
const baseFolderName = i.name;
let folderName = baseFolderName;
let count = 1;
while (folderMap[folderName]) {
folderName = `${baseFolderName}_${count}`;
count++;
}
const brunoFolderItem = {
uid: uuid(),
name: folderName,
type: 'folder',
items: [],
root: {
docs: i.description || '',
meta: {
name: folderName
},
request: {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
if (i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth, options);
}
if (i.event) {
importScriptsFromEvents(i.event, brunoFolderItem.root.request, options, pushTranslationLog);
}
brunoParent.items.push(brunoFolderItem);
folderMap[folderName] = brunoFolderItem;
} else {
if (i.request) {
if(!requestMethods.includes(i?.request?.method.toUpperCase())){
console.warn("Unexpected request.method")
return;
}
const baseRequestName = i.name;
let requestName = baseRequestName;
let count = 1;
while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
}
const url = constructUrl(i.request.url);
const brunoRequestItem = {
uid: uuid(),
name: requestName,
type: 'http-request',
request: {
url: url,
method: i?.request?.method?.toUpperCase(),
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
docs: i.request.description
}
};
if (i.event) {
i.event.forEach((event) => {
if (event.listen === 'prerequest' && event.script && event.script.exec) {
if (!brunoRequestItem.request.script) {
brunoRequestItem.request.script = {};
}
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
brunoRequestItem.request.script.req = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
? postmanTranslation(line, () => pushTranslationLog('script', index))
: `// ${line}`
)
.join('\n');
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.script.req = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec, () => pushTranslationLog('script', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request.tests) {
brunoRequestItem.request.tests = {};
}
if (Array.isArray(event.script.exec) && event.script.exec.length > 0) {
brunoRequestItem.request.tests = event.script.exec
.map((line, index) =>
options.enablePostmanTranslations.enabled
? postmanTranslation(line, () => pushTranslationLog('test', index))
: `// ${line}`
)
.join('\n');
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.tests = options.enablePostmanTranslations.enabled
? postmanTranslation(event.script.exec, () => pushTranslationLog('test', 0))
: `// ${event.script.exec}`;
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
}
const bodyMode = get(i, 'request.body.mode');
if (bodyMode) {
if (bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
const isFile = param.type === 'file';
let value;
let type;
if (isFile) {
// If param.src is an array, keep it as it is.
// If param.src is a string, convert it into an array with a single element.
value = Array.isArray(param.src) ? param.src : typeof param.src === 'string' ? [param.src] : null;
type = 'file';
} else {
value = param.value;
type = 'text';
}
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: type,
name: param.key,
value: value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'raw') {
let language = get(i, 'request.body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(i.request.header);
}
if (language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
if (bodyMode === 'graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(i.request.header, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
const auth = i.request.auth ?? parentAuth;
if (auth?.[auth.type] && auth.type !== 'noauth') {
let authValues = auth[auth.type];
if (Array.isArray(authValues)) {
authValues = convertV21Auth(authValues);
}
if (auth.type === 'basic') {
brunoRequestItem.request.auth.mode = 'basic';
brunoRequestItem.request.auth.basic = {
username: authValues.username,
password: authValues.password
};
} else if (auth.type === 'bearer') {
brunoRequestItem.request.auth.mode = 'bearer';
brunoRequestItem.request.auth.bearer = {
token: authValues.token
};
} else if (auth.type === 'awsv4') {
brunoRequestItem.request.auth.mode = 'awsv4';
brunoRequestItem.request.auth.awsv4 = {
accessKeyId: authValues.accessKey,
secretAccessKey: authValues.secretKey,
sessionToken: authValues.sessionToken,
service: authValues.service,
region: authValues.region,
profileName: ''
};
} else if (auth.type === 'apikey'){
brunoRequestItem.request.auth.mode = 'apikey';
brunoRequestItem.request.auth.apikey = {
key: authValues.key,
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: "header" //By default we are placing the apikey values in headers!
}
} else if (auth.type === 'oauth2'){
const findValueUsingKey = (key) => {
return auth?.oauth2?.find(v => v?.key == key)?.value || ''
}
const oauth2GrantTypeMaps = {
'authorization_code_with_pkce': 'authorization_code',
'authorization_code': 'authorization_code',
'client_credentials': 'client_credentials',
'password_credentials': 'password_credentials'
}
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
if (grantType) {
brunoRequestItem.request.auth.mode = 'oauth2';
switch(grantType) {
case 'authorization_code':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'authorization_code',
authorizationUrl: findValueUsingKey('authUrl'),
callbackUrl: findValueUsingKey('redirect_uri'),
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'password_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'password',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
username: findValueUsingKey('username'),
password: findValueUsingKey('password'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'client_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'client_credentials',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
}
}
}
}
each(get(i, 'request.url.query'), (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
description: param.description ?? '',
type: 'path',
enabled: true
});
});
brunoParent.items.push(brunoRequestItem);
requestMap[requestName] = brunoRequestItem;
}
}
});
};
const searchLanguageByHeader = (headers) => {
let contentType;
each(headers, (header) => {
if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
contentType = 'json';
} else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
contentType = 'xml';
}
return false;
}
});
return contentType;
};
const importPostmanV2Collection = (collection, options) => {
const brunoCollection = {
name: collection.info.name,
uid: uuid(),
version: '1',
items: [],
environments: [],
root: {
docs: collection.info.description || '',
meta: {
name: collection.info.name
},
request: {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
if (collection.event) {
importScriptsFromEvents(collection.event, brunoCollection.root.request, options, pushTranslationLog);
}
if (collection?.variable){
importCollectionLevelVariables(collection.variable, brunoCollection.root.request);
}
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, options);
return brunoCollection;
};
const parsePostmanCollection = (str, options) => {
return new Promise((resolve, reject) => {
try {
let collection = JSON.parse(str);
let schema = get(collection, 'info.schema');
let v2Schemas = [
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
'https://schema.postman.com/json/collection/v2.0.0/collection.json',
'https://schema.postman.com/json/collection/v2.1.0/collection.json'
];
if (v2Schemas.includes(schema)) {
return resolve(importPostmanV2Collection(collection, options));
}
throw new BrunoError('Unknown postman schema');
} catch (err) {
console.log(err);
if (err instanceof BrunoError) {
return reject(err);
}
return reject(new BrunoError('Unable to parse the postman collection json file'));
}
});
};
const logTranslationDetails = (translationLog) => {
if (Object.keys(translationLog || {}).length > 0) {
console.warn(
`[Postman Translation Logs]
Collections incomplete : ${Object.keys(translationLog || {}).length}` +
`\nTotal lines incomplete : ${Object.values(translationLog || {}).reduce(
(acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
0
)}` +
`\nSee details below :`,
translationLog
);
}
};
const importCollection = (options) => {
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then((str) => parsePostmanCollection(str, options))
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => resolve({ collection, translationLog }))
.then((collection) => postmanToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.log(err);
translationLog = {};
reject(new BrunoError('Import collection failed'));
})
.then(() => {
logTranslationDetails(translationLog);
translationLog = {};
});
});
};

View File

@@ -1,6 +1,7 @@
import each from 'lodash/each';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import brunoConverters from '@usebruno/converters';
const { postmanToBrunoEnvironment } = brunoConverters;
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -11,50 +12,6 @@ const readFile = (files) => {
});
};
const isSecret = (type) => {
return type === 'secret';
};
const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
brunoEnvironment.variables = brunoEnvironment.variables || [];
each(values, (i) => {
const brunoEnvironmentVariable = {
name: i.key,
value: i.value,
enabled: i.enabled,
secret: isSecret(i.type)
};
brunoEnvironment.variables.push(brunoEnvironmentVariable);
});
};
const importPostmanEnvironment = (environment) => {
const brunoEnvironment = {
name: environment.name,
variables: []
};
importPostmanEnvironmentVariables(brunoEnvironment, environment.values);
return brunoEnvironment;
};
const parsePostmanEnvironment = (str) => {
return new Promise((resolve, reject) => {
try {
let environment = JSON.parse(str);
return resolve(importPostmanEnvironment(environment));
} catch (err) {
console.log(err);
if (err instanceof BrunoError) {
return reject(err);
}
return reject(new BrunoError('Unable to parse the postman environment json file'));
}
});
};
const importEnvironment = () => {
return new Promise((resolve, reject) => {
fileDialog({ multiple: true, accept: 'application/json' })
@@ -62,7 +19,7 @@ const importEnvironment = () => {
return Promise.all(
Object.values(files ?? {}).map((file) =>
readFile([file])
.then(parsePostmanEnvironment)
.then((environment) => postmanToBrunoEnvironment(environment))
.catch((err) => {
console.error(`Error processing file: ${file.name || 'undefined'}`, err);
throw err;

View File

@@ -5,6 +5,10 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
if (['http-request', 'graphql-request'].includes(item.type)) {
sendHttpRequest(item, collection, environment, runtimeVariables)
.then((response) => {
// if there is an error, we return the response object as is
if (response?.error) {
resolve(response)
}
resolve({
state: 'success',
data: response.data,

View File

@@ -1,9 +1,11 @@
# bruno-cli
# Bruno CLI
With Bruno CLI, you can now run your API collections with ease using simple command line commands.
This makes it easier to test your APIs in different environments, automate your testing process, and integrate your API tests with your continuous integration and deployment workflows.
For detailed documentation, visit [Bruno CLI Documentation](https://docs.usebruno.com/bru-cli/overview).
## Installation
To install the Bruno CLI, use the node package manager of your choice, such as NPM:
@@ -56,6 +58,30 @@ If you need to limit the trusted CA to a specified set when validating the reque
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
```
## Command Line Options
| Option | Details |
| ---------------------------- | ----------------------------------------------------------------------------- |
| -h, --help | Show help |
| --version | Show version number |
| -r | Indicates a recursive run (default: false) |
| --cacert [string] | CA certificate to verify peer against |
| --env [string] | Specify environment to run with |
| --env-var [string] | Overwrite a single environment variable, multiple usages possible |
| -o, --output [string] | Path to write file results to |
| -f, --format [string] | Format of the file results; available formats are "json" (default) or "junit" |
| --reporter-json [string] | Path to generate a JSON report |
| --reporter-junit [string] | Path to generate a JUnit report |
| --reporter-html [string] | Path to generate an HTML report |
| --insecure | Allow insecure server connections |
| --tests-only | Only run requests that have tests |
| --bail | Stop execution after a failure of a request, test, or assertion |
| --csv-file-path | CSV file to run the collection with |
| --reporter--skip-all-headers | Skip all headers in the report |
| --reporter-skip-headers | Skip specific headers in the report |
| --client-cert-config | Client certificate configuration by passing a JSON file |
| --delay [number] | Add delay to each request |
## Scripting
Bruno cli returns the following exit status codes:

View File

@@ -177,7 +177,7 @@ const getBruFilesRecursively = (dir, testsOnly) => {
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
const stats = fs.statSync(filePath);
// todo: we might need a ignore config inside bruno.json
if (

View File

@@ -29,7 +29,7 @@ const isFile = (filepath) => {
const isDirectory = (dirPath) => {
try {
return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
} catch (_) {
return false;
}

22
packages/bruno-converters/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# dependencies
node_modules
yarn.lock
pnpm-lock.yaml
package-lock.json
.pnp
.pnp.js
# testing
coverage
# production
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};

View File

@@ -0,0 +1,13 @@
module.exports = {
transform: {
'^.+\\.js$': 'babel-jest',
},
setupFiles: ['<rootDir>/jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(nanoid)/)'
],
testEnvironment: 'node',
moduleNameMapper: {
'^nanoid(/(.*)|$)': 'nanoid$1'
}
};

View File

@@ -0,0 +1,11 @@
// Mock the uuid function
jest.mock('./src/common', () => {
// Import the original module to keep other functions intact
const originalModule = jest.requireActual('./src/common');
return {
__esModule: true, // Use this property to indicate it's an ES module
...originalModule,
uuid: jest.fn(() => 'mockeduuidvalue123456'), // Mock uuid to return a fixed value
};
});

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Anoop M D, Anusree P S and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,45 @@
{
"name": "@usebruno/converters",
"version": "0.1.0",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"package.json"
],
"scripts": {
"clean": "rimraf dist",
"test": "node --experimental-vm-modules $(npx which jest) --colors --collectCoverage",
"prebuild": "npm run clean",
"build": "rollup -c",
"watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"dependencies": {
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"nanoid": "3.3.8"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"babel-jest": "^29.7.0",
"rimraf": "^5.0.7",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup": "3.2.5"
}
}

View File

@@ -0,0 +1,45 @@
# bruno-converters
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.
## Installation
```bash
npm install @usebruno/converters
```
## Usage
### Convert Postman collection to Bruno collection
```javascript
const { postmanToBruno } = require('@usebruno/converters');
// Convert Postman collection to Bruno collection
const brunoCollection = postmanToBruno(postmanCollection);
```
### Convert Postman Environment to Bruno Environment
```javascript
const { postmanToBrunoEnvironment } = require('@usebruno/converters');
const brunoEnvironment = postmanToBrunoEnvironment(postmanEnvironment);
```
### Convert Insomnia collection to Bruno collection
```javascript
import { insomniaToBruno } from '@usebruno/converters';
const brunoCollection = insomniaToBruno(insomniaCollection);
```
### Convert OpenAPI specification to Bruno collection
```javascript
import { openApiToBruno } from '@usebruno/converters';
const brunoCollection = openApiToBruno(openApiSpecification);
```

View File

@@ -0,0 +1,38 @@
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const { terser } = require('rollup-plugin-terser');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require('./package.json');
const alias = require('@rollup/plugin-alias');
const path = require('path');
module.exports = [
{
input: 'src/index.js',
output: [
{
file: packageJson.main,
format: 'cjs',
sourcemap: true
},
{
file: packageJson.module,
format: 'esm',
sourcemap: true
}
],
plugins: [
peerDepsExternal(),
nodeResolve({
preferBuiltins: true,
extensions: ['.js', '.css'] // Resolve .js files
}),
commonjs(),
terser(),
alias({
entries: [{ find: 'src', replacement: path.resolve(__dirname, 'src') }]
})
]
}
];

View File

@@ -0,0 +1,211 @@
import each from 'lodash/each';
import get from 'lodash/get';
import { customAlphabet } from 'nanoid';
import cloneDeep from 'lodash/cloneDeep';
import { collectionSchema } from '@usebruno/schema';
export const safeParseJSON = (str) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
try {
return JSON.parse(str);
} catch (e) {
return str;
}
};
export const safeStringifyJSON = (obj, indent = false) => {
if (obj === undefined) {
return obj;
}
try {
if (indent) {
return JSON.stringify(obj, null, 2);
}
return JSON.stringify(obj);
} catch (e) {
return obj;
}
};
export const isItemARequest = (item) => {
return item.hasOwnProperty('request') && ['http-request', 'graphql-request'].includes(item.type) && !item.items;
};
// a customized version of nanoid without using _ and -
export const uuid = () => {
// https://github.com/ai/nanoid/blob/main/url-alphabet/index.js
const urlAlphabet = 'useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict';
const customNanoId = customAlphabet(urlAlphabet, 21);
return customNanoId();
};
export const validateSchema = (collection = {}) => {
try {
collectionSchema.validateSync(collection);
return collection;
} catch (err) {
throw new Error('The Collection has an invalid schema');
}
};
export const updateUidsInCollection = (_collection) => {
const collection = cloneDeep(_collection);
collection.uid = uuid();
const updateItemUids = (items = []) => {
each(items, (item) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);
}
});
};
updateItemUids(collection.items);
const updateEnvUids = (envs = []) => {
each(envs, (env) => {
env.uid = uuid();
each(env.variables, (variable) => (variable.uid = uuid()));
});
};
updateEnvUids(collection.environments);
return collection;
};
// todo
// need to eventually get rid of supporting old collection app models
// 1. start with making request type a constant fetched from a single place
// 2. move references of param and replace it with query inside the app
export const transformItemsInCollection = (collection) => {
const transformItems = (items = []) => {
each(items, (item) => {
if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`;
if (item.request.query) {
item.request.params = item.request.query.map((queryItem) => ({
...queryItem,
type: 'query',
uid: queryItem.uid || uuid()
}));
}
delete item.request.query;
// from 5 feb 2024, multipartFormData needs to have a type
// this was introduced when we added support for file uploads
// below logic is to make older collection exports backward compatible
let multipartFormData = get(item, 'request.body.multipartForm');
if (multipartFormData) {
each(multipartFormData, (form) => {
if (!form.type) {
form.type = 'text';
}
});
}
}
if (item.items && item.items.length) {
transformItems(item.items);
}
});
};
transformItems(collection.items);
return collection;
};
export const hydrateSeqInCollection = (collection) => {
const hydrateSeq = (items = []) => {
let index = 1;
each(items, (item) => {
if (isItemARequest(item) && !item.seq) {
item.seq = index;
index++;
}
if (item.items && item.items.length) {
hydrateSeq(item.items);
}
});
};
hydrateSeq(collection.items);
return collection;
};
export const deleteUidsInItems = (items) => {
each(items, (item) => {
delete item.uid;
if (['http-request', 'graphql-request'].includes(item.type)) {
each(get(item, 'request.headers'), (header) => delete header.uid);
each(get(item, 'request.params'), (param) => delete param.uid);
each(get(item, 'request.vars.req'), (v) => delete v.uid);
each(get(item, 'request.vars.res'), (v) => delete v.uid);
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
each(get(item, 'request.body.file'), (param) => delete param.uid);
}
if (item.items && item.items.length) {
deleteUidsInItems(item.items);
}
});
};
/**
* Some of the models in the app are not consistent with the Collection Json format
* This function is used to transform the models to the Collection Json format
*/
export const transformItem = (items = []) => {
each(items, (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
if (item.type === 'graphql-request') {
item.type = 'graphql';
}
if (item.type === 'http-request') {
item.type = 'http';
}
}
if (item.items && item.items.length) {
transformItem(item.items);
}
});
};
export const deleteUidsInEnvs = (envs) => {
each(envs, (env) => {
delete env.uid;
each(env.variables, (variable) => delete variable.uid);
});
};
export const deleteSecretsInEnvs = (envs) => {
each(envs, (env) => {
each(env.variables, (variable) => {
if (variable.secret) {
variable.value = '';
}
});
});
};

View File

@@ -0,0 +1,16 @@
import postmanToBruno from './postman/postman-to-bruno.js';
import postmanToBrunoEnvironment from './postman/postman-env-to-bruno-env.js';
import brunoToPostman from './postman/bruno-to-postman.js';
import openApiToBruno from './openapi/openapi-to-bruno.js';
import insomniaToBruno from './insomnia/insomnia-to-bruno.js';
export default {
postmanToBruno,
postmanToBrunoEnvironment,
brunoToPostman,
openApiToBruno,
insomniaToBruno
};

View File

@@ -0,0 +1,228 @@
import each from 'lodash/each';
import get from 'lodash/get';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
const parseGraphQL = (text) => {
try {
const graphql = JSON.parse(text);
return {
query: graphql.query,
variables: JSON.stringify(graphql.variables, null, 2)
};
} catch (e) {
return {
query: '',
variables: ''
};
}
};
const addSuffixToDuplicateName = (item, index, allItems) => {
// Check if the request name already exist and if so add a number suffix
const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
if (otherItem.name === item.name && otherIndex < index) {
nameSuffix++;
}
return nameSuffix;
}, 0);
return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
};
const regexVariable = new RegExp('{{.*?}}', 'g');
const normalizeVariables = (value) => {
value = value || '';
const variables = value.match(regexVariable) || [];
each(variables, (variable) => {
value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));
});
return value;
};
const transformInsomniaRequestItem = (request, index, allRequests) => {
const name = addSuffixToDuplicateName(request, index, allRequests);
const brunoRequestItem = {
uid: uuid(),
name,
type: 'http-request',
request: {
url: request.url,
method: request.method,
auth: {
mode: 'none',
basic: null,
bearer: null,
digest: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
}
};
each(request.headers, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.name,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
each(request.parameters, (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(request.pathParameters, (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: param.value,
description: '',
type: 'path',
enabled: true
});
});
const authType = get(request, 'authentication.type', '');
if (authType === 'basic') {
brunoRequestItem.request.auth.mode = 'basic';
brunoRequestItem.request.auth.basic = {
username: normalizeVariables(get(request, 'authentication.username', '')),
password: normalizeVariables(get(request, 'authentication.password', ''))
};
} else if (authType === 'bearer') {
brunoRequestItem.request.auth.mode = 'bearer';
brunoRequestItem.request.auth.bearer = {
token: normalizeVariables(get(request, 'authentication.token', ''))
};
}
const mimeType = get(request, 'body.mimeType', '').split(';')[0];
if (mimeType === 'application/json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = request.body.text;
} else if (mimeType === 'application/x-www-form-urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(request.body.params, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
} else if (mimeType === 'multipart/form-data') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(request.body.params, (param) => {
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: 'text',
name: param.name,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
} else if (mimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = request.body.text;
} else if (mimeType === 'text/xml' || mimeType === 'application/xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = request.body.text;
} else if (mimeType === 'application/graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
}
return brunoRequestItem;
};
const parseInsomniaCollection = (_insomniaCollection) => {
const brunoCollection = {
name: '',
uid: uuid(),
version: '1',
items: [],
environments: []
};
try {
const insomniaExport = _insomniaCollection;
const insomniaResources = get(insomniaExport, 'resources', []);
const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
if (!insomniaCollection) {
throw new Error('Collection not found inside Insomnia export');
}
brunoCollection.name = insomniaCollection.name;
const requestsAndFolders =
insomniaResources.filter((resource) => resource._type === 'request' || resource._type === 'request_group') ||
[];
function createFolderStructure(resources, parentId = null) {
const requestGroups =
resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
const folders = requestGroups.map((folder, index, allFolder) => {
const name = addSuffixToDuplicateName(folder, index, allFolder);
const requests = resources.filter(
(resource) => resource._type === 'request' && resource.parentId === folder._id
);
return {
uid: uuid(),
name,
type: 'folder',
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
};
});
return folders.concat(requests.map(transformInsomniaRequestItem));
}
brunoCollection.items = createFolderStructure(requestsAndFolders, insomniaCollection._id);
return brunoCollection;
} catch (err) {
throw new Error('An error occurred while parsing the Insomnia collection');
}
};
export const insomniaToBruno = (insomniaCollection) => {
try {
const collection = parseInsomniaCollection(insomniaCollection);
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 insomniaToBruno;

View File

@@ -0,0 +1,436 @@
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;

View File

@@ -0,0 +1,340 @@
import map from 'lodash/map';
import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from '../common';
/**
* Transforms a given URL string into an object representing the protocol, host, path, query, and variables.
*
* @param {string} url - The raw URL to be transformed.
* @param {Object} params - The params object.
* @returns {Object|null} An object containing the URL's protocol, host, path, query, and variables, or {} if an error occurs.
*/
export const transformUrl = (url, params) => {
if (typeof url !== 'string' || !url.trim()) {
url = "";
console.error("Invalid URL input:", url);
}
const urlRegexPatterns = {
protocolAndRestSeparator: /:\/\//,
hostAndPathSeparator: /\/(.+)/,
domainSegmentSeparator: /\./,
pathSegmentSeparator: /\//,
queryStringSeparator: /\?/
};
const postmanUrl = { raw: url };
/**
* Splits a URL into its protocol, host and path.
*
* @param {string} url - The URL to be split.
* @returns {Object} An object containing the protocol and the raw host/path string.
*/
const splitUrl = (url) => {
const urlParts = url.split(urlRegexPatterns.protocolAndRestSeparator);
if (urlParts.length === 1) {
return { protocol: '', rawHostAndPath: urlParts[0] };
} else if (urlParts.length === 2) {
const [hostAndPath, _] = urlParts[1].split(urlRegexPatterns.queryStringSeparator);
return { protocol: urlParts[0], rawHostAndPath: hostAndPath };
} else {
throw new Error(`Invalid URL format: ${url}`);
}
};
/**
* Splits the host and path from a raw host/path string.
*
* @param {string} rawHostAndPath - The raw host and path string to be split.
* @returns {Object} An object containing the host and path.
*/
const splitHostAndPath = (rawHostAndPath) => {
const [host, path = ''] = rawHostAndPath.split(urlRegexPatterns.hostAndPathSeparator);
return { host, path };
};
try {
const { protocol, rawHostAndPath } = splitUrl(url);
postmanUrl.protocol = protocol;
const { host, path } = splitHostAndPath(rawHostAndPath);
postmanUrl.host = host ? host.split(urlRegexPatterns.domainSegmentSeparator) : [];
postmanUrl.path = path ? path.split(urlRegexPatterns.pathSegmentSeparator) : [];
} catch (error) {
console.error(error.message);
return {};
}
// Construct query params.
postmanUrl.query = params
.filter((param) => param.type === 'query')
.map(({ name, value, description }) => ({ key: name, value, description }));
// Construct path params.
postmanUrl.variable = params
.filter((param) => param.type === 'path')
.map(({ name, value, description }) => ({ key: name, value, description }));
return postmanUrl;
};
/**
* Collapses multiple consecutive slashes (`//`) into a single slash, while skipping the protocol (e.g., `http://` or `https://`).
*
* @param {String} url - A URL string
* @returns {String} The sanitized URL
*
*/
const collapseDuplicateSlashes = (url) => {
return url.replace(/(?<!:)\/{2,}/g, '/');
};
/**
* Replaces all `\\` (backslashes) with `//` (forward slashes) and collapses multiple slashes into one.
*
* @param {string} url - The URL to sanitize.
* @returns {string} The sanitized URL.
*
*/
export const sanitizeUrl = (url) => {
let sanitizedUrl = collapseDuplicateSlashes(url.replace(/\\/g, '//'));
return sanitizedUrl;
};
export const brunoToPostman = (collection) => {
delete collection.uid;
delete collection.processEnvVariables;
deleteUidsInItems(collection.items);
deleteUidsInEnvs(collection.environments);
deleteSecretsInEnvs(collection.environments);
const generateInfoSection = () => {
return {
name: collection.name,
description: collection.root?.docs,
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
};
};
const generateCollectionVars = (collection) => {
const pattern = /{{[^{}]+}}/g;
let listOfVars = [];
const findOccurrences = (obj, results) => {
if (typeof obj === 'object') {
if (Array.isArray(obj)) {
obj.forEach((item) => findOccurrences(item, results));
} else {
for (const key in obj) {
findOccurrences(obj[key], results);
}
}
} else if (typeof obj === 'string') {
obj.replace(pattern, (match) => {
results.push(match.replace(/{{|}}/g, ''));
});
}
};
findOccurrences(collection, listOfVars);
const finalArrayOfVars = [...new Set(listOfVars)];
return finalArrayOfVars.map((variable) => ({
key: variable,
value: '',
type: 'default'
}));
};
const generateEventSection = (item) => {
const eventArray = [];
if (item?.request?.tests?.length) {
eventArray.push({
listen: 'test',
script: {
exec: item.request.tests.split('\n')
// type: 'text/javascript'
}
});
}
if (item?.request?.script?.req) {
eventArray.push({
listen: 'prerequest',
script: {
exec: item.request.script.req.split('\n')
// type: 'text/javascript'
}
});
}
return eventArray;
};
const generateHeaders = (headersArray) => {
return map(headersArray, (item) => {
return {
key: item.name,
value: item.value,
disabled: !item.enabled,
type: 'default'
};
});
};
const generateBody = (body) => {
switch (body.mode) {
case 'formUrlEncoded':
return {
mode: 'urlencoded',
urlencoded: map(body.formUrlEncoded, (bodyItem) => {
return {
key: bodyItem.name,
value: bodyItem.value,
disabled: !bodyItem.enabled,
type: 'default'
};
})
};
case 'multipartForm':
return {
mode: 'formdata',
formdata: map(body.multipartForm, (bodyItem) => {
return {
key: bodyItem.name,
value: bodyItem.value,
disabled: !bodyItem.enabled,
type: 'default'
};
})
};
case 'json':
return {
mode: 'raw',
raw: body.json,
options: {
raw: {
language: 'json'
}
}
};
case 'xml':
return {
mode: 'raw',
raw: body.xml,
options: {
raw: {
language: 'xml'
}
}
};
case 'text':
return {
mode: 'raw',
raw: body.text,
options: {
raw: {
language: 'text'
}
}
};
case 'graphql':
return {
mode: 'graphql',
graphql: body.graphql
};
}
};
const generateAuth = (itemAuth) => {
switch (itemAuth?.mode) {
case 'bearer':
return {
type: 'bearer',
bearer: {
key: 'token',
value: itemAuth.bearer.token,
type: 'string'
}
};
case 'basic': {
return {
type: 'basic',
basic: [
{
key: 'password',
value: itemAuth.basic.password,
type: 'string'
},
{
key: 'username',
value: itemAuth.basic.username,
type: 'string'
}
]
};
}
case 'apikey': {
return {
type: 'apikey',
apikey: [
{
key: 'key',
value: itemAuth.apikey.key,
type: 'string'
},
{
key: 'value',
value: itemAuth.apikey.value,
type: 'string'
}
]
};
}
default: {
return {
type: 'noauth'
};
}
}
};
const generateRequestSection = (itemRequest) => {
const requestObject = {
method: itemRequest.method,
header: generateHeaders(itemRequest.headers),
auth: generateAuth(itemRequest.auth),
description: itemRequest.docs,
// We sanitize the URL to make sure it's in the right format before passing it to the transformUrl func. This means changing backslashes to forward slashes and reducing multiple slashes to a single one, except in the protocol part.
url: transformUrl(sanitizeUrl(itemRequest.url), itemRequest.params)
};
if (itemRequest.body.mode !== 'none') {
requestObject.body = generateBody(itemRequest.body);
}
return requestObject;
};
const generateItemSection = (itemsArray) => {
return map(itemsArray, (item) => {
if (item.type === 'folder') {
return {
name: item.name,
item: item.items.length ? generateItemSection(item.items) : []
};
} else {
return {
name: item.name,
event: generateEventSection(item),
request: generateRequestSection(item.request)
};
}
});
};
const collectionToExport = {};
collectionToExport.info = generateInfoSection();
collectionToExport.item = generateItemSection(collection.items);
collectionToExport.variable = generateCollectionVars(collection);
return collectionToExport;
};
export default brunoToPostman;

View File

@@ -0,0 +1,40 @@
import each from 'lodash/each';
const isSecret = (type) => {
return type === 'secret';
};
const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
brunoEnvironment.variables = brunoEnvironment.variables || [];
each(values, (i) => {
const brunoEnvironmentVariable = {
name: i.key,
value: i.value,
enabled: i.enabled,
secret: isSecret(i.type)
};
brunoEnvironment.variables.push(brunoEnvironmentVariable);
});
};
const importPostmanEnvironment = (environment) => {
const brunoEnvironment = {
name: environment.name,
variables: []
};
importPostmanEnvironmentVariables(brunoEnvironment, environment.values);
return brunoEnvironment;
};
export const postmanToBrunoEnvironment = (postmanEnvironment) => {
try {
return importPostmanEnvironment(postmanEnvironment);
} catch (err) {
console.log(err);
throw new Error('Unable to parse the postman environment json file');
}
};
export default postmanToBrunoEnvironment;

View File

@@ -0,0 +1,584 @@
import get from 'lodash/get';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
import each from 'lodash/each';
import postmanTranslation from './postman-translations';
const parseGraphQLRequest = (graphqlSource) => {
try {
let queryResultObject = {
query: '',
variables: ''
};
if (typeof graphqlSource === 'string') {
graphqlSource = JSON.parse(graphqlSource);
}
if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') {
queryResultObject.variables = graphqlSource.variables;
}
if (graphqlSource.hasOwnProperty('query') && graphqlSource.query !== '') {
queryResultObject.query = graphqlSource.query;
}
return queryResultObject;
} catch (e) {
return {
query: '',
variables: ''
};
}
};
const isItemAFolder = (item) => {
return !item.request;
};
const convertV21Auth = (array) => {
return array.reduce((accumulator, currentValue) => {
accumulator[currentValue.key] = currentValue.value;
return accumulator;
}, {});
};
const constructUrlFromParts = (url) => {
const { protocol = 'http', host, path, port, query, hash } = url || {};
const hostStr = Array.isArray(host) ? host.filter(Boolean).join('.') : host || '';
const pathStr = Array.isArray(path) ? path.filter(Boolean).join('/') : path || '';
const portStr = port ? `:${port}` : '';
const queryStr =
query && Array.isArray(query) && query.length > 0
? `?${query
.filter((q) => q.key)
.map((q) => `${q.key}=${q.value || ''}`)
.join('&')}`
: '';
const urlStr = `${protocol}://${hostStr}${portStr}${pathStr ? `/${pathStr}` : ''}${queryStr}`;
return urlStr;
};
const constructUrl = (url) => {
if (!url) return '';
if (typeof url === 'string') {
return url;
}
if (typeof url === 'object') {
const { raw } = url;
if (raw && typeof raw === 'string') {
// If the raw URL contains url-fragments remove it
if (raw.includes('#')) {
return raw.split('#')[0]; // Returns the part of raw URL without the url-fragment part.
}
return raw;
}
// If no raw value exists, construct the URL from parts
return constructUrlFromParts(url);
}
return '';
};
const importScriptsFromEvents = (events, requestObject) => {
events.forEach((event) => {
if (event.script && event.script.exec) {
if (event.listen === 'prerequest') {
if (!requestObject.script) {
requestObject.script = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
requestObject.script.req = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
requestObject.script.req = '';
}
} else if (typeof event.script.exec === 'string') {
requestObject.script.req = postmanTranslation(event.script.exec);
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test') {
if (!requestObject.tests) {
requestObject.tests = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
requestObject.tests = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
requestObject.tests = '';
}
} else if (typeof event.script.exec === 'string') {
requestObject.tests = postmanTranslation(event.script.exec);
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
}
});
};
const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.map((v) => ({
uid: uuid(),
name: v.key,
value: v.value,
enabled: true
}));
requestObject.vars.req = vars;
};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
const requestMap = {};
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
each(item, (i) => {
if (isItemAFolder(i)) {
const baseFolderName = i.name || 'Untitled Folder';
let folderName = baseFolderName;
let count = 1;
while (folderMap[folderName]) {
folderName = `${baseFolderName}_${count}`;
count++;
}
const brunoFolderItem = {
uid: uuid(),
name: folderName,
type: 'folder',
items: [],
root: {
docs: i.description || '',
meta: {
name: folderName
},
request: {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
if (i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth);
}
if (i.event) {
importScriptsFromEvents(i.event, brunoFolderItem.root.request);
}
brunoParent.items.push(brunoFolderItem);
folderMap[folderName] = brunoFolderItem;
} else {
if (i.request) {
if(!requestMethods.includes(i?.request?.method.toUpperCase())){
console.warn("Unexpected request.method")
return;
}
const baseRequestName = i.name || 'Untitled Request';
let requestName = baseRequestName;
let count = 1;
while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
}
const url = constructUrl(i.request.url);
const brunoRequestItem = {
uid: uuid(),
name: requestName,
type: 'http-request',
request: {
url: url,
method: i?.request?.method?.toUpperCase(),
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
docs: i.request.description || ''
}
};
if (i.event) {
i.event.forEach((event) => {
if (event.listen === 'prerequest' && event.script && event.script.exec) {
if (!brunoRequestItem.request.script) {
brunoRequestItem.request.script = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
brunoRequestItem.request.script.req = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
brunoRequestItem.request.script.req = '';
}
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.script.req = postmanTranslation(event.script.exec);
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request.tests) {
brunoRequestItem.request.tests = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
brunoRequestItem.request.tests = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
brunoRequestItem.request.tests = '';
}
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.tests = postmanTranslation(event.script.exec);
} else {
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
}
const bodyMode = get(i, 'request.body.mode');
if (bodyMode) {
if (bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
const isFile = param.type === 'file';
let value;
let type;
if (isFile) {
// If param.src is an array, keep it as it is.
// If param.src is a string, convert it into an array with a single element.
value = Array.isArray(param.src) ? param.src : typeof param.src === 'string' ? [param.src] : null;
type = 'file';
} else {
value = param.value;
type = 'text';
}
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: type,
name: param.key,
value: value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'raw') {
let language = get(i, 'request.body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(i.request.header);
}
if (language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
if (bodyMode === 'graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(i.request.header, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
const auth = i.request.auth ?? parentAuth;
if (auth?.[auth.type] && auth.type !== 'noauth') {
let authValues = auth[auth.type];
if (Array.isArray(authValues)) {
authValues = convertV21Auth(authValues);
}
if (auth.type === 'basic') {
brunoRequestItem.request.auth.mode = 'basic';
brunoRequestItem.request.auth.basic = {
username: authValues.username,
password: authValues.password
};
} else if (auth.type === 'bearer') {
brunoRequestItem.request.auth.mode = 'bearer';
brunoRequestItem.request.auth.bearer = {
token: authValues.token
};
} else if (auth.type === 'awsv4') {
brunoRequestItem.request.auth.mode = 'awsv4';
brunoRequestItem.request.auth.awsv4 = {
accessKeyId: authValues.accessKey,
secretAccessKey: authValues.secretKey,
sessionToken: authValues.sessionToken,
service: authValues.service,
region: authValues.region,
profileName: ''
};
} else if (auth.type === 'apikey'){
brunoRequestItem.request.auth.mode = 'apikey';
brunoRequestItem.request.auth.apikey = {
key: authValues.key,
value: authValues.value?.toString(), // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: "header" //By default we are placing the apikey values in headers!
}
} else if (auth.type === 'oauth2'){
const findValueUsingKey = (key) => {
return auth?.oauth2?.find(v => v?.key == key)?.value || ''
}
const oauth2GrantTypeMaps = {
'authorization_code_with_pkce': 'authorization_code',
'authorization_code': 'authorization_code',
'client_credentials': 'client_credentials',
'password_credentials': 'password_credentials'
}
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
if (grantType) {
brunoRequestItem.request.auth.mode = 'oauth2';
switch(grantType) {
case 'authorization_code':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'authorization_code',
authorizationUrl: findValueUsingKey('authUrl'),
callbackUrl: findValueUsingKey('redirect_uri'),
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'password_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'password',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
username: findValueUsingKey('username'),
password: findValueUsingKey('password'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
case 'client_credentials':
brunoRequestItem.request.auth.oauth2 = {
grantType: 'client_credentials',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
break;
}
}
}
}
each(get(i, 'request.url.query'), (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
description: param.description ?? '',
type: 'path',
enabled: true
});
});
brunoParent.items.push(brunoRequestItem);
requestMap[requestName] = brunoRequestItem;
}
}
});
};
const searchLanguageByHeader = (headers) => {
let contentType;
each(headers, (header) => {
if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
contentType = 'json';
} else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
contentType = 'xml';
}
return false;
}
});
return contentType;
};
const importPostmanV2Collection = (collection) => {
const brunoCollection = {
name: collection.info.name || 'Untitled Collection',
uid: uuid(),
version: '1',
items: [],
environments: [],
root: {
docs: collection.info.description || '',
meta: {
name: collection.info.name || 'Untitled Collection'
},
request: {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
if (collection.event) {
importScriptsFromEvents(collection.event, brunoCollection.root.request);
}
if (collection?.variable){
importCollectionLevelVariables(collection.variable, brunoCollection.root.request);
}
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth);
return brunoCollection;
};
const parsePostmanCollection = (collection) => {
try {
let schema = get(collection, 'info.schema');
let v2Schemas = [
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
'https://schema.postman.com/json/collection/v2.0.0/collection.json',
'https://schema.postman.com/json/collection/v2.1.0/collection.json'
];
if (v2Schemas.includes(schema)) {
return importPostmanV2Collection(collection);
}
throw new Error('Unknown postman schema');
} catch (err) {
console.log(err);
if (err instanceof Error) {
throw err;
}
throw new Error('Unable to parse the postman collection json file');
}
};
const postmanToBruno = (postmanCollection) => {
try {
const parsedPostmanCollection = parsePostmanCollection(postmanCollection);
const transformedCollection = transformItemsInCollection(parsedPostmanCollection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);
const validatedCollection = validateSchema(hydratedCollection);
return validatedCollection;
} catch(err) {
console.log(err);
throw new Error('Import collection failed');
}
};
export default postmanToBruno;

View File

@@ -42,7 +42,7 @@ const compiledReplacements = Object.entries(extendedReplacements).map(([pattern,
replacement
}));
export const postmanTranslation = (script, logCallback) => {
const postmanTranslation = (script) => {
try {
let modifiedScript = script;
let modified = false;
@@ -54,10 +54,11 @@ export const postmanTranslation = (script, logCallback) => {
}
if (modifiedScript.includes('pm.') || modifiedScript.includes('postman.')) {
modifiedScript = modifiedScript.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
//logCallback?.();
}
return modifiedScript;
} catch (e) {
return script;
}
};
export default postmanTranslation;

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from '@jest/globals';
import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno';
describe('insomnia-collection', () => {
it('should correctly import a valid Insomnia collection file', async () => {
const brunoCollection = insomniaToBruno(insomniaCollection);
expect(brunoCollection).toMatchObject(expectedOutput)
});
});
const insomniaCollection = {
"_type": "export",
"__export_format": 4,
"__export_date": "2024-05-20T10:02:44.123Z",
"__export_source": "insomnia.desktop.app:v2021.5.2",
"resources": [
{
"_id": "req_1",
"_type": "request",
"parentId": "fld_1",
"name": "Request1",
"method": "GET",
"url": "https://httpbin.org/get",
"parameters": []
},
{
"_id": "req_2",
"_type": "request",
"parentId": "fld_2",
"name": "Request2",
"method": "GET",
"url": "https://httpbin.org/get",
"parameters": []
},
{
"_id": "fld_1",
"_type": "request_group",
"parentId": "wrk_1",
"name": "Folder1"
},
{
"_id": "fld_2",
"_type": "request_group",
"parentId": "wrk_1",
"name": "Folder2"
},
{
"_id": "wrk_1",
"_type": "workspace",
"name": "Hello World Workspace Insomnia"
},
{
"_id": "env_1",
"_type": "environment",
"parentId": "wrk_1",
"data": {
"var1": "value1",
"var2": "value2"
}
}
]
};
const expectedOutput = {
"environments": [],
"items": [
{
"items": [
{
"name": "Request1",
"request": {
"auth": {
"basic": null,
"bearer": null,
"digest": null,
"mode": "none",
},
"body": {
"formUrlEncoded": [],
"json": null,
"mode": "none",
"multipartForm": [],
"text": null,
"xml": null,
},
"headers": [],
"method": "GET",
"params": [],
"url": "https://httpbin.org/get",
},
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
},
{
"name": "Request1",
"request": {
"auth": {
"basic": null,
"bearer": null,
"digest": null,
"mode": "none",
},
"body": {
"formUrlEncoded": [],
"json": null,
"mode": "none",
"multipartForm": [],
"text": null,
"xml": null,
},
"headers": [],
"method": "GET",
"params": [],
"url": "https://httpbin.org/get",
},
"seq": 2,
"type": "http-request",
"uid": "mockeduuidvalue123456",
},
],
"name": "Folder1",
"type": "folder",
"uid": "mockeduuidvalue123456",
},
{
"items": [
{
"name": "Request2",
"request": {
"auth": {
"basic": null,
"bearer": null,
"digest": null,
"mode": "none",
},
"body": {
"formUrlEncoded": [],
"json": null,
"mode": "none",
"multipartForm": [],
"text": null,
"xml": null,
},
"headers": [],
"method": "GET",
"params": [],
"url": "https://httpbin.org/get",
},
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
},
{
"name": "Request2",
"request": {
"auth": {
"basic": null,
"bearer": null,
"digest": null,
"mode": "none",
},
"body": {
"formUrlEncoded": [],
"json": null,
"mode": "none",
"multipartForm": [],
"text": null,
"xml": null,
},
"headers": [],
"method": "GET",
"params": [],
"url": "https://httpbin.org/get",
},
"seq": 2,
"type": "http-request",
"uid": "mockeduuidvalue123456",
},
],
"name": "Folder2",
"type": "folder",
"uid": "mockeduuidvalue123456",
},
],
"name": "Hello World Workspace Insomnia",
"uid": "mockeduuidvalue123456",
"version": "1",
};

View File

@@ -0,0 +1,108 @@
import jsyaml from 'js-yaml';
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../src/openapi/openapi-to-bruno';
describe('openapi-collection', () => {
it('should correctly import a valid OpenAPI file', async () => {
const openApiSpecification = jsyaml.load(openApiCollectionString);
const brunoCollection = openApiToBruno(openApiSpecification);
expect(brunoCollection).toMatchObject(expectedOutput);
});
});
const openApiCollectionString = `
openapi: "3.0.0"
info:
version: "1.0.0"
title: "Hello World OpenAPI"
paths:
/get:
get:
tags:
- Folder1
- Folder2
summary: "Request1 and Request2"
operationId: "getRequests"
responses:
'200':
description: "Successful response"
components:
parameters:
var1:
in: "query"
name: "var1"
required: true
schema:
type: "string"
default: "value1"
var2:
in: "query"
name: "var2"
required: true
schema:
type: "string"
default: "value2"
servers:
- url: "https://httpbin.org"
`;
const expectedOutput = {
"environments": [
{
"name": "Environment 1",
"uid": "mockeduuidvalue123456",
"variables": [
{
"enabled": true,
"name": "baseUrl",
"secret": false,
"type": "text",
"uid": "mockeduuidvalue123456",
"value": "https://httpbin.org",
},
],
},
],
"items": [
{
"items": [
{
"name": "Request1 and Request2",
"request": {
"auth": {
"basic": null,
"bearer": null,
"digest": null,
"mode": "none",
},
"body": {
"formUrlEncoded": [],
"json": null,
"mode": "none",
"multipartForm": [],
"text": null,
"xml": null,
},
"headers": [],
"method": "GET",
"params": [],
"script": {
"res": null,
},
"url": "{{baseUrl}}/get",
},
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
},
],
"name": "Folder1",
"type": "folder",
"uid": "mockeduuidvalue123456",
},
],
"name": "Hello World OpenAPI",
"uid": "mockeduuidvalue123456",
"version": "1",
};

View File

@@ -1,4 +1,4 @@
const { sanitizeUrl, transformUrl } = require('./postman-collection');
import { sanitizeUrl, transformUrl } from "../../src/postman/bruno-to-postman";
describe('transformUrl', () => {
it('should handle basic URL with path variables', () => {

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from '@jest/globals';
import postmanToBrunoEnvironment from '../../src/postman/postman-env-to-bruno-env';
describe('postmanToBrunoEnvironment Function', () => {
it('should correctly import a valid Postman environment file', async () => {
const postmanEnvironment = {
"id": "some-id",
"name": "My Environment",
"values": [
{
"key": "var1",
"value": "value1",
"enabled": true,
"type": "text"
},
{
"key": "var2",
"value": "value2",
"enabled": false,
"type": "secret"
}
]
};
const brunoEnvironment = await postmanToBrunoEnvironment(postmanEnvironment);
const expectedEnvironment = {
name: 'My Environment',
variables: [
{
name: 'var1',
value: 'value1',
enabled: true,
secret: false,
},
{
name: 'var2',
value: 'value2',
enabled: false,
secret: true,
},
],
};
expect(brunoEnvironment).toEqual(expectedEnvironment);
});
it.skip('should throw Error when JSON parsing fails', async () => {
const invalidBrunoEnvironment = {
"id": "some-id",
"name": "My Environment",
"values": [
{
"key": "var1",
"value": "value1",
"enabled": true,
"type": "text"
}
]
}
await expect(postmanToBrunoEnvironment(invalidBrunoEnvironment)).rejects.toThrow(Error);
await expect(postmanToBrunoEnvironment(invalidBrunoEnvironment)).rejects.toThrow(
'Unable to parse the postman environment json file'
);
});
});

View File

@@ -0,0 +1,734 @@
import { describe, it, expect } from '@jest/globals';
import postmanToBruno from '../../src/postman/postman-to-bruno';
describe('postman-collection', () => {
it('should correctly import a valid Postman collection file', async () => {
const brunoCollection = postmanToBruno(postmanCollection);
expect(brunoCollection).toMatchObject(expectedOutput);
});
});
const postmanCollection = {
"info": {
"_postman_id": "0596d399-cfd2-4f8f-9869-65238eb40a45",
"name": "CRUD",
"schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json",
"_exporter_id": "32111649",
"_collection_link": "https://www.postman.com/fudzi9/workspace/nodejs/collection/16541095-0596d399-cfd2-4f8f-9869-65238eb40a45?action=share&source=collection_link&creator=32111649"
},
"item": [
{
"name": "GET",
"request": {
"method": "GET",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"response": [
{
"name": "1.GET",
"originalRequest": {
"method": "GET",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "2"
},
{
"key": "Etag",
"value": "W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 21:30:45 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "[]"
},
{
"name": "3.GET",
"originalRequest": {
"method": "GET",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "96"
},
{
"key": "Etag",
"value": "W/\"60-ixboSJswZpL0hV7rJrY1IE5nQlM\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 21:58:32 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "[\n {\n \"id\": 1,\n \"title\": \"first\",\n \"content\": \"some text\",\n \"createdAt\": \"some date\",\n \"updatedAt\": \"some date\"\n }\n]"
},
{
"name": "5.GET",
"originalRequest": {
"method": "GET",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "192"
},
{
"key": "Etag",
"value": "W/\"c0-rg+VAYKuV+nAzdAnddMXRNSM3tg\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 22:01:36 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "[\n {\n \"id\": 1,\n \"title\": \"first\",\n \"content\": \"some text\",\n \"createdAt\": \"some date\",\n \"updatedAt\": \"some date\"\n },\n {\n \"id\": 2,\n \"title\": \"second\",\n \"content\": \"some text\",\n \"createdAt\": \"some date\",\n \"updatedAt\": \"some date\"\n }\n]"
},
{
"name": "7.GET",
"originalRequest": {
"method": "GET",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "199"
},
{
"key": "Etag",
"value": "W/\"c7-SBFGBh+BSdmKqSUIW4VDODIOnaI\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 22:38:51 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "[\n {\n \"id\": 2,\n \"title\": \"second\",\n \"content\": \"some text\",\n \"createdAt\": \"some date\",\n \"updatedAt\": \"some date\"\n },\n {\n \"id\": 1,\n \"title\": \"first changed\",\n \"content\": \"new text\",\n \"createdAt\": \"some date\",\n \"updatedAt\": \"some date\"\n }\n]"
},
{
"name": "9.GET",
"originalRequest": {
"method": "GET",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "103"
},
{
"key": "Etag",
"value": "W/\"67-aR9NxSbB5ab73lSksdIWZNuQyq8\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 22:40:55 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "[\n {\n \"id\": 1,\n \"title\": \"first changed\",\n \"content\": \"new text\",\n \"createdAt\": \"some date\",\n \"updatedAt\": \"some date\"\n }\n]"
}
]
},
{
"name": "POST",
"event": [
{
"listen": "prerequest",
"script": {
"exec": [
""
],
"type": "text/javascript"
}
},
{
"listen": "test",
"script": {
"exec": [
""
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"id\": 1, \"title\": \"first\", \"content\": \"some text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"response": [
{
"name": "2.POST",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"id\": 1, \"title\": \"first\", \"content\": \"some text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "123"
},
{
"key": "Etag",
"value": "W/\"7b-Zs+ZSZvDSG55ZK90aBqfAjoxdAg\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 21:58:17 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "\"{\\\"id\\\": 1, \\\"title\\\": \\\"first\\\", \\\"content\\\": \\\"some text\\\", \\\"createdAt\\\": \\\"some date\\\", \\\"updatedAt\\\": \\\"some date\\\"}\""
}
]
},
{
"name": "POST",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"id\": 2, \"title\": \"second\", \"content\": \"some text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"response": [
{
"name": "4.POST",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"id\": 2, \"title\": \"second\", \"content\": \"some text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": "https://node-task2.herokuapp.com/api/notes/"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "124"
},
{
"key": "Etag",
"value": "W/\"7c-vtAEN2HlKwhD6OkasvICg9Ni+g0\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 22:00:49 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "\"{\\\"id\\\": 2, \\\"title\\\": \\\"second\\\", \\\"content\\\": \\\"some text\\\", \\\"createdAt\\\": \\\"some date\\\", \\\"updatedAt\\\": \\\"some date\\\"}\""
}
]
},
{
"name": "PUT",
"request": {
"method": "PUT",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"id\": 1, \"title\": \"first changed\", \"content\": \"new text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": "https://node-task2.herokuapp.com/api/notes/1"
},
"response": [
{
"name": "6.PUT",
"originalRequest": {
"method": "PUT",
"header": [],
"body": {
"mode": "raw",
"raw": "{\"id\": 1, \"title\": \"first changed\", \"content\": \"new text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": "https://node-task2.herokuapp.com/api/notes/1"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "130"
},
{
"key": "Etag",
"value": "W/\"82-QdzTirfdP1+K+iNOkslStk0OPpg\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 22:03:36 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "\"{\\\"id\\\": 1, \\\"title\\\": \\\"first changed\\\", \\\"content\\\": \\\"new text\\\", \\\"createdAt\\\": \\\"some date\\\", \\\"updatedAt\\\": \\\"some date\\\"}\""
}
]
},
{
"name": "DELETE",
"request": {
"method": "DELETE",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/2"
},
"response": [
{
"name": "8.DELETE",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": "https://node-task2.herokuapp.com/api/notes/2"
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "Cowboy"
},
{
"key": "Connection",
"value": "keep-alive"
},
{
"key": "X-Powered-By",
"value": "Express"
},
{
"key": "Content-Type",
"value": "application/json; charset=utf-8"
},
{
"key": "Content-Length",
"value": "23"
},
{
"key": "Etag",
"value": "W/\"17-bCXlhEBJSVIeQ+m1i+6p7+rrNak\""
},
{
"key": "Date",
"value": "Tue, 06 Jul 2021 22:40:08 GMT"
},
{
"key": "Via",
"value": "1.1 vegur"
}
],
"cookie": [],
"body": "{\n \"success\": true,\n \"id\": 2\n}"
}
]
}
]
};
const expectedOutput = {
"name": "CRUD",
"uid": "mockeduuidvalue123456",
"version": "1",
"items": [
{
"uid": "mockeduuidvalue123456",
"name": "GET",
"type": "http-request",
"request": {
"url": "https://node-task2.herokuapp.com/api/notes/",
"method": "GET",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null
},
"headers": [],
"params": [],
"body": {
"mode": "none",
"json": null,
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
"seq": 1
},
{
"uid": "mockeduuidvalue123456",
"name": "POST",
"type": "http-request",
"request": {
"url": "https://node-task2.herokuapp.com/api/notes/",
"method": "POST",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null
},
"headers": [],
"params": [],
"body": {
"mode": "json",
"json": "{\"id\": 1, \"title\": \"first\", \"content\": \"some text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": "",
"script": {
"req": ""
},
"tests": ""
},
"seq": 2
},
{
"uid": "mockeduuidvalue123456",
"name": "POST_1",
"type": "http-request",
"request": {
"url": "https://node-task2.herokuapp.com/api/notes/",
"method": "POST",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null
},
"headers": [],
"params": [],
"body": {
"mode": "json",
"json": "{\"id\": 2, \"title\": \"second\", \"content\": \"some text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
"seq": 3
},
{
"uid": "mockeduuidvalue123456",
"name": "PUT",
"type": "http-request",
"request": {
"url": "https://node-task2.herokuapp.com/api/notes/1",
"method": "PUT",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null
},
"headers": [],
"params": [],
"body": {
"mode": "json",
"json": "{\"id\": 1, \"title\": \"first changed\", \"content\": \"new text\", \"createdAt\": \"some date\", \"updatedAt\": \"some date\"}",
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
"seq": 4
},
{
"uid": "mockeduuidvalue123456",
"name": "DELETE",
"type": "http-request",
"request": {
"url": "https://node-task2.herokuapp.com/api/notes/2",
"method": "DELETE",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null
},
"headers": [],
"params": [],
"body": {
"mode": "none",
"json": null,
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
"seq": 5
}
],
"environments": [],
"root": {
"docs": "",
"meta": {
"name": "CRUD"
},
"request": {
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null
},
"headers": [],
"script": {},
"tests": "",
"vars": {}
}
}
};

View File

@@ -1,4 +1,4 @@
const { postmanTranslation } = require('./postman_translation'); // Adjust path as needed
const { default: postmanTranslation } = require("../../src/postman/postman-translations");
describe('postmanTranslation function', () => {
test('should translate pm commands correctly', () => {
@@ -11,9 +11,6 @@ describe('postmanTranslation function', () => {
pm.collectionVariables.set('key', 'value');
const data = pm.response.json();
pm.expect(pm.environment.has('key')).to.be.true;
postman.setEnvironmentVariable('key', 'value');
postman.getEnvironmentVariable('key');
postman.clearEnvironmentVariable('key');
`;
const expectedOutput = `
bru.getEnvVar('key');
@@ -24,9 +21,6 @@ describe('postmanTranslation function', () => {
bru.setVar('key', 'value');
const data = res.getBody();
expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
bru.setEnvVar('key', 'value');
bru.getEnvVar('key');
bru.deleteEnvVar('key');
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
@@ -53,7 +47,7 @@ describe('postmanTranslation function', () => {
test('should handle multiple pm commands on the same line', () => {
const inputScript = "pm.environment.get('key'); pm.environment.set('key', 'value');";
const expectedOutput = "bru.getEnvVar('key'); bru.setEnvVar('key', 'value');";
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle comments and other JavaScript code', () => {
const inputScript = `
@@ -157,13 +151,3 @@ test('should handle response commands', () => {
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle tests object', () => {
const inputScript = `
tests['Status code is 200'] = responseCode.code === 200;
`;
const expectedOutput = `
test("Status code is 200", function() { expect(Boolean(responseCode.code === 200)).to.be.true; });
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES6",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
"declaration": true,
"declarationDir": "types",
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "node",
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["dist", "node_modules", "tests"]
}

View File

@@ -0,0 +1,6 @@
export declare const uuid: () => string;
export declare const normalizeFileName: (name: string) => string;
export declare const validateSchema: (collection?: {}) => Promise<unknown>;
export declare const updateUidsInCollection: (_collection: any) => any;
export declare const transformItemsInCollection: (collection: any) => any;
export declare const hydrateSeqInCollection: (collection: any) => any;

View File

@@ -26,6 +26,7 @@
},
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@faker-js/faker": "^9.5.1",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",

View File

@@ -11,7 +11,7 @@ const { safeStringifyJSON } = require('../../utils/common');
const LOCAL_IPV6 = '::1';
const LOCAL_IPV4 = '127.0.0.1';
const LOCALHOST = 'localhost';
const version = electronApp?.app?.getVersion()?.substring(1) ?? "";
const version = electronApp?.app?.getVersion() ?? "";
const redirectResponseCodes = [301, 302, 303, 307, 308];
const saveCookies = (url, headers) => {
@@ -102,9 +102,12 @@ function makeAxiosInstance({
const url = URL.parse(config.url);
config.metadata = config.metadata || {};
config.metadata.startTime = new Date().getTime();
const timeline = config.metadata.timeline || []
const timeline = config.metadata.timeline || [];
// Add initial request details to the timeline
timeline.push({
timestamp: new Date(),
type: 'separator'
});
timeline.push({
timestamp: new Date(),
type: 'info',
@@ -173,10 +176,13 @@ function makeAxiosInstance({
});
}
catch(err) {
if (err.timeline) {
timeline = err.timeline;
}
timeline.push({
timestamp: new Date(),
type: 'error',
message: err?.message,
message: `Error setting up proxy agents: ${err?.message}`,
});
}
config.metadata.timeline = timeline;
@@ -264,21 +270,12 @@ function makeAxiosInstance({
if (redirectCount >= requestMaxRedirects) {
const errorResponseData = error.response.data;
const dataBuffer = Buffer.isBuffer(errorResponseData) ? errorResponseData : Buffer.from(errorResponseData);
timeline?.push({
timestamp: new Date(),
type: 'error',
message: safeStringifyJSON(errorResponseData?.toString?.())
});
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: errorResponseData?.toString?.(),
size: Buffer.byteLength(dataBuffer),
duration: error.response.headers.get('request-duration') ?? 0,
timeline: error.response.timeline
};
return Promise.reject(error);
}
// Increase redirect count
@@ -319,14 +316,26 @@ function makeAxiosInstance({
}
}
setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
httpsAgentRequestFields,
interpolationOptions,
timeline
});
try {
setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
httpsAgentRequestFields,
interpolationOptions,
timeline
});
}
catch(err) {
if (err.timeline) {
timeline = err.timeline;
}
timeline.push({
timestamp: new Date(),
type: 'error',
message: `Error setting up proxy agents: ${err?.message}`,
});
}
requestConfig.metadata.timeline = timeline;
// Make the redirected request
@@ -334,7 +343,11 @@ function makeAxiosInstance({
}
else {
const errorResponseData = error.response.data;
const dataBuffer = Buffer.isBuffer(errorResponseData) ? errorResponseData : Buffer.from(errorResponseData);
timeline.push({
timestamp: new Date(),
type: 'response',
message: `HTTP/${error.response.httpVersion || '1.1'} ${error.response.status} ${error.response.statusText}`,
});
Object.entries(error?.response?.headers || {}).forEach(([key, value]) => {
timeline.push({
timestamp: new Date(),
@@ -357,15 +370,8 @@ function makeAxiosInstance({
type: 'error',
message: safeStringifyJSON(error?.errors)
});
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: errorResponseData?.toString?.(),
size: Buffer.byteLength(dataBuffer),
duration: error.response.headers.get('request-duration') ?? 0,
timeline
};
error.response.timeline = timeline;
return Promise.reject(error);
}
}
else if (error?.code) {
@@ -386,13 +392,9 @@ function makeAxiosInstance({
type: 'error',
message: safeStringifyJSON(error?.errors)
});
return {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: 'request failed, check timeline network logs',
timeline
};
error.timeline = timeline;
error.statusText = error.code;
return Promise.reject(error);
}
return Promise.reject(error);
}

View File

@@ -0,0 +1,127 @@
const { faker } = require('@faker-js/faker');
const mockDataFunctions = {
guid: () => faker.string.uuid(),
timestamp: () => faker.date.anytime().getTime().toString(),
isoTimestamp: () => faker.date.anytime().toISOString(),
randomUUID: () => faker.string.uuid(),
randomAlphaNumeric: () => faker.string.alphanumeric(),
randomBoolean: () => faker.datatype.boolean(),
randomInt: () => faker.number.int(),
randomColor: () => faker.color.human(),
randomHexColor: () => faker.internet.color(),
randomAbbreviation: () => faker.hacker.abbreviation(),
randomIP: () => faker.internet.ip(),
randomIPV4: () => faker.internet.ipv4(),
randomIPV6: () => faker.internet.ipv6(),
randomMACAddress: () => faker.internet.mac(),
randomPassword: () => faker.internet.password(),
randomLocale: () => faker.location.countryCode(),
randomUserAgent: () => faker.internet.userAgent(),
randomProtocol: () => faker.internet.protocol(),
randomSemver: () => faker.system.semver(),
randomFirstName: () => faker.person.firstName(),
randomLastName: () => faker.person.lastName(),
randomFullName: () => faker.person.fullName(),
randomNamePrefix: () => faker.person.prefix(),
randomNameSuffix: () => faker.person.suffix(),
randomJobArea: () => faker.person.jobArea(),
randomJobDescriptor: () => faker.person.jobDescriptor(),
randomJobTitle: () => faker.person.jobTitle(),
randomJobType: () => faker.person.jobType(),
randomPhoneNumber: () => faker.phone.number(),
randomPhoneNumberExt: () => faker.phone.number(),
randomCity: () => faker.location.city(),
randomStreetName: () => faker.location.street(),
randomStreetAddress: () => faker.location.streetAddress(),
randomCountry: () => faker.location.country(),
randomCountryCode: () => faker.location.countryCode(),
randomLatitude: () => faker.location.latitude(),
randomLongitude: () => faker.location.longitude(),
randomAvatarImage: () => faker.image.avatar(),
randomImageUrl: () => faker.image.url(),
randomAbstractImage: () => faker.image.urlLoremFlickr({ category: 'abstract' }),
randomAnimalsImage: () => faker.image.urlLoremFlickr({ category: 'animals' }),
randomBusinessImage: () => faker.image.urlLoremFlickr({ category: 'business' }),
randomCatsImage: () => faker.image.urlLoremFlickr({ category: 'cats' }),
randomCityImage: () => faker.image.urlLoremFlickr({ category: 'city' }),
randomFoodImage: () => faker.image.urlLoremFlickr({ category: 'food' }),
randomNightlifeImage: () => faker.image.urlLoremFlickr({ category: 'nightlife' }),
randomFashionImage: () => faker.image.urlLoremFlickr({ category: 'fashion' }),
randomPeopleImage: () => faker.image.urlLoremFlickr({ category: 'people' }),
randomNatureImage: () => faker.image.urlLoremFlickr({ category: 'nature' }),
randomSportsImage: () => faker.image.urlLoremFlickr({ category: 'sports' }),
randomTransportImage: () => faker.image.urlLoremFlickr({ category: 'transport' }),
randomImageDataUri: () => faker.image.dataUri(),
randomBankAccount: () => faker.finance.accountNumber(),
randomBankAccountName: () => faker.finance.accountName(),
randomCreditCardMask: () => faker.finance.iban().replace(/(?<=.{4})\w(?=.{2})/g, '*'),
randomBankAccountBic: () => faker.finance.bic(),
randomBankAccountIban: () => faker.finance.iban(),
randomTransactionType: () => faker.finance.transactionType(),
randomCurrencyCode: () => faker.finance.currencyCode(),
randomCurrencyName: () => faker.finance.currencyName(),
randomCurrencySymbol: () => faker.finance.currencySymbol(),
randomBitcoin: () => faker.finance.bitcoinAddress(),
randomCompanyName: () => faker.company.name(),
randomCompanySuffix: () => faker.company.name(),
randomBs: () => faker.company.buzzPhrase(),
randomBsAdjective: () => faker.company.buzzAdjective(),
randomBsBuzz: () => faker.company.buzzVerb(),
randomBsNoun: () => faker.company.buzzNoun(),
randomCatchPhrase: () => faker.company.catchPhrase(),
randomCatchPhraseAdjective: () => faker.company.catchPhraseAdjective(),
randomCatchPhraseDescriptor: () => faker.company.catchPhraseDescriptor(),
randomCatchPhraseNoun: () => faker.company.catchPhraseNoun(),
randomDatabaseColumn: () => faker.database.column(),
randomDatabaseType: () => faker.database.type(),
randomDatabaseCollation: () => faker.database.collation(),
randomDatabaseEngine: () => faker.database.engine(),
randomDateFuture: () => faker.date.future().toISOString(),
randomDatePast: () => faker.date.past().toISOString(),
randomDateRecent: () => faker.date.recent().toISOString(),
randomWeekday: () => faker.date.weekday(),
randomMonth: () => faker.date.month(),
randomDomainName: () => faker.internet.domainName(),
randomDomainSuffix: () => faker.internet.domainSuffix(),
randomDomainWord: () => faker.internet.domainWord(),
randomEmail: () => faker.internet.email(),
randomExampleEmail: () => faker.internet.exampleEmail(),
randomUserName: () => faker.internet.username(),
randomUrl: () => faker.internet.url(),
randomFileName: () => faker.system.fileName(),
randomFileType: () => faker.system.fileType(),
randomFileExt: () => faker.system.fileExt(),
randomCommonFileName: () => faker.system.commonFileName(),
randomCommonFileType: () => faker.system.commonFileType(),
randomCommonFileExt: () => faker.system.commonFileExt(),
randomFilePath: () => faker.system.filePath(),
randomDirectoryPath: () => faker.system.directoryPath(),
randomMimeType: () => faker.system.mimeType(),
randomPrice: () => faker.commerce.price(),
randomProduct: () => faker.commerce.product(),
randomProductAdjective: () => faker.commerce.productAdjective(),
randomProductMaterial: () => faker.commerce.productMaterial(),
randomProductName: () => faker.commerce.productName(),
randomDepartment: () => faker.commerce.department(),
randomNoun: () => faker.hacker.noun(),
randomVerb: () => faker.hacker.verb(),
randomIngverb: () => faker.hacker.ingverb(),
randomAdjective: () => faker.hacker.adjective(),
randomWord: () => faker.hacker.noun(),
randomWords: () => faker.lorem.words(),
randomPhrase: () => faker.hacker.phrase(),
randomLoremWord: () => faker.lorem.word(),
randomLoremWords: () => faker.lorem.words(),
randomLoremSentence: () => faker.lorem.sentence(),
randomLoremSentences: () => faker.lorem.sentences(),
randomLoremParagraph: () => faker.lorem.paragraph(),
randomLoremParagraphs: () => faker.lorem.paragraphs(),
randomLoremText: () => faker.lorem.text(),
randomLoremSlug: () => faker.lorem.slug(),
randomLoremLines: () => faker.lorem.lines()
};
module.exports = {
mockDataFunctions
};

View File

@@ -20,7 +20,7 @@ const { prepareRequest } = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { makeAxiosInstance } = require('./axios-instance');
const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse } = require('../../utils/common');
const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseDataFromRequest } = require('../../utils/common');
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies');
const { createFormData } = require('../../utils/form-data');
@@ -557,16 +557,14 @@ const registerNetworkIpc = (mainWindow) => {
processEnvVars,
collectionPath
);
const requestData = request.mode == 'file'? "<request body redacted>": (typeof request?.data === 'string' ? request?.data : safeStringifyJSON(request?.data));
const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request);
let requestSent = {
url: request.url,
method: request.method,
headers: request.headers,
data: requestData,
timestamp: Date.now()
}
if (requestData) {
requestSent.dataBuffer = Buffer.from(requestData);
dataBuffer: requestDataBuffer
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
@@ -602,9 +600,14 @@ const registerNetworkIpc = (mainWindow) => {
// if it's a cancel request, don't continue
if (axios.isCancel(error)) {
let error = new Error('Request cancelled');
error.isCancel = true;
return Promise.reject(error);
// we are not rejecting the promise here and instead returning a response object with `error` which is handled in the `send-http-request` invocation
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
return {
statusText: 'REQUEST_CANCELLED',
isCancel: true,
error: 'REQUEST_CANCELLED',
timeline: error.timeline
};
}
if (error?.response) {
@@ -615,7 +618,13 @@ const registerNetworkIpc = (mainWindow) => {
response.headers.delete('request-duration');
} else {
// if it's not a network error, don't continue
return Promise.reject(error);
// we are not rejecting the promise here and instead returning a response object with `error` which is handled in the `send-http-request` invocation
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
return {
statusText: error.statusText,
error: error.message,
timeline: error.timeline
}
}
}
@@ -743,7 +752,13 @@ const registerNetworkIpc = (mainWindow) => {
} catch (error) {
deleteCancelToken(cancelTokenUid);
return Promise.reject(error);
// we are not rejecting the promise here and instead returning a response object with `error` which is handled in the `send-http-request` invocation
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
return {
status: error?.status,
error: error?.message || 'an error ocurred: debug',
timeline: error?.timeline
};
}
}
@@ -992,15 +1007,13 @@ const registerNetworkIpc = (mainWindow) => {
continue;
}
const requestData = request.mode == 'file'? "<request body redacted>": (typeof request?.data === 'string' ? request?.data : safeStringifyJSON(request?.data));
const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request);
let requestSent = {
url: request.url,
method: request.method,
headers: request.headers,
data: requestData
}
if (requestData) {
requestSent.dataBuffer = Buffer.from(requestData);
data: requestData,
dataBuffer: requestDataBuffer
}
// todo:

View File

@@ -1,6 +1,7 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash');
const FormData = require('form-data');
const { mockDataFunctions } = require('./faker-functions');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -13,6 +14,14 @@ const getContentType = (headers = {}) => {
return contentType;
};
const interpolateMockVars = (str) => {
const patternRegex = /\{\{\$(\w+)\}\}/g;
return str.replace(patternRegex, (match, keyword) => {
const replacement = mockDataFunctions[keyword]?.();
return replacement || match;
});
};
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
@@ -55,7 +64,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}
};
return interpolate(str, combinedVars);
return interpolateMockVars(interpolate(str, combinedVars));
};
request.url = _interpolate(request.url);
@@ -239,7 +248,6 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
request.wsse.password = _interpolate(request.wsse.password) || '';
}
// interpolate vars for ntlmConfig auth
if (request.ntlmConfig) {
request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';

View File

@@ -1,5 +1,6 @@
const { customAlphabet } = require('nanoid');
const iconv = require('iconv-lite');
const { cloneDeep } = require('lodash');
// a customized version of nanoid without using _ and -
const uuid = () => {
@@ -26,10 +27,24 @@ const parseJson = async (obj) => {
}
};
const safeStringifyJSON = (data) => {
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
};
};
const safeStringifyJSON = (data, indent = null) => {
if (data === undefined) return undefined;
try {
return JSON.stringify(data);
// getCircularReplacer - removes circular references that cause an error when stringifying
return JSON.stringify(data, getCircularReplacer(), indent);
} catch (e) {
console.warn('Failed to stringify data:', e.message);
return data;
}
};
@@ -112,6 +127,16 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
return { data, dataBuffer };
};
const parseDataFromRequest = (request) => {
const requestDataString = request.mode == 'file'? "<request body redacted>": (typeof request?.data === 'string' ? request?.data : safeStringifyJSON(request?.data));
const requestCopy = cloneDeep(request);
if (!requestCopy.data) {
return { data: null, dataBuffer: null };
}
requestCopy.data = requestDataString;
return parseDataFromResponse(requestCopy);
};
module.exports = {
uuid,
stringifyJson,
@@ -121,5 +146,6 @@ module.exports = {
simpleHash,
generateUidBasedOnHash,
flattenDataForDotNotation,
parseDataFromResponse
parseDataFromResponse,
parseDataFromRequest
};

View File

@@ -42,6 +42,10 @@ const isTokenExpired = (credentials) => {
return Date.now() > expiryTime;
};
const safeParseJSONBuffer = (data) => {
return safeParseJSON(Buffer.isBuffer(data) ? data.toString() : data);
}
// AUTHORIZATION CODE
const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
@@ -143,68 +147,46 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
try {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
// Interceptor to capture request data
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
let responseInfo, parsedResponseData;
try {
const response = await axiosInstance(requestCopy);
parsedResponseData = safeParseJSONBuffer(response.data);
responseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
status: response?.status,
statusText: response?.statusText,
headers: response?.headers,
data: parsedResponseData,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
}
catch(error) {
if (error.response) {
axiosResponseInfo = {
responseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
status: error?.response?.status,
statusText: error?.response?.statusText,
headers: error?.response?.headers,
data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
};
}
else if(error?.code) {
axiosResponseInfo = {
responseInfo = {
status: '-',
statusText: error.code,
statusText: error?.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data?.toString() : response.data
);
}
// Ensure debugInfo.data is initialized
if (!debugInfo) {
debugInfo = { data: [] };
@@ -216,33 +198,32 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
url: url,
method: 'POST',
headers: requestCopy?.headers,
data: requestCopy?.data,
error: null
},
response: {
url: axiosResponseInfo?.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
error: axiosResponseInfo?.error,
timeline: axiosResponseInfo?.timeline
url: responseInfo?.url,
headers: responseInfo?.headers,
data: responseInfo?.data,
status: responseInfo?.status,
statusText: responseInfo?.statusText,
error: responseInfo?.error,
timeline: responseInfo?.timeline
},
fromCache: false,
completed: true,
requests: [], // No sub-requests in this context
};
debugInfo.data.push(axiosMainRequest);
persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
} catch (error) {
return Promise.reject(safeStringifyJSON(error?.response?.data));
return Promise.reject(error);
}
};
@@ -369,96 +350,79 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
let debugInfo = { data: [] };
try {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
let responseInfo, parsedResponseData;
try {
const response = await axiosInstance(requestCopy);
parsedResponseData = safeParseJSONBuffer(response.data);
responseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
status: response?.status,
statusText: response?.statusText,
headers: response?.headers,
data: parsedResponseData,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
}
catch(error) {
if (error.response) {
axiosResponseInfo = {
responseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
status: error?.response?.status,
statusText: error?.response?.statusText,
headers: error?.response?.headers,
data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
};
}
else if(error?.code) {
axiosResponseInfo = {
responseInfo = {
status: '-',
statusText: error.code,
statusText: error?.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data.toString() : response.data
);
}
if (!debugInfo) {
debugInfo = { data: [] };
} else if (!debugInfo.data) {
debugInfo.data = [];
}
// Add the axios request and response info as a main request in debugInfo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
url: url,
method: 'POST',
headers: requestCopy?.headers,
data: requestCopy?.data,
error: null
},
response: {
url: axiosResponseInfo.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
timeline: axiosResponseInfo?.timeline,
error: null
url: responseInfo?.url,
headers: responseInfo?.headers,
data: responseInfo?.data,
status: responseInfo?.status,
statusText: responseInfo?.statusText,
error: responseInfo?.error,
timeline: responseInfo?.timeline
},
fromCache: false,
completed: true,
requests: [], // No sub-requests in this context
};
debugInfo.data.push(axiosMainRequest);
persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
} catch (error) {
return Promise.reject(safeStringifyJSON(error?.response?.data));
@@ -557,95 +521,79 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid,
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
let debugInfo = { data: [] };
try {
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
let responseInfo, parsedResponseData;
try {
const response = await axiosInstance(requestCopy);
parsedResponseData = safeParseJSONBuffer(response.data);
responseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
status: response?.status,
statusText: response?.statusText,
headers: response?.headers,
data: parsedResponseData,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
}
catch(error) {
if (error.response) {
axiosResponseInfo = {
responseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
status: error?.response?.status,
statusText: error?.response?.statusText,
headers: error?.response?.headers,
data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
};
}
else if(error?.code) {
axiosResponseInfo = {
responseInfo = {
status: '-',
statusText: error.code,
statusText: error?.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data.toString() : response.data
);
}
if (!debugInfo) {
debugInfo = { data: [] };
} else if (!debugInfo.data) {
debugInfo.data = [];
}
// Add the axios request and response info as a main request in debugInfo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
url: url,
method: 'POST',
headers: requestCopy?.headers,
data: requestCopy?.data,
error: null
},
response: {
url: axiosResponseInfo?.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
timeline: axiosResponseInfo?.timeline,
error: null
url: responseInfo?.url,
headers: responseInfo?.headers,
data: responseInfo?.data,
status: responseInfo?.status,
statusText: responseInfo?.statusText,
error: responseInfo?.error,
timeline: responseInfo?.timeline
},
fromCache: false,
completed: true,
requests: [], // No sub-requests in this context
};
debugInfo.data.push(axiosMainRequest);
persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
} catch (error) {
return Promise.reject(safeStringifyJSON(error?.response?.data));
@@ -677,101 +625,82 @@ const refreshOauth2Token = async ({ requestCopy, collectionUid, certsAndProxyCon
requestCopy.data = qs.stringify(data);
requestCopy.url = url;
requestCopy.responseType = 'arraybuffer';
// Initialize variables to hold request and response data for debugging
let axiosRequestInfo = null;
let axiosResponseInfo = null;
let debugInfo = { data: [] };
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
axiosInstance.interceptors.request.use((config) => {
const requestData = typeof config?.data === 'string' ? config?.data : safeStringifyJSON(config?.data);
axiosRequestInfo = {
method: config.method.toUpperCase(),
url: config.url,
headers: config.headers,
data: requestData,
timestamp: Date.now(),
};
return config;
});
// Interceptor to capture response data
axiosInstance.interceptors.response.use((response) => {
axiosResponseInfo = {
url: response?.url,
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
timestamp: Date.now(),
timeline: response?.timeline
};
return response;
}, (error) => {
if (error.response) {
axiosResponseInfo = {
url: error?.response?.url,
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: 'fetching access token failed! check timeline network logs'
};
}
else if(error?.code) {
axiosResponseInfo = {
status: '-',
statusText: error.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
return axiosResponseInfo;
});
try {
const response = await axiosInstance(requestCopy);
const parsedResponseData = safeParseJSON(
Buffer.isBuffer(response.data) ? response.data.toString() : response.data
);
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
let responseInfo, parsedResponseData;
try {
const response = await axiosInstance(requestCopy);
parsedResponseData = safeParseJSONBuffer(response.data);
responseInfo = {
url: response?.url,
status: response?.status,
statusText: response?.statusText,
headers: response?.headers,
data: parsedResponseData,
timestamp: Date.now(),
timeline: response?.timeline
};
}
catch(error) {
if (error.response) {
responseInfo = {
url: error?.response?.url,
status: error?.response?.status,
statusText: error?.response?.statusText,
headers: error?.response?.headers,
data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
timestamp: Date.now(),
timeline: error?.response?.timeline,
error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
};
}
else if(error?.code) {
responseInfo = {
status: '-',
statusText: error?.code,
headers: error?.config?.headers,
data: safeStringifyJSON(error?.errors),
timeline: error?.response?.timeline
};
}
}
if (!debugInfo) {
debugInfo = { data: [] };
} else if (!debugInfo.data) {
debugInfo.data = [];
}
// Add the axios request and response info as a main request in debugInfo
const axiosMainRequest = {
requestId: Date.now().toString(),
request: {
url: axiosRequestInfo?.url,
method: axiosRequestInfo?.method,
headers: axiosRequestInfo?.headers || {},
data: axiosRequestInfo?.data,
url: url,
method: 'POST',
headers: requestCopy?.headers,
data: requestCopy?.data,
error: null
},
response: {
url: axiosResponseInfo?.url,
headers: axiosResponseInfo?.headers,
data: parsedResponseData,
status: axiosResponseInfo?.status,
statusText: axiosResponseInfo?.statusText,
timeline: axiosResponseInfo?.timeline,
error: null
url: responseInfo?.url,
headers: responseInfo?.headers,
data: responseInfo?.data,
status: responseInfo?.status,
statusText: responseInfo?.statusText,
error: responseInfo?.error,
timeline: responseInfo?.timeline
},
fromCache: false,
completed: true,
requests: [], // No sub-requests in this context
};
debugInfo.data.push(axiosMainRequest);
if (parsedResponseData?.error) {
if (!parsedResponseData || parsedResponseData?.error) {
clearOauth2Credentials({ collectionUid, url, credentialsId });
return { collectionUid, url, credentials: null, credentialsId, debugInfo };
}
persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
} catch (error) {
clearOauth2Credentials({ collectionUid, url, credentialsId });

View File

@@ -168,10 +168,21 @@ function createTimelineAgentClass(BaseAgentClass) {
message: `Trying ${host}:${port}...`,
});
const socket = super.createConnection(options, callback);
let socket;
try {
socket = super.createConnection(options, callback);
} catch (error) {
this.timeline.push({
timestamp: new Date(),
type: 'error',
message: `Error creating connection: ${error.message}`,
});
error.timeline = this.timeline;
throw error;
}
// Attach event listeners to the socket
socket.on('lookup', (err, address, family, host) => {
socket?.on('lookup', (err, address, family, host) => {
if (err) {
this.timeline.push({
timestamp: new Date(),
@@ -187,7 +198,7 @@ function createTimelineAgentClass(BaseAgentClass) {
}
});
socket.on('connect', () => {
socket?.on('connect', () => {
const address = socket.remoteAddress || host;
const remotePort = socket.remotePort || port;
@@ -198,7 +209,7 @@ function createTimelineAgentClass(BaseAgentClass) {
});
});
socket.on('secureConnect', () => {
socket?.on('secureConnect', () => {
const protocol = socket.getProtocol() || 'SSL/TLS';
const cipher = socket.getCipher();
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
@@ -270,7 +281,7 @@ function createTimelineAgentClass(BaseAgentClass) {
}
});
socket.on('error', (err) => {
socket?.on('error', (err) => {
this.timeline.push({
timestamp: new Date(),
type: 'error',
@@ -294,6 +305,10 @@ function setupProxyAgents({
// Ensure TLS options are properly set
const tlsOptions = {
...httpsAgentRequestFields,
// Enable all secure protocols by default
secureProtocol: undefined,
// Allow Node.js to choose the protocol
minVersion: 'TLSv1',
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
};

View File

@@ -1,4 +1,5 @@
const { get } = require('@usebruno/query');
const _ = require('lodash');
class BrunoResponse {
constructor(res) {
@@ -46,8 +47,9 @@ class BrunoResponse {
return;
}
this.body = data;
this.res.data = data;
const clonedData = _.cloneDeep(data);
this.res.data = clonedData;
this.body = clonedData;
}
}

View File

@@ -193,6 +193,11 @@ class ScriptRuntime {
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
const additionalContextRootsAbsolute = lodash
.chain(additionalContextRoots)
.map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
.value();
const whitelistedModules = {};
@@ -255,7 +260,7 @@ class ScriptRuntime {
context: 'sandbox',
builtin: [ "*" ],
external: true,
root: [collectionPath],
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs
path,

View File

@@ -7,8 +7,9 @@ auth:oauth2 {
callback_url: {{key-host}}/realms/bruno/account
authorization_url: {{key-host}}/realms/bruno/protocol/openid-connect/auth
access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
refresh_token_url:
client_id: account
client_secret: Lh3NkRikMZpO12rwSBwVimde9v89B5Rw
client_secret: {{client_secret}}
scope: openid
state:
pkce: true
@@ -16,5 +17,6 @@ auth:oauth2 {
credentials_id: credentials
token_placement: header
token_header_prefix: Bearer
reuse_token:
auto_fetch_token: true
auto_refresh_token: false
}

View File

@@ -1,21 +1,6 @@
vars {
host: http://localhost:8081
bearer_auth_token: your_secret_token
basic_auth_password: della
client_id: client_id_1
client_secret: client_secret_1
password_credentials_access_token_url: http://localhost:8081/api/auth/oauth2/password_credentials/token
password_credentials_username: foo
password_credentials_password: bar
password_credentials_scope:
authorization_code_authorize_url: http://localhost:8081/api/auth/oauth2/authorization_code/authorize
authorization_code_callback_url: http://localhost:8081/api/auth/oauth2/authorization_code/callback
authorization_code_access_token_url: http://localhost:8081/api/auth/oauth2/authorization_code/token
authorization_code_access_token: null
client_credentials_access_token_url: http://localhost:8081/api/auth/oauth2/client_credentials/token
client_credentials_client_id: client_id_1
client_credentials_client_secret: client_secret_1
client_credentials_scope: admin
client_credentials_access_token: 870132a2ed28a3c94d34f868e6514720
key-host: http://localhost:8080
}
vars:secret [
client_secret
]

View File

@@ -17,7 +17,7 @@ auth:oauth2 {
access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
refresh_token_url:
client_id: account
client_secret: Lh3NkRikMZpO12rwSBwVimde9v89B5Rw
client_secret: {{client_secret}}
scope: openid
state:
pkce: true

View File

@@ -3,18 +3,16 @@ auth {
}
auth:oauth2 {
grant_type: authorization_code
callback_url: {{key-host}}/realms/bruno/account
authorization_url: {{key-host}}/realms/bruno/protocol/openid-connect/auth
grant_type: client_credentials
access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
refresh_token_url:
client_id: account
client_secret: Lh3NkRikMZpO12rwSBwVimde9v89B5Rw
client_secret: {{client_secret}}
scope: openid
state:
pkce: true
tokenId: keycloak
tokenPlacement: header
tokenHeaderPrefix: Bearer
tokenQueryKey: access_token
reuseToken:
credentials_placement: body
credentials_id: credentials
token_placement: header
token_header_prefix: Bearer
auto_fetch_token: true
auto_refresh_token: false
}

View File

@@ -1,22 +1,6 @@
vars {
host: http://localhost:8080
bearer_auth_token: your_secret_token
basic_auth_password: della
client_id: client_id_1
client_secret: client_secret_1
password_credentials_access_token_url: http://localhost:8081/api/auth/oauth2/password_credentials/token
password_credentials_username: foo
password_credentials_password: bar
password_credentials_scope:
authorization_code_authorize_url: http://localhost:8081/api/auth/oauth2/authorization_code/authorize
authorization_code_callback_url: http://localhost:8081/api/auth/oauth2/authorization_code/callback
authorization_code_access_token_url: http://localhost:8081/api/auth/oauth2/authorization_code/token
authorization_code_access_token: null
client_credentials_access_token_url: http://localhost:8081/api/auth/oauth2/client_credentials/token
client_credentials_client_id: client_id_1
client_credentials_client_secret: client_secret_1
client_credentials_scope: admin
client_credentials_access_token: 870132a2ed28a3c94d34f868e6514720
key-host: http://localhost:8080
key-host-1: http://localhost:8082
}
vars:secret [
client_secret
]

View File

@@ -11,5 +11,5 @@ get {
}
auth:bearer {
token: {{$oauth2.keycloak.access_token}}
token: {{$oauth2.credentials.access_token}}
}

View File

@@ -13,12 +13,14 @@ get {
auth:oauth2 {
grant_type: client_credentials
access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
refresh_token_url:
client_id: account
client_secret: Lh3NkRikMZpO12rwSBwVimde9v89B5Rw
client_secret: {{client_secret}}a
scope: openid
credentials_placement: body
credentials_id: credentials
token_placement: header
token_header_prefix: Bearer
reuse_token:
auto_fetch_token: true
auto_refresh_token: false
}

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "keycloak-password-credentials",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,20 @@
auth {
mode: oauth2
}
auth:oauth2 {
grant_type: password
access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
refresh_token_url:
username: bruno
password: bruno
client_id: account
client_secret: {{client_secret}}
scope: openid
credentials_placement: body
credentials_id: credentials
token_placement: header
token_header_prefix: Bearer
auto_fetch_token: true
auto_refresh_token: false
}

View File

@@ -0,0 +1,6 @@
vars {
key-host: http://localhost:8080
}
vars:secret [
client_secret
]

View File

@@ -0,0 +1,11 @@
meta {
name: user_info_coll-auth
type: http
seq: 1
}
get {
url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
body: none
auth: inherit
}

View File

@@ -0,0 +1,15 @@
meta {
name: user_info_custom
type: http
seq: 2
}
get {
url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
body: none
auth: bearer
}
auth:bearer {
token: {{$oauth2.credentials.access_token}}
}

View File

@@ -0,0 +1,28 @@
meta {
name: user_info_request-auth
type: http
seq: 3
}
get {
url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
body: none
auth: oauth2
}
auth:oauth2 {
grant_type: password
access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
refresh_token_url:
username: admin
password: admin
client_id: account
client_secret: {{client_secret}}
scope: openid
credentials_placement: body
credentials_id: credentials
token_placement: header
token_header_prefix: Bearer
auto_fetch_token: true
auto_refresh_token: false
}

View File

@@ -74,6 +74,7 @@ async function setup() {
execCommand('npm run build:graphql-docs', 'Building graphql-docs');
execCommand('npm run build:bruno-query', 'Building bruno-query');
execCommand('npm run build:bruno-common', 'Building bruno-common');
execCommand('npm run build:bruno-converters', 'Building bruno-converters');
// Bundle JS sandbox libraries
execCommand(