feat: bru.sendRequest api (#4867)

* feat: bru.sendRequest api

* updated the postman-translations logic to handle `pm.sendRequest` to `bru.sendRequest` translations, and added unit tests

* ~ removed `maxRedirects` and `proxy` values for sendRequest axios-instance
~ fixed the imports for the `send-request-transformer` function
~ `sendRequest` and `runRequest` will return same response object in both safe and developer mode
~ sendRequest function optimization

* revert sendRequest to async function, added a testcase for sendRequest with url string

* sendRequest callback errors handling

* updated tests and added await for the callbacks

---------

Co-authored-by: lohit <lohit@usebruno.com>
This commit is contained in:
lohit
2025-06-14 22:18:31 +05:30
committed by GitHub
parent a7ba23d97e
commit f03047a2f9
17 changed files with 1164 additions and 7 deletions

14
package-lock.json generated
View File

@@ -32748,7 +32748,8 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@types/qs": "^6.9.18"
"@types/qs": "^6.9.18",
"axios": "^1.9.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
@@ -32761,6 +32762,17 @@
"typescript": "^4.8.4"
}
},
"packages/bruno-requests/node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"packages/bruno-schema": {
"name": "@usebruno/schema",
"version": "0.7.0",

View File

@@ -1,3 +1,4 @@
import sendRequestTransformer from './send-request-transformer';
const j = require('jscodeshift');
const cloneDeep = require('lodash/cloneDeep');
@@ -99,7 +100,14 @@ const simpleTranslations = {
* as a separate statement, which allows a single Postman expression to be
* transformed into multiple Bruno statements (e.g. for complex assertions).
*/
const complexTransformations = [
// pm.sendRequest transformation
{
pattern: 'pm.sendRequest',
transform: sendRequestTransformer
},
// pm.environment.has requires special handling
{
pattern: 'pm.environment.has',

View File

@@ -0,0 +1,284 @@
/**
* Convert Postman header array format to Bruno headers object
* @param {Object} j - jscodeshift API
* @param {Object} arrayValue - Array expression of key-value pair objects
* @returns {Object} - Object expression with key-value pairs
*/
const convertArrayToObject = (j, arrayValue) => {
const obj = j.objectExpression([]);
if (arrayValue.type === 'ArrayExpression') {
arrayValue.elements.forEach(elem => {
if (elem.type === 'ObjectExpression') {
const keyProp = elem.properties.find(p => (p.key.name === 'key' || p.key.value === 'key'));
const valueProp = elem.properties.find(p => (p.key.name === 'value' || p.key.value === 'value'));
if (keyProp && valueProp) {
obj.properties.push(
j.property(
'init',
j.literal(keyProp.value.value),
valueProp.value
)
);
}
}
});
}
return obj;
};
/**
* Add or update a specific header in the request options
* @param {Object} j - jscodeshift API
* @param {Object} requestOptions - Request options object
* @param {string} headerName - Header name to add/update
* @param {string} headerValue - Header value
*/
const addOrUpdateHeader = (j, requestOptions, headerName, headerValue) => {
let headersProp = requestOptions.properties.find(p => (p.key.name === 'headers' || p.key.value === 'headers'));
if (!headersProp) {
headersProp = j.property('init', j.identifier('headers'), j.objectExpression([]));
requestOptions.properties.push(headersProp);
} else if (headersProp.value.type !== 'ObjectExpression') {
headersProp.value = j.objectExpression([]);
}
// filter out existing header with same name (case-insensitive)
headersProp.value.properties = headersProp.value.properties.filter(p =>
p.key.type !== 'Literal' ||
p.key.value.toLowerCase() !== headerName.toLowerCase()
);
headersProp.value.properties.push(
j.property(
'init',
j.literal(headerName),
j.literal(headerValue)
)
);
};
/**
* Transform headers property from array to object format
* @param {Object} j - jscodeshift API
* @param {Object} requestOptions - Request options object
*/
const transformHeaders = (j, requestOptions) => {
if (requestOptions.type !== 'ObjectExpression') return;
requestOptions.properties.forEach(prop => {
// find and rename 'header' property to 'headers'
if (prop.key.name === 'header' || prop.key.value === 'header') {
prop.key.name = 'headers';
prop.key.value = 'headers';
// Handle array of header objects
if (prop.value.type === 'ArrayExpression') {
prop.value = convertArrayToObject(j, prop.value);
}
}
});
};
/**
* Transform body property based on body mode
* @param {Object} j - jscodeshift API
* @param {Object} requestOptions - Request options object
* @returns {Array|null} - Array of statements if formdata is used, null otherwise
*/
const transformBody = (j, requestOptions) => {
if (requestOptions.type !== 'ObjectExpression') return null;
requestOptions.properties.forEach(prop => {
if (prop.key.name === 'body' || prop.key.value === 'body') {
if (prop.value.type === 'ObjectExpression') {
const bodyProps = prop.value.properties;
const modeProp = bodyProps.find(p => (p.key.name === 'mode' || p.key.value === 'mode'));
if (modeProp && modeProp.value.type === 'Literal') {
const bodyMode = modeProp.value.value;
// Handle raw mode (text, json, xml, etc.)
if (bodyMode === 'raw') {
const rawProp = bodyProps.find(p => (p.key.name === 'raw' || p.key.value === 'raw'));
if (rawProp) {
// Replace body with data
prop.key.name = 'data';
prop.key.value = 'data';
prop.value = rawProp.value;
}
}
// Handle urlencoded mode
else if (bodyMode === 'urlencoded') {
const urlencodedProp = bodyProps.find(p => (p.key.name === 'urlencoded' || p.key.value === 'urlencoded') && p.value.type === 'ArrayExpression');
if (urlencodedProp) {
// Replace the body property with a 'data' property
prop.key.name = 'data';
prop.key.value = 'data';
// Transform the urlencoded array to an object
prop.value = convertArrayToObject(j, urlencodedProp.value);
// Add Content-Type header for urlencoded
addOrUpdateHeader(j, requestOptions, 'Content-Type', 'application/x-www-form-urlencoded');
}
}
// Handle formdata mode
else if (bodyMode === 'formdata') {
const formdataProp = bodyProps.find(p => (p.key.name === 'formdata' || p.key.value === 'formdata') && p.value.type === 'ArrayExpression');
if (formdataProp) {
// Replace the body property with a 'data' property
prop.key.name = 'data';
prop.key.value = 'data';
// Transform the urlencoded array to an object
prop.value = convertArrayToObject(j, formdataProp.value);
// Add Content-Type header for urlencoded
addOrUpdateHeader(j, requestOptions, 'Content-Type', 'multipart/form-data');
}
}
}
}
}
});
};
/**
* Transform callback function to Bruno format
* @param {Object} j - jscodeshift API
* @param {Object} callback - Callback function expression
* @returns {Object} - Transformed callback function
*/
const transformCallback = (j, callback) => {
if (!callback || callback.type !== 'FunctionExpression') return null;
const params = callback.params;
const callbackBody = callback.body;
// Get the response parameter name (typically the second param)
let responseVarName = 'response'; // Default if not found
if (params.length >= 2 && params[1].type === 'Identifier') {
responseVarName = params[1].name;
}
let errorVarName = 'error'; // Default if not found
if (params.length >= 1 && params[0].type === 'Identifier') {
errorVarName = params[0].name;
}
// Define translations for callback response properties
const responsePropertyMap = {
'json': 'getBody',
'text': 'getBody',
'code': 'getStatus()',
'status': 'statusText',
'responseTime': 'getResponseTime()',
'statusText': 'statusText',
'headers': 'getHeaders()',
};
// Process the callback body to transform response property references
j(callbackBody).find(j.MemberExpression, {
object: {
type: 'Identifier',
name: responseVarName
}
}).forEach(memberPath => {
const property = memberPath.node.property;
// Handle property access
if (property.type === 'Identifier' && responsePropertyMap[property.name]) {
const bruProperty = responsePropertyMap[property.name];
// If it's a method call (with parentheses)
if (bruProperty.endsWith('()')) {
// If it's already being called (e.g., response.json())
if (memberPath.parent.node.type === 'CallExpression' &&
memberPath.parent.node.callee === memberPath.node) {
// Replace with method call: res.getBody()
j(memberPath.parent).replaceWith(
j.callExpression(
j.memberExpression(
j.identifier(responseVarName),
j.identifier(bruProperty.slice(0, -2))
),
[]
)
);
} else {
// Replace with method call: res.getBody()
j(memberPath).replaceWith(
j.callExpression(
j.memberExpression(
j.identifier(responseVarName),
j.identifier(bruProperty.slice(0, -2))
),
[]
)
);
}
} else {
// Replace with property access: res.statusText
j(memberPath).replaceWith(
j.memberExpression(
j.identifier(responseVarName),
j.identifier(bruProperty)
)
);
}
}
});
// Create the callback block
return j.functionExpression(
null,
[j.identifier(errorVarName), j.identifier(responseVarName)],
j.blockStatement(callbackBody.body)
);
};
const sendRequestTransformer = (path, j) => {
const callExpr = path.parent.value;
if (callExpr.type !== 'CallExpression') return;
// Clone the argument object for modification
const args = [...callExpr.arguments];
if (!args.length) return;
const requestOptions = args[0];
const callback = args[1];
// transform the request config options
if (requestOptions.type === 'ObjectExpression') {
// Transform headers
transformHeaders(j, requestOptions);
// Transform body
transformBody(j, requestOptions);
}
// Create the callback block and promise chain if there's a callback
if (callback) {
const transformedCallback = transformCallback(j, callback);
// Create expression: bru.sendRequest(requestConfig, callback);
return j.callExpression(
j.identifier('bru.sendRequest'),
transformedCallback ? [requestOptions, transformedCallback] : [requestOptions]
);
}
// If there's no callback, just transform to bru.sendRequest
return j.callExpression(
j.identifier('bru.sendRequest'),
[requestOptions]
);
};
export default sendRequestTransformer;

View File

@@ -0,0 +1,592 @@
import translateCode from '../../../../../src/utils/jscode-shift-translator';
describe('Send Request Translation', () => {
describe('Raw Body Mode', () => {
it('should transform raw JSON string body', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: {
mode: 'raw',
raw: JSON.stringify({
"x": 1
})
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
data: JSON.stringify({
"x": 1
})
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
it('should transform raw JSON object body', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: {
mode: 'raw',
raw: {
"x": 1
}
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
data: {
"x": 1
}
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
it('should transform raw text body', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Content-Type': 'text/plain',
},
body: {
mode: 'raw',
raw: 'Hello World'
}
}, function (error, response) {
console.log(response.text());
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
data: 'Hello World'
}, function(error, response) {
console.log(response.getBody());
});
`);
});
});
describe('URL-encoded Body Mode', () => {
it('should transform urlencoded body with single key-value pair', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "key", value: "value" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Accept': 'application/json',
"Content-Type": "application/x-www-form-urlencoded",
},
data: {
"key": "value"
}
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
it('should transform urlencoded body with multiple key-value pairs', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
console.log(response.json());
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
}
}, function(error, response) {
console.log(response.getBody());
});
`);
});
it('should transform urlencoded body when no Content-Type header exists', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "key1", value: "value1" },
{ key: "key2", value: "value2" }
]
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: {
"key1": "value1",
"key2": "value2"
},
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
`);
});
it('should transform urlencoded body with incorrect Content-Type header', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "text/plain"
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "key1", value: "value1" },
{ key: "key2", value: "value2" }
]
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
"key1": "value1",
"key2": "value2"
}
});
`);
});
});
describe('Multi-part Form Data Body Mode', () => {
it('should transform formdata body with single key-value pair', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Content-Type': 'multipart/form-data',
},
body: {
mode: 'formdata',
formdata: [
{ key: "key", value: "value" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "multipart/form-data",
},
data: {
"key": "value"
}
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
it('should transform formdata body with multiple key-value pair', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Content-Type': 'multipart/form-data',
},
body: {
mode: 'formdata',
formdata: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "multipart/form-data",
},
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
}
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
it('should transform formdata body when no Content-Type header exists', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
body: {
mode: 'formdata',
formdata: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
},
headers: {
"Content-Type": "multipart/form-data"
}
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
it('should transform formdata body with incorrect Content-Type header', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "text/plain"
},
body: {
mode: 'formdata',
formdata: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "multipart/form-data"
},
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
}
}, function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.getBody();
const response_headers = response.getHeaders();
console.log(response_body, response_headers);
}
});
`);
});
});
describe('Headers and Content-Type Handling', () => {
it('should rename header property to headers', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
header: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token'
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
headers: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token'
}
});
`);
});
it('should handle header array format', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
header: [
{ key: 'X-Custom-Header', value: 'custom-value' },
{ key: 'Authorization', value: 'Bearer token' }
]
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
headers: {
"X-Custom-Header": 'custom-value',
"Authorization": 'Bearer token'
}
});
`);
});
});
describe('Response Handling', () => {
it('should transform response property access', () => {
const code = `
pm.sendRequest('https://echo.usebruno.com', function (error, response) {
const status = response.code;
const statusText = response.status;
const headers = response.headers;
const body = response.json();
const responseTime = response.responseTime;
const text = response.text();
if (status === 200) {
console.log('Success!');
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const status = response.getStatus()');
expect(translatedCode).toContain('const statusText = response.statusText');
expect(translatedCode).toContain('const headers = response.getHeaders()');
expect(translatedCode).toContain('const body = response.getBody()');
expect(translatedCode).toContain('const responseTime = response.getResponseTime()');
expect(translatedCode).toContain('const text = response.getBody()');
});
});
});

View File

@@ -1,5 +1,6 @@
const { cloneDeep } = require('lodash');
const { interpolate: _interpolate } = require('@usebruno/common');
const { sendRequest } = require('@usebruno/requests').scripting;
const variableNameRegex = /^[\w-.]*$/;
@@ -15,6 +16,7 @@ class Bru {
this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
this.collectionName = collectionName;
this.sendRequest = sendRequest;
this.runner = {
skipRequest: () => {
this.skipRequest = true;

View File

@@ -142,10 +142,10 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
const { bru, req, res, test, __brunoTestResults, console: consoleFn } = externalContext;
consoleFn && addConsoleShimToContext(vm, consoleFn);
bru && addBruShimToContext(vm, bru);
req && addBrunoRequestShimToContext(vm, req);
res && addBrunoResponseShimToContext(vm, res);
consoleFn && addConsoleShimToContext(vm, consoleFn);
addLocalModuleLoaderShimToContext(vm, collectionPath);
addPathShimToContext(vm);

View File

@@ -1,4 +1,4 @@
const { cleanJson } = require('../../../utils');
const { cleanJson, cleanCircularJson } = require('../../../utils');
const { marshallToVm } = require('../utils');
const addBruShimToContext = (vm, bru) => {
@@ -210,8 +210,7 @@ const addBruShimToContext = (vm, bru) => {
bru
.runRequest(vm.dump(args))
.then((response) => {
const { status, headers, data, dataBuffer, size, statusText } = response || {};
promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm));
promise.resolve(marshallToVm(cleanCircularJson(response), vm));
})
.catch((err) => {
promise.resolve(
@@ -228,6 +227,26 @@ const addBruShimToContext = (vm, bru) => {
});
runRequestHandle.consume((handle) => vm.setProp(bruObject, 'runRequest', handle));
let sendRequestHandle = vm.newFunction('_sendRequest', (args) => {
const promise = vm.newPromise();
bru
.sendRequest(vm.dump(args))
.then((response) => {
promise.resolve(marshallToVm(cleanCircularJson(response), vm));
})
.catch((err) => {
promise.reject(
marshallToVm(
cleanJson(err),
vm
)
);
});
promise.settled.then(vm.runtime.executePendingJobs);
return promise.handle;
});
sendRequestHandle.consume((handle) => vm.setProp(bruObject, '_sendRequest', handle));
const sleep = vm.newFunction('sleep', (timer) => {
const t = vm.getString(timer);
const promise = vm.newPromise();
@@ -242,6 +261,29 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'runner', bruRunnerObject);
vm.setProp(vm.global, 'bru', bruObject);
bruObject.dispose();
vm.evalCode(`
globalThis.bru.sendRequest = async (requestConfig, callback) => {
if (!callback) return await globalThis.bru._sendRequest(requestConfig);
try {
const response = await globalThis.bru._sendRequest(requestConfig);
try {
await callback(null, response);
}
catch(error) {
return Promise.reject(error);
}
}
catch(error) {
try {
await callback(JSON.parse(JSON.stringify(error)), null);
}
catch(err) {
return Promise.reject(err);
}
}
}
`);
};
module.exports = addBruShimToContext;

View File

@@ -144,11 +144,37 @@ const cleanJson = (data) => {
}
};
const cleanCircularJson = (data) => {
try {
// Handle circular references by keeping track of seen objects
const seen = new WeakSet();
const replacer = (key, value) => {
// Skip non-objects and null
if (typeof value !== 'object' || value === null) {
return value;
}
// Detect circular reference
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
return value;
};
return JSON.parse(JSON.stringify(data, replacer));
} catch (e) {
return data;
}
};
module.exports = {
evaluateJsExpression,
evaluateJsTemplateLiteral,
createResponseParser,
internalExpressionCache,
cleanJson
cleanJson,
cleanCircularJson
};

View File

@@ -31,6 +31,7 @@
"rollup": "3.29.5"
},
"dependencies": {
"@types/qs": "^6.9.18"
"@types/qs": "^6.9.18",
"axios": "^1.9.0"
}
}

View File

@@ -1,3 +1,7 @@
export { addDigestInterceptor, getOAuth2Token } from './auth';
export * as utils from './utils';
export * as network from './network';
export * as scripting from './scripting';

View File

@@ -0,0 +1,48 @@
import { default as axios, AxiosRequestConfig, AxiosRequestHeaders } from 'axios';
/**
*
* @param {Object} customRequestConfig options - partial AxiosRequestConfig
*
* @returns {import('axios').AxiosInstance} Configured Axios instance
*
* @example
* const instance = makeAxiosInstance({
* maxRedirects: 0,
* proxy: false,
* headers: {
* "User-Agent": `bruno-runtime/_version_`
* },
* });
*/
const baseRequestConfig: Partial<AxiosRequestConfig> = {
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
const contentType = headers.getContentType() || '';
const hasJSONContentType = contentType.includes('json');
if (typeof data === 'string' && hasJSONContentType) {
return data;
}
if (Array.isArray(axios.defaults.transformRequest)) {
axios.defaults.transformRequest.forEach((tr) => {
data = tr.call(this, data, headers);
});
}
return data;
}
}
const makeAxiosInstance = (customRequestConfig?: AxiosRequestConfig) => {
customRequestConfig = customRequestConfig || {};
const axiosInstance = axios.create({
...baseRequestConfig,
...customRequestConfig
});
return axiosInstance;
};
export {
makeAxiosInstance
};

View File

@@ -0,0 +1 @@
export { makeAxiosInstance } from './axios-instance';

View File

@@ -0,0 +1 @@
export { default as sendRequest } from './send-request';

View File

@@ -0,0 +1,30 @@
import { AxiosRequestConfig } from 'axios';
import { makeAxiosInstance } from '../network';
type T_SendRequestCallback = (error: any, response: any) => void;
const sendRequest = async (requestConfig: AxiosRequestConfig, callback: T_SendRequestCallback) => {
const axiosInstance = makeAxiosInstance();
if (!callback) {
return await axiosInstance(requestConfig);
}
try {
const response = await axiosInstance(requestConfig);
try {
await callback(null, response);
}
catch(error) {
return Promise.reject(error);
}
}
catch (error) {
try {
await callback(error, null);
}
catch(err) {
return Promise.reject(err);
}
}
};
export default sendRequest;

View File

@@ -0,0 +1,8 @@
meta {
name: send-request
seq: 16
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,18 @@
meta {
name: get-url-string
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: none
auth: inherit
}
tests {
await test("send request with a get url string", async () => {
const res = await bru.sendRequest("https://testbench-sanity.usebruno.com/ping");
expect(res.data).to.eql('pong');
});
}

View File

@@ -0,0 +1,80 @@
meta {
name: usage-patterns
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: none
auth: inherit
}
tests {
// pattern 1: using async/await
await test("post request with async/await - success case", async () => {
const res = await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: 'ping'
});
expect(res.data).to.eql('ping');
});
await test("post request with async/await - error case", async () => {
try {
await bru.sendRequest({
url: 'https://echo.usebruno.com/invalid',
method: 'POST',
data: 'ping'
});
}
catch(err) {
expect(err.status).to.eql(404);
}
});
// pattern 2: using promise (.then/.catch)
await test("post request with promise chain - success case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: 'ping'
})
.then(res => {
expect(res.data).to.eql('ping');
});
});
await test("post request with promise chain - error case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com/invalid',
method: 'POST',
data: 'ping'
})
.catch(err => {
expect(err.status).to.eql(404);
});
});
// pattern 3: using callbacks
await test("post request with callback - success case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: 'ping'
}, function(error, response) {
expect(response.data).to.eql('ping');
});
});
await test("post request with callback - error case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com/invalid',
method: 'POST',
data: 'ping'
}, function(error, response) {
expect(error.status).to.eql(404);
});
});
}