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
404 | Not found
; } @@ -158,6 +162,9 @@ const HttpRequestPane = ({ item, collection }) => { Docs {docs && docs.length > 0 && } +
selectTab('settings')}> + Settings +
{focusedTab.requestPaneTab === 'body' ? (
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; };