mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
feat(url): introduce setting to toggle encoding of URL query parameters (#5089)
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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', '')
|
||||
};
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
6
packages/bruno-requests/babel.config.js
Normal file
6
packages/bruno-requests/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
13
packages/bruno-requests/jest.config.js
Normal file
13
packages/bruno-requests/jest.config.js
Normal 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']
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './cookie-utils';
|
||||
export * from './url';
|
||||
|
||||
221
packages/bruno-requests/src/utils/url/index.spec.ts
Normal file
221
packages/bruno-requests/src/utils/url/index.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
76
packages/bruno-requests/src/utils/url/index.ts
Normal file
76
packages/bruno-requests/src/utils/url/index.ts
Normal 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
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user