feat(url): introduce setting to toggle encoding of URL query parameters (#5089)

This commit is contained in:
maintainer-bruno
2025-07-15 00:23:17 +05:30
committed by GitHub
parent e89a240237
commit ecc6c1604c
29 changed files with 590 additions and 70 deletions

View File

@@ -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 <Documentation item={item} collection={collection} />;
}
case 'settings': {
return <Settings item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
@@ -158,6 +162,9 @@ const HttpRequestPane = ({ item, collection }) => {
Docs
{docs && docs.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">
<RequestBodyMode item={item} collection={collection} />

View File

@@ -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 (
<div className="flex items-center gap-3">
<button
type="button"
onClick={onChange}
disabled={disabled}
className={`
relative inline-flex ${currentSize.container} mx-1 items-center rounded-full transition-colors
focus:outline-none focus:ring-1 focus:ring-offset-1
${disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}
${checked
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-700'
}
`}
role="switch"
aria-checked={checked}
aria-disabled={disabled}
>
<span
className={`
inline-block ${currentSize.thumb} transform rounded-full bg-white transition-transform
${currentSize.translate}
`}
/>
</button>
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
);
};
export default ToggleSelector;

View File

@@ -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 (
<div className="h-full flex flex-col gap-2">
<div className='flex flex-col gap-4'>
<ToggleSelector
checked={encodeUrl}
onChange={onToggleUrlEncoding}
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
/>
</div>
</div>
);
};
export default Settings;

View File

@@ -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(() => {

View File

@@ -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
}
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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&param2=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' }
]
});
});
});

View File

@@ -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 <param-name>, else push <param-name: param-value>
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}`);
}
});

View File

@@ -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'
};

View File

@@ -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}`;
}

View File

@@ -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'),

View File

@@ -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', '')
};

View File

@@ -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') {

View File

@@ -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'
};

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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)}

View File

@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};

View File

@@ -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']
};

View File

@@ -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",

View File

@@ -1 +1,2 @@
export * from './cookie-utils';
export * from './url';

View File

@@ -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');
});
});

View File

@@ -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<typeof param> => 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
};

View File

@@ -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.

View File

@@ -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);
};

View File

@@ -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;
};