diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
index 6be64a43c..2a2acbb21 100644
--- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
@@ -17,6 +17,7 @@ import Documentation from 'components/Documentation/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import { useEffect } from 'react';
import StatusDot from 'components/StatusDot';
+import Settings from 'components/RequestPane/Settings';
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -61,6 +62,9 @@ const HttpRequestPane = ({ item, collection }) => {
case 'docs': {
return ;
}
+ case 'settings': {
+ return ;
+ }
default: {
return
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js
new file mode 100644
index 000000000..f0294aee9
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js
@@ -0,0 +1,74 @@
+import React from 'react';
+
+const ToggleSelector = ({
+ checked,
+ onChange,
+ label,
+ description,
+ disabled = false,
+ size = 'small' // 'small', 'medium', 'large'
+}) => {
+ const sizeClasses = {
+ small: {
+ container: 'h-4 w-8',
+ thumb: 'h-3 w-3',
+ translate: checked ? 'translate-x-4' : 'translate-x-1'
+ },
+ medium: {
+ container: 'h-5 w-9',
+ thumb: 'h-3 w-3',
+ translate: checked ? 'translate-x-5' : 'translate-x-1'
+ },
+ large: {
+ container: 'h-6 w-11',
+ thumb: 'h-4 w-4',
+ translate: checked ? 'translate-x-6' : 'translate-x-1'
+ }
+ };
+
+ const currentSize = sizeClasses[size];
+
+ return (
+
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ );
+};
+
+export default ToggleSelector;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js
new file mode 100644
index 000000000..97caaf1af
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js
@@ -0,0 +1,39 @@
+import React, { useState, useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import get from 'lodash/get';
+import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
+import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
+
+const Settings = ({ item, collection }) => {
+ const dispatch = useDispatch();
+
+ // get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
+ const getPropertyFromDraftOrRequest = (propertyKey) =>
+ item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {});
+
+ const { encodeUrl } = getPropertyFromDraftOrRequest('settings');
+
+ const onToggleUrlEncoding = useCallback(() => {
+ dispatch(updateItemSettings({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ settings: { encodeUrl: !encodeUrl }
+ }));
+ }, [encodeUrl, dispatch, collection.uid, item.uid]);
+
+ return (
+
+ );
+};
+
+export default Settings;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
index 1d349b2c5..f27cfc52e 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
@@ -157,6 +157,8 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else if (values.requestType === 'from-curl') {
const request = getRequestFromCurlCommand(values.curlCommand, curlRequestTypeDetected);
+ const settings = { encodeUrl: false };
+
dispatch(
newHttpRequest({
requestName: values.requestName,
@@ -168,7 +170,8 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body,
- auth: request.auth
+ auth: request.auth,
+ settings: settings
})
)
.then(() => {
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index 6c880096e..7c097916e 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -748,7 +748,7 @@ export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getSta
}
export const newHttpRequest = (params) => (dispatch, getState) => {
- const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
+ const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth, settings } = params;
return new Promise((resolve, reject) => {
const state = getState();
@@ -795,6 +795,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
auth: auth ?? {
mode: 'inherit'
}
+ },
+ settings: settings ?? {
+ encodeUrl: true
}
};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index b904f64b8..490eabd91 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -508,6 +508,20 @@ export const collectionsSlice = createSlice({
}
}
},
+ updateItemSettings: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+ item.draft.settings = { ...item.draft.settings, ...action.payload.settings };
+ }
+ }
+ },
updateAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1849,6 +1863,7 @@ export const collectionsSlice = createSlice({
currentItem.request = file.data.request;
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
+ currentItem.settings = file.data.settings;
currentItem.draft = null;
currentItem.partial = file.partial;
currentItem.loading = file.loading;
@@ -1861,6 +1876,7 @@ export const collectionsSlice = createSlice({
type: file.data.type,
seq: file.data.seq,
request: file.data.request,
+ settings: file.data.settings,
filename: file.meta.name,
pathname: file.meta.pathname,
draft: null,
@@ -1950,6 +1966,7 @@ export const collectionsSlice = createSlice({
item.type = file.data.type;
item.seq = file.data.seq;
item.request = file.data.request;
+ item.settings = file.data.settings;
item.filename = file.meta.name;
item.pathname = file.meta.pathname;
item.draft = null;
@@ -2358,6 +2375,7 @@ export const {
toggleCollection,
toggleCollectionItem,
requestUrlChanged,
+ updateItemSettings,
updateAuth,
addQueryParam,
setQueryParams,
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 69f15fd46..1cfbc028e 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -232,7 +232,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
type: si.type,
name: si.name,
filename: si.filename,
- seq: si.seq
+ seq: si.seq,
+ settings: si.settings
};
if (si.request) {
@@ -552,6 +553,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
type: _item.type,
name: _item.name,
seq: _item.seq,
+ settings: _item.settings,
request: {
method: _item.request.method,
url: _item.request.url,
diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js
index 4897f5a2d..b6d6a323f 100644
--- a/packages/bruno-app/src/utils/curl/curl-to-json.js
+++ b/packages/bruno-app/src/utils/curl/curl-to-json.js
@@ -9,6 +9,7 @@
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
+import { stringifyQueryParams } from '../url';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -20,22 +21,6 @@ function repr(value, isKey) {
return isKey ? "'" + jsesc(value, { quotes: 'single' }) + "'" : value;
}
-function getQueries(request) {
- const queries = {};
- for (const paramName in request.query) {
- const rawValue = request.query[paramName];
- let paramValue;
- if (Array.isArray(rawValue)) {
- paramValue = rawValue.map(value => repr(value, false));
- } else {
- paramValue = repr(rawValue);
- }
- queries[repr(paramName)] = paramValue;
- }
-
- return queries;
-}
-
/**
* Converts request data to a string based on its content type.
*
@@ -177,10 +162,8 @@ const curlToJson = (curlCommand) => {
requestJson.headers = headers;
}
- if (request.query) {
- const queries = getQueries(request);
- // append query to requestJson.url
- requestJson.url = requestJson.url + '?' + querystring.stringify(queries);
+ if (request.queries) {
+ requestJson.url = requestJson.url + '?' + stringifyQueryParams(request.queries, { encode: false });
}
if (request.multipartUploads) {
diff --git a/packages/bruno-app/src/utils/curl/parse-curl.js b/packages/bruno-app/src/utils/curl/parse-curl.js
index 3a9f82df6..592f54817 100644
--- a/packages/bruno-app/src/utils/curl/parse-curl.js
+++ b/packages/bruno-app/src/utils/curl/parse-curl.js
@@ -1,8 +1,8 @@
import cookie from 'cookie';
import URL from 'url';
-import querystring from 'query-string';
import { parse } from 'shell-quote';
import { isEmpty } from 'lodash';
+import { parseQueryParams } from '../url';
/**
* Flag definitions - maps flag names to their states and actions
@@ -347,7 +347,7 @@ const setURL = (request, url) => {
request.url = formattedUrl;
request.urlWithoutQuery = urlWithoutQuery;
- request.query = queries;
+ request.queries = queries;
};
/**
@@ -368,12 +368,7 @@ const getUrlString = (url) => {
const parseUrl = (url) => {
const parsedUrl = URL.parse(url);
- const queries = querystring.parse(parsedUrl.query, { sort: false });
-
- // set empty string for null values
- Object.entries(queries).forEach(([key, value]) => {
- queries[key] = value ?? '';
- });
+ const queries = parseQueryParams(parsedUrl.query, { decode: false });
let formattedUrl = URL.format(parsedUrl);
if (!url.endsWith('/') && formattedUrl.endsWith('/')) {
@@ -409,7 +404,7 @@ const convertDataToQueryString = (request) => {
const { url: formattedUrl, queries } = parseUrl(url);
request.url = formattedUrl;
- request.query = queries;
+ request.queries = queries;
return request;
};
@@ -451,8 +446,8 @@ const cleanRequest = (request) => {
delete request.headers;
}
- if (isEmpty(request.query)) {
- delete request.query;
+ if (isEmpty(request.queries)) {
+ delete request.queries;
}
return request;
diff --git a/packages/bruno-app/src/utils/curl/parse-curl.spec.js b/packages/bruno-app/src/utils/curl/parse-curl.spec.js
index b136ebb20..3ab767f62 100644
--- a/packages/bruno-app/src/utils/curl/parse-curl.spec.js
+++ b/packages/bruno-app/src/utils/curl/parse-curl.spec.js
@@ -415,11 +415,11 @@ describe('parseCurlCommand', () => {
expect(result).toEqual({
method: 'get',
- query: {
- page: '1',
- limit: '10',
- sort: 'asc'
- },
+ queries: [
+ { name: 'page', value: '1' },
+ { name: 'limit', value: '10' },
+ { name: 'sort', value: 'asc' }
+ ],
url: 'https://api.example.com/users?page=1&limit=10&sort=asc',
urlWithoutQuery: 'https://api.example.com/users'
});
@@ -514,10 +514,10 @@ describe('parseCurlCommand', () => {
password: 'api_pass'
}
},
- query: {
- param1: 'value1',
- param2: 'custom param'
- },
+ queries: [
+ { name: 'param1', value: 'value1' },
+ { name: 'param2', value: 'custom+param' }
+ ],
url: 'https://api.example.com/v1/users?param1=value1¶m2=custom+param',
urlWithoutQuery: 'https://api.example.com/v1/users'
});
@@ -702,10 +702,10 @@ describe('parseCurlCommand', () => {
method: 'get',
url: 'https://api.example.com/users?name=John&age=30',
urlWithoutQuery: 'https://api.example.com/users',
- query: {
- name: 'John',
- age: '30'
- }
+ queries: [
+ { name: 'name', value: 'John' },
+ { name: 'age', value: '30' }
+ ]
});
});
@@ -721,12 +721,12 @@ describe('parseCurlCommand', () => {
method: 'get',
url: 'https://api.example.com/users?test=urlquery&name=John%20Doe&email=john@example.com&hello',
urlWithoutQuery: 'https://api.example.com/users',
- query: {
- email: 'john@example.com',
- hello: '',
- name: 'John Doe',
- test: 'urlquery'
- }
+ queries: [
+ { name: 'test', value: 'urlquery' },
+ { name: 'name', value: 'John%20Doe' },
+ { name: 'email', value: 'john@example.com' },
+ { name: 'hello', value: '' }
+ ]
});
});
@@ -743,12 +743,12 @@ describe('parseCurlCommand', () => {
method: 'get',
url: 'https://api.example.com/search?search=test+query&filter=active&sort=name&page=1',
urlWithoutQuery: 'https://api.example.com/search',
- query: {
- search: 'test query',
- filter: 'active',
- sort: 'name',
- page: '1'
- }
+ queries: [
+ { name: 'search', value: 'test+query' },
+ { name: 'filter', value: 'active' },
+ { name: 'sort', value: 'name' },
+ { name: 'page', value: '1' }
+ ]
});
});
});
diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js
index 3a82398a1..577a89cdf 100644
--- a/packages/bruno-app/src/utils/url/index.js
+++ b/packages/bruno-app/src/utils/url/index.js
@@ -15,14 +15,29 @@ const hasLength = (str) => {
return str.length > 0;
};
-export const parseQueryParams = (query) => {
+export const parseQueryParams = (query, { decode = false } = {}) => {
try {
if (!query || !query.length) {
return [];
}
- return Array.from(new URLSearchParams(query.split('#')[0]).entries())
- .map(([name, value]) => ({ name, value }));
+ const [queryString, ...hashParts] = query.split('#');
+ const pairs = queryString.split('&');
+
+ const params = pairs.map(pair => {
+ const [key, ...valueParts] = pair.split('=');
+
+ if (!key) {
+ return null;
+ }
+
+ return {
+ name: decode ? decodeURIComponent(key) : key,
+ value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
+ };
+ }).filter(Boolean);
+
+ return params;
} catch (error) {
console.error('Error parsing query params:', error);
return [];
@@ -64,7 +79,7 @@ export const parsePathParams = (url) => {
return paths;
};
-export const stringifyQueryParams = (params) => {
+export const stringifyQueryParams = (params, { encode = false } = {}) => {
if (!params || isEmpty(params)) {
return '';
}
@@ -72,12 +87,14 @@ export const stringifyQueryParams = (params) => {
let queryString = [];
each(params, (p) => {
const hasEmptyName = isEmpty(trim(p.name));
- const hasEmptyVal = isEmpty(trim(p.value));
+ const hasEmptyVal = isEmpty(p.value);
// query param name must be present
if (!hasEmptyName) {
- // if query param value is missing, push only
, else push
- queryString.push(hasEmptyVal ? p.name : `${p.name}=${p.value}`);
+ const finalName = encode ? encodeURIComponent(p.name) : p.name;
+ const finalValue = encode ? encodeURIComponent(p.value) : p.value;
+
+ queryString.push(hasEmptyVal ? finalName : `${finalName}=${finalValue}`);
}
});
diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js
index 1885ef2b2..1b5993852 100644
--- a/packages/bruno-cli/src/runner/prepare-request.js
+++ b/packages/bruno-cli/src/runner/prepare-request.js
@@ -33,7 +33,8 @@ const prepareRequest = (item = {}, collection = {}) => {
url: request.url,
headers: headers,
name: item.name,
- pathParams: request?.params?.filter((param) => param.type === 'path'),
+ pathParams: request.params?.filter((param) => param.type === 'path'),
+ settings: item.settings,
responseType: 'arraybuffer'
};
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index 49ff39b20..f39d79762 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -26,6 +26,7 @@ const { getOAuth2Token } = require('./oauth2');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
+const { encodeUrl } = require('@usebruno/requests').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -138,6 +139,10 @@ const runSingleRequest = async function (
// interpolate variables inside request
interpolateVars(request, envVariables, runtimeVariables, processEnvVars);
+ if (request.settings?.encodeUrl) {
+ request.url = encodeUrl(request.url);
+ }
+
if (!protocolRegex.test(request.url)) {
request.url = `http://${request.url}`;
}
diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js
index 193354f50..205902705 100644
--- a/packages/bruno-cli/src/utils/bru.js
+++ b/packages/bruno-cli/src/utils/bru.js
@@ -63,6 +63,7 @@ const bruToJson = (bru) => {
type: requestType,
name: _.get(json, 'meta.name'),
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
+ settings: _.get(json, 'settings', {}),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js
index cde4067ac..c830d5f7e 100644
--- a/packages/bruno-electron/src/bru/index.js
+++ b/packages/bruno-electron/src/bru/index.js
@@ -23,6 +23,7 @@ const collectionBruToJson = async (data, parsed = false) => {
vars: _.get(json, 'vars', {}),
tests: _.get(json, 'tests', '')
},
+ settings: _.get(json, 'settings', {}),
docs: _.get(json, 'docs', '')
};
@@ -136,6 +137,7 @@ const bruToJson = (data, parsed = false) => {
type: requestType,
name: _.get(json, 'meta.name'),
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
+ settings: _.get(json, 'settings', {}),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
@@ -212,6 +214,7 @@ const jsonToBru = async (json) => {
},
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
+ settings: _.get(json, 'settings', {}),
docs: _.get(json, 'request.docs', '')
};
@@ -253,6 +256,7 @@ const jsonToBruViaWorker = async (json) => {
},
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
+ settings: _.get(json, 'settings', {}),
docs: _.get(json, 'request.docs', '')
};
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index c7a5ef2b1..f369d0416 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -12,6 +12,7 @@ const { ipcMain } = require('electron');
const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
+const { encodeUrl } = require('@usebruno/requests').utils;
const { interpolateString } = require('./interpolate-string');
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('@usebruno/requests');
@@ -475,6 +476,10 @@ const registerNetworkIpc = (mainWindow) => {
// interpolate variables inside request
interpolateVars(request, envVars, runtimeVariables, processEnvVars);
+ if (request.settings?.encodeUrl) {
+ request.url = encodeUrl(request.url);
+ }
+
// if this is a graphql request, parse the variables, only after interpolation
// https://github.com/usebruno/bruno/issues/884
if (request.mode === 'graphql') {
diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js
index e51192f3b..b2f11b9f9 100644
--- a/packages/bruno-electron/src/ipc/network/prepare-request.js
+++ b/packages/bruno-electron/src/ipc/network/prepare-request.js
@@ -292,6 +292,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
const prepareRequest = async (item, collection = {}, abortController) => {
const request = item.draft ? item.draft.request : item.request;
+ const settings = item.draft?.settings ?? item.settings;
const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {});
const collectionPath = collection?.pathname;
const headers = {};
@@ -332,7 +333,8 @@ const prepareRequest = async (item, collection = {}, abortController) => {
url,
headers,
name: item.name,
- pathParams: request?.params?.filter((param) => param.type === 'path'),
+ pathParams: request.params?.filter((param) => param.type === 'path'),
+ settings,
responseType: 'arraybuffer'
};
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
index 038e388fc..dc330dc97 100644
--- a/packages/bruno-electron/src/utils/collection.js
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -295,6 +295,7 @@ const transformRequestToSaveToFilesystem = (item) => {
type: _item.type,
name: _item.name,
seq: _item.seq,
+ settings: _item.settings,
request: {
method: _item.request.method,
url: _item.request.url,
diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js
index 844e21e79..13d2032dc 100644
--- a/packages/bruno-lang/v2/src/bruToJson.js
+++ b/packages/bruno-lang/v2/src/bruToJson.js
@@ -22,7 +22,7 @@ const { safeParseJson, outdentString } = require('./utils');
*
*/
const grammar = ohm.grammar(`Bru {
- BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
+ BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | settings | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart | bodyfile
@@ -60,6 +60,7 @@ const grammar = ohm.grammar(`Bru {
textchar = ~nl any
meta = "meta" dictionary
+ settings = "settings" dictionary
http = get | post | put | delete | patch | options | head | connect | trace
get = "get" dictionary
@@ -333,6 +334,15 @@ const sem = grammar.createSemantics().addAttribute('ast', {
meta
};
},
+ settings(_1, dictionary) {
+ let settings = mapPairListToKeyValPair(dictionary.ast);
+
+ return {
+ settings: {
+ encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true'
+ }
+ };
+ },
get(_1, dictionary) {
return {
http: {
diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js
index 9013599b7..1afbe19e8 100644
--- a/packages/bruno-lang/v2/src/jsonToBru.js
+++ b/packages/bruno-lang/v2/src/jsonToBru.js
@@ -30,7 +30,7 @@ const getValueString = (value) => {
};
const jsonToBru = (json) => {
- const { meta, http, params, headers, auth, body, script, tests, vars, assertions, docs } = json;
+ const { meta, http, params, headers, auth, body, script, tests, vars, assertions, settings, docs } = json;
let bru = '';
@@ -500,6 +500,14 @@ ${indentString(tests)}
`;
}
+ if (settings && Object.keys(settings).length) {
+ bru += 'settings {\n';
+ for (const key in settings) {
+ bru += ` ${key}: ${settings[key]}\n`;
+ }
+ bru += '}\n\n';
+ }
+
if (docs && docs.length) {
bru += `docs {
${indentString(docs)}
diff --git a/packages/bruno-requests/babel.config.js b/packages/bruno-requests/babel.config.js
new file mode 100644
index 000000000..d19d38b94
--- /dev/null
+++ b/packages/bruno-requests/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }],
+ '@babel/preset-typescript',
+ ],
+};
\ No newline at end of file
diff --git a/packages/bruno-requests/jest.config.js b/packages/bruno-requests/jest.config.js
new file mode 100644
index 000000000..554150cbe
--- /dev/null
+++ b/packages/bruno-requests/jest.config.js
@@ -0,0 +1,13 @@
+module.exports = {
+ transform: {
+ '^.+\\.(ts|js)$': 'babel-jest',
+ },
+ transformIgnorePatterns: [
+ '/node_modules/(?!(lodash-es)/)',
+ ],
+ testEnvironment: 'node',
+ testMatch: [
+ '**/*.(test|spec).(ts|js)'
+ ],
+ moduleFileExtensions: ['ts', 'js', 'json']
+};
\ No newline at end of file
diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json
index f8b55f4cd..ecb28bc72 100644
--- a/packages/bruno-requests/package.json
+++ b/packages/bruno-requests/package.json
@@ -15,12 +15,19 @@
"prebuild": "npm run clean",
"build": "rollup -c",
"watch": "rollup -c -w",
+ "test": "jest",
+ "test:watch": "jest --watch",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
+ "@babel/preset-env": "^7.22.0",
+ "@babel/preset-typescript": "^7.22.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
+ "@types/jest": "^29.5.11",
+ "babel-jest": "^29.7.0",
+ "jest": "^29.2.0",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
diff --git a/packages/bruno-requests/src/utils/index.ts b/packages/bruno-requests/src/utils/index.ts
index dd94dd186..af511b957 100644
--- a/packages/bruno-requests/src/utils/index.ts
+++ b/packages/bruno-requests/src/utils/index.ts
@@ -1 +1,2 @@
export * from './cookie-utils';
+export * from './url';
diff --git a/packages/bruno-requests/src/utils/url/index.spec.ts b/packages/bruno-requests/src/utils/url/index.spec.ts
new file mode 100644
index 000000000..4a6ecc353
--- /dev/null
+++ b/packages/bruno-requests/src/utils/url/index.spec.ts
@@ -0,0 +1,221 @@
+import { encodeUrl, parseQueryParams, buildQueryString } from './index';
+
+describe('encodeUrl', () => {
+ describe('basic functionality', () => {
+ it('should return the original URL when query string is empty', () => {
+ const url = 'https://example.com/path?';
+ expect(encodeUrl(url)).toBe(url);
+ });
+
+ it('should preserve URLs without query parameters', () => {
+ const url = 'https://api.example.com/v1/users';
+ expect(encodeUrl(url)).toBe(url);
+ });
+ });
+
+ describe('query parameter encoding', () => {
+ it('should handle a single query parameter', () => {
+ const url = 'https://example.com/api?name=john';
+ const expected = 'https://example.com/api?name=john';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle simple query parameters', () => {
+ const url = 'https://example.com/api?name=john&age=25';
+ const expected = 'https://example.com/api?name=john&age=25';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should encode query parameters with special characters', () => {
+ const url = 'https://example.com/api?name=john doe&email=john@example.com';
+ const expected = 'https://example.com/api?name=john%20doe&email=john%40example.com';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should encode query parameters with special URL characters', () => {
+ const url = 'https://example.com/api?path=/users/123&redirect=https://other.com';
+ const expected = 'https://example.com/api?path=%2Fusers%2F123&redirect=https%3A%2F%2Fother.com';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should encode query parameters with unicode characters', () => {
+ const url = 'https://example.com/api?name=José&city=München';
+ const expected = 'https://example.com/api?name=Jos%C3%A9&city=M%C3%BCnchen';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle query parameters with empty values', () => {
+ const url = 'https://example.com/api?name=&age=25&active=';
+ const expected = 'https://example.com/api?name=&age=25&active=';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should encode query parameters with pipe operator', () => {
+ const url = 'https://example.com/api?filter=status|active&sort=name|asc&tags=frontend|backend|api';
+ const expected = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc&tags=frontend%7Cbackend%7Capi';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should encode query parameters with pipe operator and spaces', () => {
+ const url = 'https://example.com/api?categories=web development|mobile apps|data science&status=in progress|completed';
+ const expected = 'https://example.com/api?categories=web%20development%7Cmobile%20apps%7Cdata%20science&status=in%20progress%7Ccompleted';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+ });
+
+ describe('hash fragment handling', () => {
+ it('should preserve hash fragments with encoded query parameters', () => {
+ const url = 'https://example.com/api?name=john doe#section1';
+ const expected = 'https://example.com/api?name=john%20doe#section1';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should preserve hash fragments with pipe operator in query', () => {
+ const url = 'https://example.com/api?filter=status|active#results';
+ const expected = 'https://example.com/api?filter=status%7Cactive#results';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle invalid input gracefully', () => {
+ expect(encodeUrl('')).toBe('');
+ expect(encodeUrl(null as any)).toBe(null);
+ expect(encodeUrl(undefined as any)).toBe(undefined);
+ expect(encodeUrl(123 as any)).toBe(123);
+ });
+
+ it('should handle URLs with multiple question marks', () => {
+ const url = 'https://example.com/api?name=john?age=25';
+ const expected = 'https://example.com/api?name=john%3Fage%3D25';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle complex query parameters with multiple special characters', () => {
+ const url = 'https://example.com/api?search=hello world!@#$%^&*()&filter=active&sort=name asc';
+ const expected = 'https://example.com/api?search=hello%20world!%40#$%^&*()&filter=active&sort=name asc';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle already encoded URLs', () => {
+ const url = 'https://example.com/api?name=john%20doe&email=john%40example.com';
+ const expected = 'https://example.com/api?name=john%2520doe&email=john%2540example.com';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle pipe operator in already encoded URLs', () => {
+ const url = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc';
+ const expected = 'https://example.com/api?filter=status%257Cactive&sort=name%257Casc';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+ });
+
+ describe('real-world scenarios', () => {
+ it('should handle API URLs with complex query parameters', () => {
+ const url = 'https://api.github.com/search/repositories?q=language:javascript&sort=stars&order=desc&per_page=10';
+ const expected = 'https://api.github.com/search/repositories?q=language%3Ajavascript&sort=stars&order=desc&per_page=10';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle OAuth callback URLs', () => {
+ const url = 'https://myapp.com/callback?code=abc123&state=xyz789&redirect_uri=https://myapp.com/dashboard';
+ const expected = 'https://myapp.com/callback?code=abc123&state=xyz789&redirect_uri=https%3A%2F%2Fmyapp.com%2Fdashboard';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle GraphQL queries with pipe operator', () => {
+ const url = 'https://api.example.com/graphql?query=query{users(status:active|pending){id,name}}&variables={"filter":"status|active"}';
+ const expected = 'https://api.example.com/graphql?query=query%7Busers(status%3Aactive%7Cpending)%7Bid%2Cname%7D%7D&variables=%7B%22filter%22%3A%22status%7Cactive%22%7D';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle search APIs with complex queries', () => {
+ const url = 'https://api.example.com/search?q=react typescript tutorial&type=article,code&language=en&date_range=2023-01-01:2023-12-31&sort=relevance:desc';
+ const expected = 'https://api.example.com/search?q=react%20typescript%20tutorial&type=article%2Ccode&language=en&date_range=2023-01-01%3A2023-12-31&sort=relevance%3Adesc';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+
+ it('should handle e-commerce API filters', () => {
+ const url = 'https://api.shop.com/products?category=electronics&brand=apple|samsung|google&price_range=100:1000&rating=4.5:5.0&availability=in_stock&sort=price:asc&limit=50';
+ const expected = 'https://api.shop.com/products?category=electronics&brand=apple%7Csamsung%7Cgoogle&price_range=100%3A1000&rating=4.5%3A5.0&availability=in_stock&sort=price%3Aasc&limit=50';
+ expect(encodeUrl(url)).toBe(expected);
+ });
+ });
+});
+
+describe('parseQueryParams', () => {
+ it('should extract query parameters correctly', () => {
+ const queryString = 'name=john&age=25&active=true';
+ const result = parseQueryParams(queryString);
+ expect(result).toEqual([
+ { name: 'name', value: 'john' },
+ { name: 'age', value: '25' },
+ { name: 'active', value: 'true' }
+ ]);
+ });
+
+ it('should handle empty query string', () => {
+ const result = parseQueryParams('');
+ expect(result).toEqual([]);
+ });
+
+ it('should handle query parameters with empty values', () => {
+ const queryString = 'name=&age=25&active=';
+ const result = parseQueryParams(queryString);
+ expect(result).toEqual([
+ { name: 'name', value: '' },
+ { name: 'age', value: '25' },
+ { name: 'active', value: '' }
+ ]);
+ });
+
+ it('should extract query parameters with pipe operator', () => {
+ const queryString = 'filter=status|active&sort=name|asc&tags=frontend|backend';
+ const result = parseQueryParams(queryString);
+ expect(result).toEqual([
+ { name: 'filter', value: 'status|active' },
+ { name: 'sort', value: 'name|asc' },
+ { name: 'tags', value: 'frontend|backend' }
+ ]);
+ });
+});
+
+describe('buildQueryString', () => {
+ it('should build query string correctly', () => {
+ const params = [
+ { name: 'name', value: 'john' },
+ { name: 'age', value: '25' },
+ { name: 'active', value: 'true' }
+ ];
+ const result = buildQueryString(params);
+ expect(result).toBe('name=john&age=25&active=true');
+ });
+
+ it('should encode parameters by default', () => {
+ const params = [
+ { name: 'name', value: 'john doe' },
+ { name: 'email', value: 'john@example.com' }
+ ];
+ const result = buildQueryString(params);
+ expect(result).toBe('name=john%20doe&email=john%40example.com');
+ });
+
+ it('should encode pipe operator in parameters', () => {
+ const params = [
+ { name: 'filter', value: 'status|active' },
+ { name: 'sort', value: 'name|asc' },
+ { name: 'tags', value: 'frontend|backend|api' }
+ ];
+ const result = buildQueryString(params);
+ expect(result).toBe('filter=status%7Cactive&sort=name%7Casc&tags=frontend%7Cbackend%7Capi');
+ });
+
+ it('should not encode parameters when encode is false', () => {
+ const params = [
+ { name: 'filter', value: 'status|active' },
+ { name: 'sort', value: 'name|asc' }
+ ];
+ const result = buildQueryString(params, { encode: false });
+ expect(result).toBe('filter=status|active&sort=name|asc');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-requests/src/utils/url/index.ts b/packages/bruno-requests/src/utils/url/index.ts
new file mode 100644
index 000000000..201623100
--- /dev/null
+++ b/packages/bruno-requests/src/utils/url/index.ts
@@ -0,0 +1,76 @@
+interface QueryParam {
+ name: string;
+ value?: string;
+}
+
+interface BuildQueryStringOptions {
+ encode?: boolean;
+}
+
+interface ExtractQueryParamsOptions {
+ decode?: boolean;
+}
+
+function buildQueryString(paramsArray: QueryParam[], { encode = true }: BuildQueryStringOptions = {}): string {
+ return paramsArray
+ .filter(({ name }) => typeof name === 'string' && name.trim().length > 0)
+ .map(({ name, value }) => {
+ const finalName = encode ? encodeURIComponent(name) : name;
+ const finalValue = encode ? encodeURIComponent(value ?? '') : (value ?? '');
+
+ return `${finalName}=${finalValue}`;
+ })
+ .join('&');
+}
+
+function parseQueryParams(queryString: string, { decode = false }: ExtractQueryParamsOptions = {}): QueryParam[] {
+ const pairs = queryString.split('&');
+
+ const params = pairs.map(pair => {
+ const [name, ...valueParts] = pair.split('=');
+
+ if (!name) {
+ return null;
+ }
+
+ return {
+ name: decode ? decodeURIComponent(name) : name,
+ value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
+ };
+ }).filter((param): param is NonNullable => param !== null);
+
+ return params;
+}
+
+const encodeUrl = (url: string): string => {
+ // Early return for invalid input
+ if (!url || typeof url !== 'string') {
+ return url;
+ }
+
+ const [urlWithoutHash, ...hashFragments] = url.split('#');
+ const [basePath, ...queryString] = urlWithoutHash.split('?');
+
+ // If no query parameters exist, return original URL
+ if (!queryString || queryString.length === 0) {
+ return url;
+ }
+
+ const queryParams = parseQueryParams(queryString.join('?'), { decode: false });
+ // Parse and re-encode query parameters
+ const encodedQueryString = buildQueryString(queryParams, { encode: true });
+
+ // Reconstruct URL with encoded query parameters
+ const encodedUrl = `${basePath}?${encodedQueryString}${hashFragments.length > 0 ? `#${hashFragments.join('#')}` : ''}`;
+
+ return encodedUrl;
+};
+
+export {
+ encodeUrl,
+ parseQueryParams,
+ buildQueryString,
+ type QueryParam,
+ type BuildQueryStringOptions,
+ type ExtractQueryParamsOptions
+};
\ No newline at end of file
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
index 7a03141d5..0e9d31618 100644
--- a/packages/bruno-schema/src/collections/index.js
+++ b/packages/bruno-schema/src/collections/index.js
@@ -359,6 +359,12 @@ const itemSchema = Yup.object({
is: (type) => ['http-request', 'graphql-request'].includes(type),
then: (schema) => schema.required('request is required when item-type is request')
}),
+ settings: Yup.object({
+ encodeUrl: Yup.boolean().nullable()
+ })
+ .noUnknown(true)
+ .strict()
+ .nullable(),
fileContent: Yup.string().when('type', {
// If the type is 'js', the fileContent field is expected to be a string.
// This can include an empty string, indicating that the JS file may not have any content.
diff --git a/packages/bruno-toml/src/jsonToToml.js b/packages/bruno-toml/src/jsonToToml.js
index c61191ad8..d2922e245 100644
--- a/packages/bruno-toml/src/jsonToToml.js
+++ b/packages/bruno-toml/src/jsonToToml.js
@@ -92,6 +92,12 @@ const jsonToToml = (json) => {
}
}
+ if (json.settings && Object.keys(json.settings).length > 0) {
+ formattedJson.settings = {
+ encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true'
+ };
+ }
+
return stringify(formattedJson);
};
diff --git a/packages/bruno-toml/src/tomlToJson.js b/packages/bruno-toml/src/tomlToJson.js
index 37b50ad39..f5eea0f19 100644
--- a/packages/bruno-toml/src/tomlToJson.js
+++ b/packages/bruno-toml/src/tomlToJson.js
@@ -77,6 +77,12 @@ const tomlToJson = (toml) => {
}
}
+ if (json.settings && Object.keys(json.settings).length > 0) {
+ formattedJson.settings = {
+ encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true'
+ };
+ }
+
return formattedJson;
};