Compare commits

...

23 Commits

Author SHA1 Message Date
Anoop M D
5fc32d035f wip: working on async scripting support 2023-03-28 14:49:57 +05:30
Anoop M D
78251c530c feat: added custom assertion for chaijs for match() method 2023-03-23 21:36:35 +05:30
Anoop M D
dea95664b9 fix: fixed issue in bru cli where assertions was not being run 2023-03-23 21:35:41 +05:30
Anoop M D
fbc6e7bff5 Merge pull request #135 from dcoomber/bugfix/132-isjson-assertion
Resolve issue with to.be.json assertions Re #132
2023-03-23 14:11:55 +05:30
David Coomber
4884106aaa Removed chai-http Re #132 2023-03-22 22:12:17 +02:00
David Coomber
5c15438949 Updated plugin to be addProperty Re #132 2023-03-22 20:56:35 +02:00
Anoop M D
b53a9eaee9 Merge pull request #134 from dcoomber/bugfix/128-close-tab-hotkey
Proposed addition of CMD+W hotkey Re #128
2023-03-21 22:26:28 +05:30
David Coomber
5899ca446d Applied code review feedback Re #128 2023-03-21 17:45:26 +02:00
David Coomber
d21e7f6fb5 Added Chai.js plugin to cater for isJson assertion Re #132 2023-03-21 17:30:45 +02:00
Anoop M D
ee8a3eae8c Merge pull request #130 from dcoomber/bugfix/request-dialog-terminology
Proposed adjustment to terminology on requests
2023-03-21 01:34:19 +05:30
Anoop M D
fac5109242 Merge pull request #136 from dcoomber/bugfix/dev-docs
Corrected reference to bruno-query node script
2023-03-21 01:33:24 +05:30
David Coomber
47dfbd2a64 Corrected reference to bruno-query node script 2023-03-19 21:14:52 +02:00
David Coomber
074d72d885 Add chai-http to enable to.be.json assertions Re #132 2023-03-19 21:08:19 +02:00
David Coomber
8c29d131e2 Proposed addition of CMD+W hotkey Re #128 2023-03-19 18:38:08 +02:00
David Coomber
437044bdcd Applied code review feedback 2023-03-19 17:17:38 +02:00
Anoop M D
2120a562da chore: improved dev documentation 2023-03-19 15:41:18 +05:30
Anoop M D
04c3c2dbf1 Merge pull request #133 from bharathbdev/bugfix/assertion-result-issue
Bugfix/assertion result issue
2023-03-19 14:57:19 +05:30
David Coomber
1d03e1d5ea Adjusted terminology on requests (REST, GraphQL, Form URL encoded) 2023-03-18 10:53:49 +02:00
Bharath B
2b174e1c60 added the indentation 2023-03-18 13:43:16 +05:30
Bharath B
7a2b32069e bugfix/assertion-result-issue fixed the issue related to assertions still displayed in Tests tab after deletion#121 2023-03-18 12:06:20 +05:30
Anoop M D
a9e6c3a35c feat: support for importing insomnia collections (#74) 2023-03-05 00:19:03 +05:30
Anoop M D
e6a754b933 Merge pull request #108 from ajaishankar/feature/object-predicate
filter shortcut for scalar properties
2023-02-27 21:32:59 +05:30
Ajai Shankar
ee4509f037 feat(query): simple object predicate for scalar properties 2023-02-26 12:56:11 -06:00
17 changed files with 338 additions and 36 deletions

View File

@@ -15,8 +15,12 @@ nvm use
npm i --legacy-peer-deps
# build graphql docs
# note: you can for now ignore the error thrown while building the graphql docs
npm run build:graphql-docs
# build bruno query
npm run build:bruno-query
# run next app (terminal 1)
npm run dev:web

View File

@@ -32,7 +32,7 @@ const useGraphqlSchema = (endpoint, environment) => {
setSchema(buildClientSchema(s.data));
setIsLoading(false);
localStorage.setItem(localStorageKey, JSON.stringify(s.data));
toast.success('Graphql Schema loaded successfully');
toast.success('GraphQL Schema loaded successfully');
} else {
return Promise.reject(new Error('An error occurred while introspecting schema'));
}
@@ -40,7 +40,7 @@ const useGraphqlSchema = (endpoint, environment) => {
.catch((err) => {
setIsLoading(false);
setError(err);
toast.error('Error occured while loading Graphql Schema');
toast.error('Error occured while loading GraphQL Schema');
});
};

View File

@@ -52,7 +52,7 @@ const RequestBodyMode = ({ item, collection }) => {
onModeChange('formUrlEncoded');
}}
>
Form Url Encoded
Form URL Encoded
</div>
<div className="label-item font-medium">Raw</div>
<div

View File

@@ -1,6 +1,7 @@
import React from 'react';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
@@ -21,6 +22,14 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then((collection) => {
handleSubmit(collection);
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<div>
@@ -36,6 +45,12 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
>
Postman Collection
</div>
<div
className='text-link hover:underline cursor-pointer mt-2'
onClick={handleImportInsomniaCollection}
>
Insomnia Collection
</div>
</div>
</Modal>
);

View File

@@ -102,7 +102,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
checked={formik.values.requestType === 'http-request'}
/>
<label htmlFor="http-request" className="ml-1 cursor-pointer select-none">
Http
HTTP
</label>
<input
@@ -118,7 +118,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
checked={formik.values.requestType === 'graphql-request'}
/>
<label htmlFor="graphql-request" className="ml-1 cursor-pointer select-none">
Graphql
GraphQL
</label>
</div>
</div>
@@ -145,7 +145,7 @@ const NewRequest = ({ collection, item, isEphermal, onClose }) => {
<div className="mt-4">
<label htmlFor="request-url" className="block font-semibold">
Url
URL
</label>
<div className="flex items-center mt-2 ">

View File

@@ -10,6 +10,7 @@ import NewRequest from 'components/Sidebar/NewRequest';
import BrunoSupport from 'components/BrunoSupport';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
export const HotkeysContext = React.createContext();
@@ -144,6 +145,23 @@ export const HotkeysProvider = (props) => {
};
}, [setShowNewRequestModal]);
// close tab hotkey
useEffect(() => {
Mousetrap.bind(['command+w', 'ctrl+w'], (e) => {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+w', 'ctrl+w']);
};
}, [activeTabUid]);
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showBrunoSupportModal && <BrunoSupport onClose={() => setShowBrunoSupportModal(false)} />}

View File

@@ -433,7 +433,7 @@ export const humanizeRequestBodyMode = (mode) => {
break;
}
case 'formUrlEncoded': {
label = 'Form Url Encoded';
label = 'Form URL Encoded';
break;
}
case 'multipartForm': {

View File

@@ -0,0 +1,186 @@
import each from 'lodash/each';
import get from 'lodash/get';
import fileDialog from 'file-dialog';
import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const 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 transformInsomniaRequestItem = (request) => {
const brunoRequestItem = {
uid: uuid(),
name: request.name,
type: 'http-request',
request: {
url: request.url,
method: request.method,
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,
enabled: !param.disabled
});
});
const mimeType = get(request, 'body.mimeType', '');
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(),
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') {
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 = JSON.parse(data);
const insomniaResources = get(insomniaExport, 'resources', []);
const insomniaCollection = insomniaResources.find(resource => resource._type === 'workspace' && resource.scope === 'collection');
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) => {
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === folder._id);
return {
uid: uuid(),
name: folder.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: 'application/json' })
.then(readFile)
.then(parseInsomniaCollection)
.then(transformItemsInCollection)
.then(hydrateSeqInCollection)
.then(validateSchema)
.then((collection) => resolve(collection))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
});
});
};
export default importCollection;

View File

@@ -60,7 +60,7 @@ const runSingleRequest = async function (filename, bruJson, collectionPath, coll
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assert');
const assertions = get(bruJson, 'request.assertions');
if(assertions && assertions.length) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(assertions, request, response, envVariables, collectionVariables, collectionPath);

View File

@@ -42,7 +42,7 @@ const bruToJson = (bru) => {
"headers": _.get(json, "headers", []),
"body": _.get(json, "body", {}),
"vars": _.get(json, "vars", []),
"assert": _.get(json, "assert", []),
"assertions": _.get(json, "assertions", []),
"script": _.get(json, "script", ""),
"tests": _.get(json, "tests", "")
}

View File

@@ -113,7 +113,7 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
const requestScript = get(request, 'script.req');
if(requestScript && requestScript.length) {
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
const result = await scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
@@ -169,16 +169,14 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
// run assertions
const assertions = get(request, 'assertions');
if(assertions && assertions.length) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(assertions, request, response, envVars, collectionVariables, collectionPath);
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(assertions, request, response, envVars, collectionVariables, collectionPath);
mainWindow.webContents.send('main:assertion-results', {
results: results,
itemUid: item.uid,
collectionUid
});
}
mainWindow.webContents.send('main:assertion-results', {
results: results,
itemUid: item.uid,
collectionUid
});
// run tests
const testFile = get(item, 'request.tests');

View File

@@ -14,8 +14,8 @@
},
"dependencies": {
"@usebruno/query": "0.1.0",
"atob": "^2.1.2",
"ajv": "^8.12.0",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"crypto-js": "^4.1.1",
"json-query": "^2.2.2",
@@ -24,4 +24,4 @@
"nanoid": "3.3.4",
"uuid": "^9.0.0"
}
}
}

View File

@@ -6,6 +6,38 @@ const BrunoRequest = require('../bruno-request');
const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
const { expect } = chai;
chai.use(function (chai, utils) {
// Custom assertion for checking if a variable is JSON
chai.Assertion.addProperty('json', function () {
const obj = this._obj;
const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object;
this.assert(
isJson,
`expected ${utils.inspect(obj)} to be JSON`,
`expected ${utils.inspect(obj)} not to be JSON`
);
});
});
// Custom assertion for matching regex
chai.use(function (chai, utils) {
chai.Assertion.addMethod('match', function (regex) {
const obj = this._obj;
let match = false;
if(obj === undefined) {
match = false;
} else {
match = regex.test(obj);
}
this.assert(
match,
`expected ${utils.inspect(obj)} to match ${regex}`,
`expected ${utils.inspect(obj)} not to match ${regex}`
);
});
});
/**
* Assertion operators
@@ -92,7 +124,7 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
return;
}
// gracefulle allyow both a,b as well as [a, b]
// gracefully allow both a,b as well as [a, b]
if(operator === 'in' || operator === 'notIn') {
if(rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) {
rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
@@ -267,4 +299,4 @@ class AssertRuntime {
}
}
module.exports = AssertRuntime;
module.exports = AssertRuntime;

View File

@@ -1,5 +1,12 @@
const { NodeVM } = require('vm2');
const path = require('path');
const http = require('http');
const https = require('https');
const stream = require('stream');
const util = require('util');
const zlib = require('zlib');
const url = require('url');
const punycode = require('punycode');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
@@ -11,13 +18,14 @@ const lodash = require('lodash');
const moment = require('moment');
const uuid = require('uuid');
const nanoid = require('nanoid');
const axios = require('axios');
const CryptoJS = require('crypto-js');
class ScriptRuntime {
constructor() {
}
runRequestScript(script, request, envVariables, collectionVariables, collectionPath) {
async runRequestScript(script, request, envVariables, collectionVariables, collectionPath) {
const bru = new Bru(envVariables, collectionVariables);
const req = new BrunoRequest(request);
@@ -32,18 +40,36 @@ class ScriptRuntime {
external: true,
root: [collectionPath],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
atob,
btoa,
lodash,
moment,
uuid,
nanoid,
axios,
'crypto-js': CryptoJS
}
}
});
vm.run(script, path.join(collectionPath, 'vm.js'));
// wrap script inside a async function that gets called
script = `return (async () => { ${script} })()`;
// bug that needs to be fixed
// vm.run is not awaiting the async function
// created an issue in vm2 repo: https://github.com/patriksimek/vm2/issues/513
const result = await vm.run(script, path.join(collectionPath, 'vm.js'));
console.log(result);
return {
request,

View File

@@ -18,6 +18,10 @@ Array filtering [?] with corresponding filter function
```js
get(data, '..items[?].amount', i => i.amount > 20)
```
Array filtering [?] with simple object predicate, same as (i => i.id === 2 && i.amount === 20)
```js
get(data, '..items[?]', { id: 2, amount: 20 })
```
Array mapping [?] with corresponding mapper function
```js
get(data, '..items[?].amount', i => i.amount + 10)
@@ -26,4 +30,4 @@ get(data, '..items[?].amount', i => i.amount + 10)
### Publish to Npm Registry
```bash
npm publish --access=public
```
```

View File

@@ -19,11 +19,11 @@ function normalize(value: any) {
/**
* Gets value of a prop from source.
*
* If source is an array get value for each item.
* If source is an array get value from each item.
*
* If deep is true then recursively gets values for prop in nested objects.
*
* Once a value if found will not recurese further into that value.
* Once a value is found will not recurse further into that value.
*/
function getValue(source: any, prop: string, deep = false): any {
if (typeof source !== 'object') return;
@@ -47,14 +47,27 @@ function getValue(source: any, prop: string, deep = false): any {
return normalize(value);
}
type PredicateOrMapper = (obj: any) => any;
type PredicateOrMapper = ((obj: any) => any) | Record<string, any>;
/**
* Make a predicate function that checks scalar properties for equality
*/
function objectPredicate(obj: Record<string, any>) {
return (item: any) => {
for (const [key, value] of Object.entries(obj)) {
if (item[key] !== value) return false;
}
return true;
};
}
/**
* Apply filter on source array or object
*
* If the filter returns a non boolean non null value it is treated as a mapped value
*/
function filterOrMap(source: any, fun: PredicateOrMapper) {
function filterOrMap(source: any, funOrObj: PredicateOrMapper) {
const fun = typeof funOrObj === 'object' ? objectPredicate(funOrObj) : funOrObj;
const isArray = Array.isArray(source);
const list = isArray ? source : [source];
const result = [] as any[];
@@ -67,7 +80,7 @@ function filterOrMap(source: any, fun: PredicateOrMapper) {
result.push(value); // mapper
}
}
return isArray ? result : result[0];
return normalize(isArray ? result : result[0]);
}
/**
@@ -89,7 +102,11 @@ function filterOrMap(source: any, fun: PredicateOrMapper) {
* ```js
* get(data, '..items[?].amount', i => i.amount > 20)
* ```
* 5. Array mapping [?] with corresponding mapper function
* 5. Array filtering [?] with simple object predicate, same as (i => i.id === 2 && i.amount === 20)
* ```js
* get(data, '..items[?]', { id: 2, amount: 20 })
* ```
* 6. Array mapping [?] with corresponding mapper function
* ```js
* get(data, '..items[?].amount', i => i.amount + 10)
* ```
@@ -121,7 +138,7 @@ export function get(source: any, path: string, ...fns: PredicateOrMapper[]) {
source = filterOrMap(source, fun);
break;
case typeof token === 'number':
source = source[token];
source = normalize(source[token]);
break;
default:
source = getValue(source, token as string, lookbehind === "..");
@@ -131,4 +148,4 @@ export function get(source: any, path: string, ...fns: PredicateOrMapper[]) {
}
return source;
}
}

View File

@@ -22,7 +22,7 @@ const data = {
{ id: 4, amount: 40 }
]
}
]
],
},
};
@@ -48,11 +48,13 @@ describe("get", () => {
// filter and map
it.each([
["..items[?].amount", [40], (i: any) => i.amount > 30], // [?] filter
["..items[?].amount", [40], { id: 4, amount: 40 }], // object filter
["..items[?].amount", undefined, { id: 5, amount: 40 }],
["..items..amount[?][0]", 40, (amt: number) => amt > 30],
["..items..amount[0][?]", undefined, (amt: number) => amt > 30], // filter on single value
["..items..amount[?]", [11, 21, 31, 41], (amt: number) => amt + 1], // [?] mapper
["..items..amount[0][?]", 11, (amt: number) => amt + 1], // [?] map on single value
])("%s should be %j %s", (expr, result, filter) => {
])("%s should be %j for %s", (expr, result, filter) => {
expect(get(data, expr, filter)).toEqual(result);
});