feat(url): move url encoding utils to bruno common

This commit is contained in:
Maintainer Bruno
2025-07-15 02:19:21 +05:30
parent ecc6c1604c
commit dda1673a0f
16 changed files with 189 additions and 149 deletions

View File

@@ -1,4 +1,5 @@
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
import { parseQueryParams } from '@usebruno/common/utils';
import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
@@ -43,7 +44,7 @@ import {
import { each } from 'lodash';
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { parsePathParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';

View File

@@ -1,3 +1,4 @@
import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@usebruno/common/utils';
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
@@ -15,7 +16,7 @@ import {
isItemAFolder,
isItemARequest
} from 'utils/collections';
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { parsePathParams, splitOnFirst } from 'utils/url';
import { getSubdirectoriesFromRoot } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';

View File

@@ -9,7 +9,7 @@
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import { stringifyQueryParams } from '../url';
import { buildQueryString } from '@usebruno/common/utils';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -163,7 +163,7 @@ const curlToJson = (curlCommand) => {
}
if (request.queries) {
requestJson.url = requestJson.url + '?' + stringifyQueryParams(request.queries, { encode: false });
requestJson.url = requestJson.url + '?' + buildQueryString(request.queries, { encode: false });
}
if (request.multipartUploads) {

View File

@@ -2,7 +2,7 @@ import cookie from 'cookie';
import URL from 'url';
import { parse } from 'shell-quote';
import { isEmpty } from 'lodash';
import { parseQueryParams } from '../url';
import { parseQueryParams } from '@usebruno/common/utils';
/**
* Flag definitions - maps flag names to their states and actions

View File

@@ -1,6 +1,3 @@
import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim';
import each from 'lodash/each';
import find from 'lodash/find';
import { interpolate } from '@usebruno/common';
@@ -15,35 +12,6 @@ const hasLength = (str) => {
return str.length > 0;
};
export const parseQueryParams = (query, { decode = false } = {}) => {
try {
if (!query || !query.length) {
return [];
}
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 [];
}
};
export const parsePathParams = (url) => {
let uri = url.slice();
@@ -79,28 +47,6 @@ export const parsePathParams = (url) => {
return paths;
};
export const stringifyQueryParams = (params, { encode = false } = {}) => {
if (!params || isEmpty(params)) {
return '';
}
let queryString = [];
each(params, (p) => {
const hasEmptyName = isEmpty(trim(p.name));
const hasEmptyVal = isEmpty(p.value);
// query param name must be present
if (!hasEmptyName) {
const finalName = encode ? encodeURIComponent(p.name) : p.name;
const finalValue = encode ? encodeURIComponent(p.value) : p.value;
queryString.push(hasEmptyVal ? finalName : `${finalName}=${finalValue}`);
}
});
return queryString.join('&');
};
export const splitOnFirst = (str, char) => {
if (!str || !str.length) {
return [str];

View File

@@ -1,72 +1,4 @@
import { parseQueryParams, splitOnFirst, parsePathParams, interpolateUrl, interpolateUrlPathParams } from './index';
describe('Url Utils - parseQueryParams', () => {
it('should parse query - case 1', () => {
const params = parseQueryParams('');
expect(params).toEqual([]);
});
it('should parse query - case 2', () => {
const params = parseQueryParams('a');
expect(params).toEqual([{ name: 'a', value: '' }]);
});
it('should parse query - case 3', () => {
const params = parseQueryParams('a=');
expect(params).toEqual([{ name: 'a', value: '' }]);
});
it('should parse query - case 4', () => {
const params = parseQueryParams('a=1');
expect(params).toEqual([{ name: 'a', value: '1' }]);
});
it('should parse query - case 5', () => {
const params = parseQueryParams('a=1&');
expect(params).toEqual([{ name: 'a', value: '1' }]);
});
it('should parse query - case 6', () => {
const params = parseQueryParams('a=1&b');
expect(params).toEqual([
{ name: 'a', value: '1' },
{ name: 'b', value: '' }
]);
});
it('should parse query - case 7', () => {
const params = parseQueryParams('a=1&b=');
expect(params).toEqual([
{ name: 'a', value: '1' },
{ name: 'b', value: '' }
]);
});
it('should parse query - case 8', () => {
const params = parseQueryParams('a=1&b=2');
expect(params).toEqual([
{ name: 'a', value: '1' },
{ name: 'b', value: '2' }
]);
});
it('should parse query with "=" character - case 9', () => {
const params = parseQueryParams('a=1&b={color=red,size=large}&c=3');
expect(params).toEqual([
{ name: 'a', value: '1' },
{ name: 'b', value: '{color=red,size=large}' },
{ name: 'c', value: '3' }
]);
});
it('should parse query with fragment - case 10', () => {
const params = parseQueryParams('a=1&b=2#I-AM-FRAGMENT');
expect(params).toEqual([
{ name: 'a', value: '1' },
{ name: 'b', value: '2' }
]);
});
});
import { splitOnFirst, parsePathParams, interpolateUrl, interpolateUrlPathParams } from './index';
describe('Url Utils - parsePathParams', () => {
it('should parse path - case 1', () => {

View File

@@ -26,7 +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 { encodeUrl } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);

View File

@@ -15,6 +15,11 @@
"require": "./dist/runner/cjs/index.js",
"import": "./dist/runner/esm/index.js",
"types": "./dist/runner/index.d.ts"
},
"./utils": {
"require": "./dist/utils/cjs/index.js",
"import": "./dist/utils/esm/index.js",
"types": "./dist/utils/index.d.ts"
}
},
"files": [

View File

@@ -55,5 +55,11 @@ module.exports = [
input: 'src/runner/index.ts',
cjsOutput: 'dist/runner/cjs/index.js',
esmOutput: 'dist/runner/esm/index.js'
}),
...createBuildConfig({
inputDir: 'src/utils/**/*',
input: 'src/utils/index.ts',
cjsOutput: 'dist/utils/cjs/index.js',
esmOutput: 'dist/utils/esm/index.js'
})
];

View File

@@ -1,2 +1,4 @@
export { mockDataFunctions } from './utils/faker-functions';
export { default as interpolate } from './interpolate';
export * as utils from './utils';

View File

@@ -0,0 +1,5 @@
export {
encodeUrl,
parseQueryParams,
buildQueryString
} from './url';

View File

@@ -46,7 +46,7 @@ describe('encodeUrl', () => {
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=';
const expected = 'https://example.com/api?name&age=25&active';
expect(encodeUrl(url)).toBe(expected);
});
@@ -196,7 +196,7 @@ describe('buildQueryString', () => {
{ name: 'name', value: 'john doe' },
{ name: 'email', value: 'john@example.com' }
];
const result = buildQueryString(params);
const result = buildQueryString(params, { encode: true });
expect(result).toBe('name=john%20doe&email=john%40example.com');
});
@@ -206,7 +206,7 @@ describe('buildQueryString', () => {
{ name: 'sort', value: 'name|asc' },
{ name: 'tags', value: 'frontend|backend|api' }
];
const result = buildQueryString(params);
const result = buildQueryString(params, { encode: true });
expect(result).toBe('filter=status%7Cactive&sort=name%7Casc&tags=frontend%7Cbackend%7Capi');
});

View File

@@ -11,35 +11,45 @@ interface ExtractQueryParamsOptions {
decode?: boolean;
}
function buildQueryString(paramsArray: QueryParam[], { encode = true }: BuildQueryStringOptions = {}): string {
function buildQueryString(paramsArray: QueryParam[], { encode = false }: 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}`;
return finalValue ? `${finalName}=${finalValue}` : finalName;
})
.join('&');
}
function parseQueryParams(queryString: string, { decode = false }: ExtractQueryParamsOptions = {}): QueryParam[] {
const pairs = queryString.split('&');
function parseQueryParams(query: string, { decode = false }: ExtractQueryParamsOptions = {}): QueryParam[] {
if (!query || !query.length) {
return [];
}
const params = pairs.map(pair => {
const [name, ...valueParts] = pair.split('=');
try {
const [queryString, ...hashParts] = query.split('#');
const pairs = queryString.split('&');
if (!name) {
return null;
}
const params = pairs.map(pair => {
const [name, ...valueParts] = pair.split('=');
return {
name: decode ? decodeURIComponent(name) : name,
value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
};
}).filter((param): param is NonNullable<typeof param> => param !== null);
if (!name) {
return null;
}
return params;
return {
name: decode ? decodeURIComponent(name) : name,
value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
};
}).filter((param): param is NonNullable<typeof param> => param !== null);
return params;
} catch (error) {
console.error('Error parsing query params:', error);
return [];
}
}
const encodeUrl = (url: string): string => {

View File

@@ -12,7 +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 { encodeUrl } = require('@usebruno/common').utils;
const { interpolateString } = require('./interpolate-string');
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('@usebruno/requests');

View File

@@ -0,0 +1,133 @@
const { isPotentiallyTrustworthyOrigin } = require('./cookie-utils');
describe('isPotentiallyTrustworthyOrigin', () => {
describe('secure schemes', () => {
it('should return true for HTTPS URLs', () => {
expect(isPotentiallyTrustworthyOrigin('https://example.com')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('https://api.github.com/v1/users')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('https://localhost:3000')).toBe(true);
});
it('should return true for WSS URLs', () => {
expect(isPotentiallyTrustworthyOrigin('wss://example.com')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('wss://localhost:8080/ws')).toBe(true);
});
it('should return true for file URLs', () => {
expect(isPotentiallyTrustworthyOrigin('file:///path/to/file.html')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('file://localhost/path/to/file.html')).toBe(true);
});
});
describe('insecure schemes', () => {
it('should return false for HTTP URLs with non-localhost domains', () => {
expect(isPotentiallyTrustworthyOrigin('http://example.com')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('http://api.github.com')).toBe(false);
});
it('should return false for WS URLs with non-localhost domains', () => {
expect(isPotentiallyTrustworthyOrigin('ws://example.com')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('ws://api.github.com')).toBe(false);
});
it('should return false for other schemes', () => {
expect(isPotentiallyTrustworthyOrigin('ftp://example.com')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('ssh://example.com')).toBe(false);
});
it('should return true for HTTP/WS URLs with localhost (localhost is always trustworthy)', () => {
expect(isPotentiallyTrustworthyOrigin('http://localhost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('ws://localhost')).toBe(true);
});
});
describe('loopback addresses', () => {
describe('IPv4 loopback', () => {
it('should return true for 127.0.0.1', () => {
expect(isPotentiallyTrustworthyOrigin('http://127.0.0.1')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://127.0.0.1:3000')).toBe(true);
});
it('should return true for other 127.x.x.x addresses', () => {
expect(isPotentiallyTrustworthyOrigin('http://127.0.0.0')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://127.255.255.255')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://127.1.2.3')).toBe(true);
});
it('should return false for non-loopback IPv4 addresses', () => {
expect(isPotentiallyTrustworthyOrigin('http://192.168.1.1')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('http://10.0.0.1')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('http://172.16.0.1')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('http://8.8.8.8')).toBe(false);
});
});
describe('IPv6 loopback', () => {
it('should return true for ::1', () => {
expect(isPotentiallyTrustworthyOrigin('http://[::1]')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://[::1]:3000')).toBe(true);
});
it('should return false for non-loopback IPv6 addresses', () => {
expect(isPotentiallyTrustworthyOrigin('http://[2001:db8::1]')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('http://[fe80::1]')).toBe(false);
});
});
});
describe('localhost hostnames', () => {
it('should return true for localhost and *.localhost domains', () => {
expect(isPotentiallyTrustworthyOrigin('http://localhost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://localhost:3000')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://app.localhost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://api.localhost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://sub.domain.localhost')).toBe(true);
});
it('should handle case insensitive localhost', () => {
expect(isPotentiallyTrustworthyOrigin('http://LOCALHOST')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://LocalHost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://APP.LOCALHOST')).toBe(true);
});
it('should return false for non-localhost domains', () => {
expect(isPotentiallyTrustworthyOrigin('http://api.example.com')).toBe(false);
expect(isPotentiallyTrustworthyOrigin('http://localhost.example.com')).toBe(false);
});
});
describe('edge cases', () => {
it('should handle trailing dots in hostnames', () => {
expect(isPotentiallyTrustworthyOrigin('http://localhost.')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://app.localhost.')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://example.com.')).toBe(false);
});
it('should handle URLs with query parameters and fragments', () => {
expect(isPotentiallyTrustworthyOrigin('https://example.com/path?query=value#fragment')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://localhost/path?query=value#fragment')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://api.example.com/path?query=value#fragment')).toBe(false);
});
it('should handle URLs with authentication', () => {
expect(isPotentiallyTrustworthyOrigin('https://user:pass@example.com')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://user:pass@localhost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('http://user:pass@api.example.com')).toBe(false);
});
});
describe('mixed scenarios', () => {
it('should prioritize secure schemes over hostname checks', () => {
// Even though example.com is not localhost, HTTPS makes it trustworthy
expect(isPotentiallyTrustworthyOrigin('https://example.com')).toBe(true);
// Even though 192.168.1.1 is not loopback, HTTPS makes it trustworthy
expect(isPotentiallyTrustworthyOrigin('https://192.168.1.1')).toBe(true);
});
it('should handle localhost with different schemes', () => {
expect(isPotentiallyTrustworthyOrigin('https://localhost')).toBe(true);
expect(isPotentiallyTrustworthyOrigin('wss://localhost')).toBe(true);
});
});
});

View File

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