diff --git a/package-lock.json b/package-lock.json index fe4a15fd8..2e4a5e517 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-converters/src/utils/jscode-shift-translator.js b/packages/bruno-converters/src/utils/jscode-shift-translator.js index 92ccf97ba..4d68c2b68 100644 --- a/packages/bruno-converters/src/utils/jscode-shift-translator.js +++ b/packages/bruno-converters/src/utils/jscode-shift-translator.js @@ -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', diff --git a/packages/bruno-converters/src/utils/send-request-transformer.js b/packages/bruno-converters/src/utils/send-request-transformer.js new file mode 100644 index 000000000..cb5ecd19b --- /dev/null +++ b/packages/bruno-converters/src/utils/send-request-transformer.js @@ -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; \ No newline at end of file diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js new file mode 100644 index 000000000..90f54e38b --- /dev/null +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js @@ -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()'); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index d38d28983..5dad6935e 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -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; diff --git a/packages/bruno-js/src/sandbox/quickjs/index.js b/packages/bruno-js/src/sandbox/quickjs/index.js index 2c83c0e3f..d1340e92d 100644 --- a/packages/bruno-js/src/sandbox/quickjs/index.js +++ b/packages/bruno-js/src/sandbox/quickjs/index.js @@ -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); diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js index 8439d7206..5be5e26d0 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js @@ -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; diff --git a/packages/bruno-js/src/utils.js b/packages/bruno-js/src/utils.js index 289bf8dcc..7ebfa795a 100644 --- a/packages/bruno-js/src/utils.js +++ b/packages/bruno-js/src/utils.js @@ -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 }; diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index 1aed11446..f8b55f4cd 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -31,6 +31,7 @@ "rollup": "3.29.5" }, "dependencies": { - "@types/qs": "^6.9.18" + "@types/qs": "^6.9.18", + "axios": "^1.9.0" } } diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index 01850f3e4..3bdef99a0 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -1,3 +1,7 @@ export { addDigestInterceptor, getOAuth2Token } from './auth'; export * as utils from './utils'; + +export * as network from './network'; + +export * as scripting from './scripting'; \ No newline at end of file diff --git a/packages/bruno-requests/src/network/axios-instance.ts b/packages/bruno-requests/src/network/axios-instance.ts new file mode 100644 index 000000000..bede865d7 --- /dev/null +++ b/packages/bruno-requests/src/network/axios-instance.ts @@ -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 = { + 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 +}; diff --git a/packages/bruno-requests/src/network/index.ts b/packages/bruno-requests/src/network/index.ts new file mode 100644 index 000000000..7d72cb7d1 --- /dev/null +++ b/packages/bruno-requests/src/network/index.ts @@ -0,0 +1 @@ +export { makeAxiosInstance } from './axios-instance'; \ No newline at end of file diff --git a/packages/bruno-requests/src/scripting/index.ts b/packages/bruno-requests/src/scripting/index.ts new file mode 100644 index 000000000..5ec4ee97b --- /dev/null +++ b/packages/bruno-requests/src/scripting/index.ts @@ -0,0 +1 @@ +export { default as sendRequest } from './send-request'; \ No newline at end of file diff --git a/packages/bruno-requests/src/scripting/send-request.ts b/packages/bruno-requests/src/scripting/send-request.ts new file mode 100644 index 000000000..8afedeaf3 --- /dev/null +++ b/packages/bruno-requests/src/scripting/send-request.ts @@ -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; diff --git a/packages/bruno-tests/collection/scripting/api/bru/send-request/folder.bru b/packages/bruno-tests/collection/scripting/api/bru/send-request/folder.bru new file mode 100644 index 000000000..02561d36e --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/send-request/folder.bru @@ -0,0 +1,8 @@ +meta { + name: send-request + seq: 16 +} + +auth { + mode: inherit +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/send-request/get-url-string.bru b/packages/bruno-tests/collection/scripting/api/bru/send-request/get-url-string.bru new file mode 100644 index 000000000..161f6d763 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/send-request/get-url-string.bru @@ -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'); + }); +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/send-request/usage-patterns.bru b/packages/bruno-tests/collection/scripting/api/bru/send-request/usage-patterns.bru new file mode 100644 index 000000000..bb80b3346 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/send-request/usage-patterns.bru @@ -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); + }); + }); +}