fix(bru-2035): form-urlencoded logic updates (#5820)

This commit is contained in:
lohit
2025-10-17 18:22:43 +05:30
committed by GitHub
parent 7d8fde9180
commit a4b1941817
15 changed files with 375 additions and 112 deletions

View File

@@ -19,13 +19,13 @@ const { shouldUseProxy, PatchedHttpsProxyAgent, getSystemProxyEnvVariables } = r
const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
const { createFormData, buildFormUrlEncodedPayload } = require('../utils/form-data');
const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { getCACertificates } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -332,8 +332,14 @@ const runSingleRequest = async function (
const contentTypeHeader = Object.keys(request.headers).find(
name => name.toLowerCase() === 'content-type'
);
if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') {
request.data = buildFormUrlEncodedPayload(request.data);
if (Array.isArray(request.data)) {
request.data = buildFormUrlEncodedPayload(request.data);
} else if (typeof request.data !== 'string') {
request.data = qs.stringify(request.data, { arrayFormat: 'repeat' });
}
// if `data` is of string type - return as-is (assumes already encoded)
}
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {

View File

@@ -3,24 +3,6 @@ const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
/**
* @param {Array.<object>} params The request body Array
* @returns {string} Returns a order respecting standard compliant string of form encoded values
*/
const buildFormUrlEncodedPayload = (params) => {
if (typeof params !== 'object') return '';
if (!Array.isArray(params)) return '';
const resultParams = new URLSearchParams();
for (const param of params) {
// Invalid items are ignored
if (typeof param !== 'object') continue;
if (!('name' in param)) continue;
resultParams.append(param.name, param.value ?? '');
}
return resultParams.toString();
};
const createFormData = (data, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
@@ -56,6 +38,5 @@ const createFormData = (data, collectionPath) => {
};
module.exports = {
buildFormUrlEncodedPayload,
createFormData
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from '@jest/globals';
import { buildFormUrlEncodedPayload } from './form-data';
describe('buildFormUrlEncodedPayload', () => {
it('should handle single key-value pair', () => {
const requestObj = [{ name: 'item', value: 2 }];
const expected = 'item=2';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle multiple key-value pairs with unique keys', () => {
const requestObj = [
{ name: 'item1', value: 2 },
{ name: 'item2', value: 3 }
];
const expected = 'item1=2&item2=3';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle multiple key-value pairs with the same key', () => {
const requestObj = [
{ name: 'item', value: 2 },
{ name: 'item', value: 3 }
];
const expected = 'item=2&item=3';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle mixed key-value pairs with unique and duplicate keys', () => {
const requestObj = [
{ name: 'item1', value: 2 },
{ name: 'item2', value: 3 },
{ name: 'item1', value: 4 }
];
const expected = 'item1=2&item2=3&item1=4';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle empty array', () => {
const result = buildFormUrlEncodedPayload([]);
expect(result).toEqual('');
});
it('should handle array with undefined and null values', () => {
const requestObj = [
{ name: 'item1', value: undefined },
{ name: 'item2', value: null as any },
{ name: 'item3', value: '' },
{ name: 'item4', value: 0 }
];
const expected = 'item1=&item2=&item3=&item4=0';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle array with special characters in names and values', () => {
const requestObj = [
{ name: 'item with spaces', value: 'value with spaces' },
{ name: 'item&special', value: 'value&special' },
{ name: 'item=equals', value: 'value=equals' },
{ name: 'item%percent', value: 'value%percent' }
];
const expected = 'item+with+spaces=value+with+spaces&item%26special=value%26special&item%3Dequals=value%3Dequals&item%25percent=value%25percent';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle array with numeric and boolean values', () => {
const requestObj = [
{ name: 'number', value: 42 },
{ name: 'float', value: 3.14 },
{ name: 'boolean_true', value: true },
{ name: 'boolean_false', value: false }
];
const expected = 'number=42&float=3.14&boolean_true=true&boolean_false=false';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should preserve parameter order in array format', () => {
const requestObj = [
{ name: 'z', value: '1' },
{ name: 'a', value: '2' },
{ name: 'm', value: '3' }
];
const expected = 'z=1&a=2&m=3';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should ignore invalid items inside params array', () => {
const requestObj: any[] = [
{ name: 'item1', value: 'a' },
'not-an-object',
{ value: 'missingName' },
42,
{ name: 'item2', value: 'b' },
{ name: 'item3' }, // missing value should default to empty string
null,
undefined,
{ name: '', value: 'empty_name' }, // empty name should still work
{ name: 'valid', value: 'c' }
];
const expected = 'item1=a&item2=b&item3=&=empty_name&valid=c';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
});

View File

@@ -0,0 +1,33 @@
/**
* Builds a URL-encoded payload from various data formats
*
* This function handles multiple input formats:
* - Array of objects with 'name' and 'value' properties (preserves order)
*
* @param data The request body data
* @returns URL-encoded string suitable for application/x-www-form-urlencoded content type
*
* @example
* // Array format (preserves order)
* buildFormUrlEncodedPayload([{name: 'a', value: '1'}, {name: 'b', value: '2'}])
* // Returns: 'a=1&b=2'
*/
export const buildFormUrlEncodedPayload = (params: Array<{ name: string; value: string | number | boolean | undefined }>): string => {
// Ensure params is iterable (array)
if (!Array.isArray(params)) {
return '';
}
const resultParams = new URLSearchParams();
for (const param of params) {
// Invalid items are ignored
if (typeof param !== 'object' || param === null) continue;
if (!('name' in param)) continue;
// Append parameter with value (default to empty string if undefined/null)
resultParams.append(param.name, String(param.value ?? ''));
}
return resultParams.toString();
};

View File

@@ -2,4 +2,8 @@ export {
encodeUrl,
parseQueryParams,
buildQueryString,
} from './url';
} from './url';
export {
buildFormUrlEncodedPayload
} from './form-data';

View File

@@ -1,7 +1,7 @@
const qs = require('qs');
const https = require('https');
const axios = require('axios');
const path = require('path');
const qs = require('qs');
const decomment = require('decomment');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
@@ -23,7 +23,7 @@ const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../util
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, buildFormUrlEncodedPayload } = require('../../utils/form-data');
const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
@@ -35,6 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig } = require('./cert-utils');
const { buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -423,11 +424,18 @@ const registerNetworkIpc = (mainWindow) => {
}
// stringify the request url encoded params
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
request.data = buildFormUrlEncodedPayload(request.data);
const contentTypeHeader = Object.keys(request.headers).find((name) => name.toLowerCase() === 'content-type');
if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') {
if (Array.isArray(request.data)) {
request.data = buildFormUrlEncodedPayload(request.data);
} else if (typeof request.data !== 'string') {
request.data = qs.stringify(request.data, { arrayFormat: 'repeat' });
}
// if `data` is of string type - return as-is (assumes already encoded)
}
if (request.headers['content-type'] === 'multipart/form-data') {
if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') {
if (!(request.data instanceof FormData)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;

View File

@@ -3,7 +3,6 @@ const decomment = require('decomment');
const crypto = require('node:crypto');
const fs = require('node:fs');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials, mergeAuth } = require('../../utils/collection');
const { buildFormUrlEncodedPayload } = require('../../utils/form-data');
const path = require('node:path');
const { isLargeFile } = require('../../utils/filesystem');

View File

@@ -3,24 +3,6 @@ const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
/**
* @param {Array.<object>} params The request body Array
* @returns {string} Returns a order respecting standard compliant string of form encoded values
*/
const buildFormUrlEncodedPayload = (params) => {
if (typeof params !== 'object') return '';
if (!Array.isArray(params)) return '';
const resultParams = new URLSearchParams();
for (const param of params) {
// Invalid items are ignored
if (typeof param !== 'object') continue;
if (!('name' in param)) continue;
resultParams.append(param.name, param.value ?? '');
}
return resultParams.toString();
};
const createFormData = (data, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
@@ -56,6 +38,5 @@ const createFormData = (data, collectionPath) => {
};
module.exports = {
buildFormUrlEncodedPayload,
createFormData
};

View File

@@ -1,7 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
const { prepareRequest } = require('../../src/ipc/network/prepare-request');
const { buildFormUrlEncodedPayload } = require('../../src/utils/form-data');
describe('prepare-request: prepareRequest', () => {
describe('Decomments request body', () => {
@@ -20,65 +19,6 @@ describe('prepare-request: prepareRequest', () => {
const result = await prepareRequest({ request: { body }, collection: { pathname: '' } });
expect(result.data).toEqual(expected);
});
it('should handle single key-value pair', () => {
const requestObj = [{ name: 'item', value: 2 }];
const expected = 'item=2';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle multiple key-value pairs with unique keys', () => {
const requestObj = [
{ name: 'item1', value: 2 },
{ name: 'item2', value: 3 }
];
const expected = 'item1=2&item2=3';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle multiple key-value pairs with the same key', () => {
const requestObj = [
{ name: 'item', value: 2 },
{ name: 'item', value: 3 }
];
const expected = 'item=2&item=3';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('should handle mixed key-value pairs with unique and duplicate keys', () => {
const requestObj = [
{ name: 'item1', value: 2 },
{ name: 'item2', value: 3 },
{ name: 'item1', value: 4 }
];
const expected = 'item1=2&item2=3&item1=4';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
it('returns empty string when params is not an object', () => {
expect(buildFormUrlEncodedPayload(null)).toEqual('');
expect(buildFormUrlEncodedPayload('string')).toEqual('');
expect(buildFormUrlEncodedPayload(123)).toEqual('');
expect(buildFormUrlEncodedPayload(undefined)).toEqual('');
});
it('ignores invalid items inside params array', () => {
const requestObj = [
{ name: 'item1', value: 'a' },
'not-an-object',
{ value: 'missingName' },
42,
{ name: 'item2', value: 'b' },
{ name: 'item3' }
];
const expected = 'item1=a&item2=b&item3=';
const result = buildFormUrlEncodedPayload(requestObj);
expect(result).toEqual(expected);
});
});
describe.each(['POST', 'PUT', 'PATCH'])('POST request with no body', (method) => {

View File

@@ -13,10 +13,10 @@ post {
body:form-urlencoded {
form-data-key: {{form-data-key}}
form-data-stringified-object: {{form-data-stringified-object}}
}
assert {
res.body: eq form-data-key=form-data-value&form-data-stringified-object=%7B%22foo%22%3A123%7D
key_1: value_1
key_2: value_2
key_1: value_3
key_2: value_4
}
script:pre-request {
@@ -24,3 +24,18 @@ script:pre-request {
bru.setVar('form-data-key', 'form-data-value');
bru.setVar('form-data-stringified-object', obj);
}
tests {
test("form-urlencoded body with variables and duplicate keys", function() {
const expected = [
"form-data-key=form-data-value",
"form-data-stringified-object=%7B%22foo%22%3A123%7D", // {"foo":123} URL encoded
"key_1=value_1",
"key_2=value_2",
"key_1=value_3", // duplicate key with different value
"key_2=value_4" // duplicate key with different value
].join("&");
expect(res.getBody()).to.eql(expected);
});
}

View File

@@ -0,0 +1,66 @@
meta {
name: array body
type: http
seq: 8
}
post {
url: {{echo-host}}
body: formUrlEncoded
auth: inherit
}
script:pre-request {
req.setBody([
{name: "empty", value: ""},
{name: "null", value: null},
{name: "undefined", value: undefined},
{name: "zero", value: 0},
{name: "false", value: false},
{name: "", value: "empty_key"},
{name: "key", value: "value1"},
{name: "name", value: "bruno"},
{name: "key", value: "value2"},
]);
}
tests {
test("req.setBody() with edge cases - request body", function() {
const data = req.getBody();
const expected = [
"empty=",
"null=",
"undefined=",
"zero=0",
"false=false",
"=empty_key",
"key=value1",
"name=bruno",
"key=value2"
].join("&");
expect(data).to.eql(expected);
});
test("req.setBody() with edge cases - response body", function() {
const data = res.getBody();
const expected = [
"empty=",
"null=",
"undefined=",
"zero=0",
"false=false",
"=empty_key",
"key=value1",
"name=bruno",
"key=value2"
].join("&");
expect(data).to.eql(expected);
});
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,41 @@
meta {
name: content-type via setHeader
type: http
seq: 7
}
post {
url: {{echo-host}}
body: none
auth: inherit
}
script:pre-request {
req.setHeader('content-type', 'application/x-www-form-urlencoded');
req.setBody([
{name: "key", value: "value"},
{name: "name", value: "bruno"}
]);
}
tests {
test("req.setBody() - request body", function() {
const data = req.getBody();
expect(data).to.eql("key=value&name=bruno");
});
test("req.setBody() - response body", function() {
const data = res.getBody();
expect(data).to.eql("key=value&name=bruno");
});
test("Content-Type header is set correctly", function() {
const contentType = req.getHeader('content-type');
expect(contentType).to.eql('application/x-www-form-urlencoded');
});
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: form-urlencoded
seq: 1
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,37 @@
meta {
name: object body
type: http
seq: 1
}
post {
url: {{echo-host}}
body: formUrlEncoded
auth: inherit
}
script:pre-request {
req.setBody({
"key": "value with spaces",
"name": "bruno",
"array": ["test", "value"],
});
}
tests {
// https://github.com/usebruno/bruno/issues/5813
test("req.setBody() with object - request body", function() {
const data = req.getBody();
expect(data).to.eql("key=value%20with%20spaces&name=bruno&array=test&array=value");
});
test("req.setBody() with object - response body", function() {
const data = res.getBody();
expect(data).to.eql("key=value%20with%20spaces&name=bruno&array=test&array=value");
});
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,32 @@
meta {
name: string body
type: http
seq: 3
}
post {
url: {{echo-host}}
body: formUrlEncoded
auth: inherit
}
script:pre-request {
req.setBody("key=value&name=bruno");
}
tests {
test("req.setBody() with string format - request body", function() {
const data = req.getBody();
expect(data).to.eql("key=value&name=bruno");
});
test("req.setBody() with string format - response body", function() {
const data = res.getBody();
expect(data).to.eql("key=value&name=bruno");
});
}
settings {
encodeUrl: true
timeout: 0
}