mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
Enable encodeUrl setting to control URL encoding in generated snippets (#7187)
* feat(snippet-generator): implement encodeUrl setting to control URL encoding in generated snippets * refactor(snippet-generator): rename and enhance URL encoding logic for better clarity and functionality * feat(snippet-generator): enhance raw URL handling to preserve user encoding choices and improve snippet generation * test(snippet-generator): add tests for URL fragment handling based on encodeUrl setting * test(snippet-generator): improve comments on URL fragment handling to clarify RFC compliance * feat(url): enhance interpolateUrlPathParams to support raw URL handling, preserving user encoding choices for snippet generation * fix(url): ensure URLs are prefixed with http:// if missing in interpolateUrlPathParams function * refactor(snippet-generator): streamline URL handling logic to improve snippet generation and ensure proper encoding based on settings * feat(url): add stripOrigin utility to simplify URL processing in snippet generation * test(snippet-generator): add test for double-encoding of pre-encoded URLs when encodeUrl is true * feat(encoding): implement URL encoding settings and add tests for encoding behavior * fix: address PR review comments (#7187) - Remove unnecessary no-op jest.mock for @usebruno/common/utils - Add length guard to prevent catastrophic replaceAll('/') on root-path URLs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * empty commit * fix(tests): update interpolateUrlPathParams tests to use correct parameter structure * empty commit --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,7 @@ const Settings = ({ item, collection }) => {
|
||||
label="URL Encoding"
|
||||
description="Automatically encode query parameters in the URL"
|
||||
size="medium"
|
||||
data-testid="encode-url-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
|
||||
variables
|
||||
);
|
||||
|
||||
// Raw URL: path params resolved via string replacement (no new URL() encoding),
|
||||
// preserving the user's original encoding choices for snippet generation.
|
||||
const rawUrl = interpolateUrlPathParams(interpolatedUrl, requestData.params, variables, { raw: true });
|
||||
|
||||
// Get the full language object based on current preferences
|
||||
const selectedLanguage = useMemo(() => {
|
||||
const fullName = generateCodePrefs.library === 'default'
|
||||
@@ -120,7 +124,8 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
|
||||
...requestData.request,
|
||||
auth: resolvedRequest.auth,
|
||||
url: finalUrl
|
||||
}
|
||||
},
|
||||
rawUrl
|
||||
};
|
||||
|
||||
// Update modal title based on mode
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from '
|
||||
import { resolveInheritedAuth } from 'utils/auth';
|
||||
import { get } from 'lodash';
|
||||
import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation';
|
||||
import { encodeUrl as encodeUrlCommon, stripOrigin } from '@usebruno/common/utils';
|
||||
import { parse } from 'url';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
const addCurlAuthFlags = (curlCommand, auth) => {
|
||||
if (!auth || !curlCommand) return curlCommand;
|
||||
@@ -79,6 +82,38 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
|
||||
result = addCurlAuthFlags(result, effectiveAuth);
|
||||
}
|
||||
|
||||
// Respect encodeUrl setting: when not explicitly true, replace HTTPSnippet's encoded path+query with the raw version.
|
||||
// Replacing the path portion works for all targets since it's a substring of the full URL.
|
||||
// encodeUrl defaults to false in the UI when undefined/null
|
||||
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
|
||||
const rawUrl = item.rawUrl || request.url;
|
||||
const parsed = parse(request.url, true, true);
|
||||
const search = stringify(parsed.query);
|
||||
const httpSnippetPath = search ? `${parsed.pathname}?${search}` : parsed.pathname;
|
||||
|
||||
let desiredPath;
|
||||
if (settings?.encodeUrl === true) {
|
||||
// Apply the same encodeUrl() transform used by the actual request execution path
|
||||
// so the snippet matches what's sent on the wire.
|
||||
const encodedUrl = encodeUrlCommon(rawUrl);
|
||||
desiredPath = stripOrigin(encodedUrl);
|
||||
// Strip fragment per RFC 3986 §3.5
|
||||
desiredPath = desiredPath.replace(/#.*$/, '');
|
||||
} else {
|
||||
desiredPath = stripOrigin(rawUrl);
|
||||
// The HTTP raw target (http/http1.1) uses the request line format:
|
||||
// METHOD <request-target> HTTP-version
|
||||
// Spaces delimit these fields, so a literal space in the request-target
|
||||
// would be parsed as the end of the URI (RFC 7230 §3.1.1).
|
||||
if (language.target === 'http') {
|
||||
desiredPath = desiredPath.replace(/ /g, '%20');
|
||||
}
|
||||
}
|
||||
|
||||
if (httpSnippetPath !== desiredPath && httpSnippetPath?.length > 1) {
|
||||
result = result.replaceAll(httpSnippetPath, desiredPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error generating code snippet:', error);
|
||||
|
||||
@@ -906,3 +906,338 @@ describe('generateSnippet – digest and NTLM auth curl export', () => {
|
||||
expect(result).toMatch(/^curl --digest --user 'myuser'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSnippet – encodeUrl setting', () => {
|
||||
const language = { target: 'shell', client: 'curl' };
|
||||
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
|
||||
|
||||
// Replicate HTTPSnippet's internal encoding to get encoded path+query
|
||||
const getEncodedPath = (url) => {
|
||||
const { parse } = require('url');
|
||||
const { stringify } = require('query-string');
|
||||
const parsed = parse(url, true, true);
|
||||
if (!parsed.query || Object.keys(parsed.query).length === 0) {
|
||||
return parsed.pathname;
|
||||
}
|
||||
const search = stringify(parsed.query);
|
||||
return search ? `${parsed.pathname}?${search}` : parsed.pathname;
|
||||
};
|
||||
|
||||
const makeItem = (url, settings, draft) => ({
|
||||
uid: 'enc-req',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: [],
|
||||
body: { mode: 'none' },
|
||||
auth: { mode: 'none' }
|
||||
},
|
||||
...(settings !== undefined && { settings }),
|
||||
...(draft !== undefined && { draft })
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock HTTPSnippet to simulate encoding (same pipeline as the real library)
|
||||
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
|
||||
convert: jest.fn((target) => {
|
||||
const method = harRequest?.method || 'GET';
|
||||
const url = harRequest?.url || 'http://example.com';
|
||||
const { parse } = require('url');
|
||||
const parsed = parse(url, false, true);
|
||||
const encodedPath = getEncodedPath(url);
|
||||
// Simulate targets that use only the path (e.g., python http.client, raw HTTP)
|
||||
if (target === 'python') {
|
||||
return `conn.request("${method}", "${encodedPath}", headers=headers)`;
|
||||
}
|
||||
// Full URL targets: reconstruct with encoded path
|
||||
const fullEncodedUrl = `${parsed.protocol}//${parsed.host}${encodedPath}`;
|
||||
return `curl -X ${method} '${fullEncodedUrl}'`;
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
it('should preserve equals signs in query values when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc123==');
|
||||
// %3D = encoded '='
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve email with plus alias and @ when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/invite?email=test+alias@example.com';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('email=test+alias@example.com');
|
||||
});
|
||||
|
||||
it('should preserve redirect URL with colons and slashes when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('redirect=https://other.com/callback');
|
||||
// %3A = encoded ':'
|
||||
expect(result).not.toContain('%3A');
|
||||
// %2F = encoded '/'
|
||||
expect(result).not.toContain('%2F');
|
||||
});
|
||||
|
||||
it('should preserve comma-separated values when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/filter?tags=a,b,c&time=10:30';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('tags=a,b,c');
|
||||
expect(result).toContain('time=10:30');
|
||||
});
|
||||
|
||||
it('should encode URL when encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// %3D%3D = encoded '=='
|
||||
expect(result).toContain('%3D%3D');
|
||||
});
|
||||
|
||||
it('should preserve raw URL when settings are absent (encodeUrl defaults to false)', () => {
|
||||
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback';
|
||||
const item = makeItem(rawUrl);
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('redirect=https://other.com/callback');
|
||||
// %3A = encoded ':'
|
||||
expect(result).not.toContain('%3A');
|
||||
});
|
||||
|
||||
it('should be a no-op for URLs without query params and no encoding needed', () => {
|
||||
const rawUrl = 'https://example.com/api/users';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toBe(`curl -X GET '${rawUrl}'`);
|
||||
});
|
||||
|
||||
it('should preserve spaces in pathname when encodeUrl is false and rawUrl is provided', () => {
|
||||
const encodedUrl = 'https://example.com/my%20path/hello%20world?token=abc123==';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/my path/hello world?token=abc123=='
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/my path/hello world?token=abc123==');
|
||||
expect(result).not.toContain('%20');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve spaces in pathname without query params when encodeUrl is false', () => {
|
||||
const encodedUrl = 'https://example.com/my%20path/hello%20world';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/my path/hello world'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/my path/hello world');
|
||||
expect(result).not.toContain('%20');
|
||||
});
|
||||
|
||||
it('should preserve spaces in path-only targets (e.g., python) when encodeUrl is false', () => {
|
||||
const pythonLanguage = { target: 'python', client: 'python3' };
|
||||
const encodedUrl = 'https://example.com/my%20path/hello%20world?q=test';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/my path/hello world?q=test'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/my path/hello world?q=test');
|
||||
expect(result).not.toContain('%20');
|
||||
});
|
||||
|
||||
it('should preserve spaces in query values when encodeUrl is false and rawUrl is provided', () => {
|
||||
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc 123==');
|
||||
expect(result).not.toContain('%20');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should still work when rawUrl is not provided (backward compatibility)', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc123==');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should keep spaces as %20 for http target when encodeUrl is false (HTTP spec compliance)', () => {
|
||||
const httpLanguage = { target: 'http', client: 'http1.1' };
|
||||
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
|
||||
};
|
||||
const result = generateSnippet({ language: httpLanguage, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Spaces must remain encoded for valid HTTP request line
|
||||
expect(result).toContain('%20');
|
||||
// But other chars like = should still be decoded
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve user-typed %20 when encodeUrl is false (not decode to space)', () => {
|
||||
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
|
||||
const item = {
|
||||
...makeItem(preEncodedUrl, { encodeUrl: false }),
|
||||
rawUrl: preEncodedUrl // rawUrl has %20 intact (no decodeURI applied)
|
||||
};
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// %20 should be preserved, not decoded to a literal space
|
||||
expect(result).toContain('%20');
|
||||
// %3D should also be preserved
|
||||
expect(result).toContain('%3D%3D');
|
||||
// No double-encoding
|
||||
expect(result).not.toContain('%2520');
|
||||
expect(result).not.toContain('%253D');
|
||||
});
|
||||
|
||||
it('should double-encode pre-encoded %20 when encodeUrl is true', () => {
|
||||
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
|
||||
const item = {
|
||||
...makeItem(preEncodedUrl, { encodeUrl: true }),
|
||||
rawUrl: preEncodedUrl
|
||||
};
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// %20 → %2520 because encodeURIComponent encodes the literal '%' in the already-encoded value
|
||||
expect(result).toContain('%2520');
|
||||
// %3D → %253D for the same reason
|
||||
expect(result).toContain('%253D');
|
||||
});
|
||||
|
||||
it('should preserve OData-style paths with parenthesized params when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10';
|
||||
const item = {
|
||||
...makeItem(rawUrl, { encodeUrl: false }),
|
||||
rawUrl
|
||||
};
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('Products(123)/Categories(456)');
|
||||
expect(result).toContain('$expand=Items');
|
||||
expect(result).toContain('$filter=Price gt 10');
|
||||
// $ should not be encoded
|
||||
expect(result).not.toContain('%24');
|
||||
});
|
||||
|
||||
it('should use draft settings when draft exists', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true }, { settings: { encodeUrl: false } });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc123==');
|
||||
// %3D%3D = encoded '=='
|
||||
expect(result).not.toContain('%3D%3D');
|
||||
});
|
||||
|
||||
it('should replace encoded path for targets that use only path+query (e.g., python http.client)', () => {
|
||||
const pythonLanguage = { target: 'python', client: 'python3' };
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/api?token=abc123==&type=test');
|
||||
// %3D = encoded '='
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve URL fragment (#) in snippet when encodeUrl is false', () => {
|
||||
// Intentional asymmetry: when encodeUrl is false (raw mode), generateSnippet preserves the
|
||||
// user-supplied URL as-is, including any fragment. This contrasts with encodeUrl: true,
|
||||
// which strips fragments per RFC 3986 §3.5. The rawUrl is preserved through the makeItem
|
||||
// call with { encodeUrl: false } and passed to generateSnippet, which intentionally treats
|
||||
// it as a user-specified string not subject to RFC-compliant stripping. This is a designed
|
||||
// behavior to honor user intent in raw mode, not a bug. This behavior can be revisited in
|
||||
// the future if requirements or RFC interpretations change.
|
||||
const rawUrl = 'https://example.com/api?token=abc==#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('#section');
|
||||
expect(result).toContain('token=abc==');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should not include URL fragment (#) in snippet when encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc==#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Fragment is stripped — correct per RFC 3986 §3.5: user agents MUST NOT include the fragment
|
||||
// in the HTTP request target sent to the origin server (though fragments can still appear in
|
||||
// user-facing URLs, SPA routing, and are inherited across redirects per RFC 9110 §10.2.2).
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
|
||||
// https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.2
|
||||
expect(result).not.toContain('#section');
|
||||
expect(result).toContain('%3D%3D');
|
||||
});
|
||||
|
||||
it('should single-encode spaces and special chars when encodeUrl is true and rawUrl is provided', () => {
|
||||
// The raw URL (before new URL() encoding) contains literal spaces and @.
|
||||
// encodeUrl() should encode them once: space → %20, @ → %40.
|
||||
// Previously this double-encoded because request.url was already encoded by new URL().
|
||||
const encodedUrl = 'https://example.com/api?name=abc%20os&email=user%40test.com';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: true }),
|
||||
rawUrl: 'https://example.com/api?name=abc os&email=user@test.com'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// space → %20 (single encoding, not %2520)
|
||||
expect(result).toContain('%20');
|
||||
expect(result).not.toContain('%2520');
|
||||
// @ → %40 (single encoding, not %2540)
|
||||
expect(result).toContain('%40');
|
||||
expect(result).not.toContain('%2540');
|
||||
});
|
||||
|
||||
it('should encode special chars in query values when encodeUrl is true (e.g., redirect URLs)', () => {
|
||||
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// : → %3A, / → %2F when encodeURIComponent is applied to query values
|
||||
expect(result).toContain('%3A');
|
||||
expect(result).toContain('%2F');
|
||||
});
|
||||
|
||||
it('should strip fragment and apply encodeUrl when both are present and encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?redirect=https://other.com/cb#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Fragment stripped per RFC 3986
|
||||
expect(result).not.toContain('#section');
|
||||
// Query value should be encoded
|
||||
expect(result).toContain('%3A');
|
||||
expect(result).toContain('%2F');
|
||||
});
|
||||
|
||||
it('should be a no-op for path-only URLs when encodeUrl is true (no query params to encode)', () => {
|
||||
const rawUrl = 'https://example.com/api/users';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toBe(`curl -X GET '${rawUrl}'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ export const interpolateUrl = ({ url, variables }) => {
|
||||
return interpolate(url, variables);
|
||||
};
|
||||
|
||||
export const interpolateUrlPathParams = (url, params, variables = {}) => {
|
||||
export const interpolateUrlPathParams = (url, params, variables = {}, options = {}) => {
|
||||
const getInterpolatedBasePath = (pathname, params) => {
|
||||
let replacedPathname = pathname
|
||||
.split('/')
|
||||
@@ -141,12 +141,28 @@ export const interpolateUrlPathParams = (url, params, variables = {}) => {
|
||||
return interpolate(replacedPathname, variables);
|
||||
};
|
||||
|
||||
let uri;
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `http://${url}`;
|
||||
}
|
||||
|
||||
// When raw is true, resolve :params via pure string manipulation without
|
||||
// passing through new URL(), which would percent-encode characters like spaces.
|
||||
// This preserves the user's original encoding choices for snippet generation.
|
||||
if (options.raw) {
|
||||
const enabledPathParams = (params || []).filter((p) => p.enabled !== false && p.type === 'path');
|
||||
if (enabledPathParams.length === 0) return url;
|
||||
|
||||
const separatorIdx = url.search(/[?#]/);
|
||||
const pathPart = separatorIdx >= 0 ? url.substring(0, separatorIdx) : url;
|
||||
const rest = separatorIdx >= 0 ? url.substring(separatorIdx) : '';
|
||||
|
||||
// resolvedPath includes the origin (scheme + host) since pathPart is the full URL before ?/#
|
||||
const resolvedPath = getInterpolatedBasePath(pathPart, enabledPathParams);
|
||||
return `${resolvedPath}${rest}`;
|
||||
}
|
||||
|
||||
let uri;
|
||||
|
||||
try {
|
||||
uri = new URL(url);
|
||||
} catch (error) {
|
||||
|
||||
@@ -372,3 +372,68 @@ describe('Url Utils - interpolateUrl, interpolateUrlPathParams', () => {
|
||||
expect(result).toEqual(expectedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Url Utils - interpolateUrlPathParams with { raw: true }', () => {
|
||||
it('should resolve :params without encoding (spaces stay as spaces)', () => {
|
||||
const url = 'https://example.com/api/:id/path';
|
||||
const params = [{ name: 'id', type: 'path', enabled: true, value: 'hello world' }];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/api/hello world/path');
|
||||
});
|
||||
|
||||
it('should preserve query string and fragment as-is', () => {
|
||||
const url = 'https://example.com/api/:id?foo=bar&baz=qux#section';
|
||||
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/api/123?foo=bar&baz=qux#section');
|
||||
});
|
||||
|
||||
it('should return URL unchanged when no path params match', () => {
|
||||
const url = 'https://example.com/api/path?q=1';
|
||||
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/api/path?q=1');
|
||||
});
|
||||
|
||||
it('should return URL unchanged when params array is empty', () => {
|
||||
const url = 'https://example.com/api/:id';
|
||||
const params = [];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/api/:id');
|
||||
});
|
||||
|
||||
it('should handle OData-style params', () => {
|
||||
const url = 'https://example.com/odata/Products(\':productId\')';
|
||||
const params = [{ name: 'productId', type: 'path', enabled: true, value: 'ABC 123' }];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/odata/Products(\'ABC 123\')');
|
||||
});
|
||||
|
||||
it('should preserve existing percent-encoding', () => {
|
||||
const url = 'https://example.com/api/:id/already%20encoded';
|
||||
const params = [{ name: 'id', type: 'path', enabled: true, value: '456' }];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/api/456/already%20encoded');
|
||||
});
|
||||
|
||||
it('should skip disabled params', () => {
|
||||
const url = 'https://example.com/api/:id';
|
||||
const params = [{ name: 'id', type: 'path', enabled: false, value: '123' }];
|
||||
|
||||
const result = interpolateUrlPathParams(url, params, {}, { raw: true });
|
||||
|
||||
expect(result).toEqual('https://example.com/api/:id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export {
|
||||
encodeUrl,
|
||||
parseQueryParams,
|
||||
buildQueryString
|
||||
buildQueryString,
|
||||
stripOrigin
|
||||
} from './url';
|
||||
|
||||
export {
|
||||
|
||||
@@ -76,10 +76,23 @@ const encodeUrl = (url: string): string => {
|
||||
return encodedUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the origin (scheme + authority) from a URL, returning the path, query, and fragment.
|
||||
* Returns '/' if the URL has no path component.
|
||||
*
|
||||
* @example
|
||||
* stripOrigin('https://example.com/api/users?name=foo') // '/api/users?name=foo'
|
||||
* stripOrigin('http://localhost:3000') // '/'
|
||||
*/
|
||||
const stripOrigin = (url: string): string => {
|
||||
return url.replace(/^https?:\/\/[^/?#]*/, '') || '/';
|
||||
};
|
||||
|
||||
export {
|
||||
encodeUrl,
|
||||
parseQueryParams,
|
||||
buildQueryString,
|
||||
stripOrigin,
|
||||
type QueryParam,
|
||||
type BuildQueryStringOptions,
|
||||
type ExtractQueryParamsOptions
|
||||
|
||||
9
tests/request/encoding/collection/bruno.json
Normal file
9
tests/request/encoding/collection/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "encoding-test",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
15
tests/request/encoding/collection/encode-url-preencoded.bru
Normal file
15
tests/request/encoding/collection/encode-url-preencoded.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: encode-url-preencoded
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://base.source?name=John%20Doe
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
15
tests/request/encoding/collection/encode-url-unencoded.bru
Normal file
15
tests/request/encoding/collection/encode-url-unencoded.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: encode-url-unencoded
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://base.source?name=John Doe
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
15
tests/request/encoding/collection/raw-url-preencoded.bru
Normal file
15
tests/request/encoding/collection/raw-url-preencoded.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: raw-url-preencoded
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://base.source?name=John%20Doe
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: false
|
||||
}
|
||||
15
tests/request/encoding/collection/raw-url-unencoded.bru
Normal file
15
tests/request/encoding/collection/raw-url-unencoded.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: raw-url-unencoded
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://base.source?name=John Doe
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: false
|
||||
}
|
||||
@@ -1,78 +1,85 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, createCollection, createRequest } from '../../utils/page';
|
||||
import { openCollection } from '../../utils/page';
|
||||
import { buildCommonLocators } from '../../utils/page/locators';
|
||||
|
||||
test.describe('Code Generation URL Encoding', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
const modalCloseButton = page.getByTestId('modal-close-button');
|
||||
if (await modalCloseButton.isVisible()) {
|
||||
await modalCloseButton.click();
|
||||
await modalCloseButton.waitFor({ state: 'hidden' });
|
||||
}
|
||||
} catch (e) {}
|
||||
test.describe('when encodeUrl is true', () => {
|
||||
test('should encode unencoded URL (spaces to %20)', async ({ pageWithUserData: page }) => {
|
||||
const { sidebar, request, modal } = buildCommonLocators(page);
|
||||
|
||||
await closeAllCollections(page);
|
||||
await openCollection(page, 'encoding-test');
|
||||
await sidebar.request('encode-url-unencoded').click();
|
||||
|
||||
await request.generateCodeButton().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
expect(generatedCode).toContain('http://base.source?name=John%20Doe');
|
||||
|
||||
await modal.closeButton().click();
|
||||
await modal.closeButton().waitFor({ state: 'hidden' });
|
||||
});
|
||||
|
||||
test('should double-encode pre-encoded URL (%20 to %2520)', async ({ pageWithUserData: page }) => {
|
||||
const { sidebar, request, modal } = buildCommonLocators(page);
|
||||
|
||||
await openCollection(page, 'encoding-test');
|
||||
await sidebar.request('encode-url-preencoded').click();
|
||||
|
||||
await request.generateCodeButton().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
expect(generatedCode).toContain('http://base.source?name=John%2520Doe');
|
||||
|
||||
await modal.closeButton().click();
|
||||
await modal.closeButton().waitFor({ state: 'hidden' });
|
||||
});
|
||||
});
|
||||
|
||||
test('Should generate code with proper URL encoding for unencoded input', async ({
|
||||
page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
const collectionName = 'unencoded-test-collection';
|
||||
const requestName = 'curl-encoding-unencoded';
|
||||
test.describe('when encodeUrl is false', () => {
|
||||
test('should preserve unencoded URL as-is (spaces kept)', async ({ pageWithUserData: page }) => {
|
||||
const { sidebar, request, modal } = buildCommonLocators(page);
|
||||
|
||||
// Create collection and request
|
||||
await createCollection(page, collectionName, await createTmpDir(collectionName));
|
||||
await createRequest(page, requestName, collectionName, { url: 'http://base.source?name=John Doe' });
|
||||
await openCollection(page, 'encoding-test');
|
||||
await sidebar.request('raw-url-unencoded').click();
|
||||
|
||||
// Click the request in the sidebar
|
||||
await page.locator('.collection-item-name').filter({ hasText: requestName }).first().click();
|
||||
await request.generateCodeButton().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.locator('#send-request .infotip').first().click();
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').locator('.bruno-modal-header-title')).toContainText('Generate Code');
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
expect(generatedCode).toContain('http://base.source?name=John Doe');
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
await modal.closeButton().click();
|
||||
await modal.closeButton().waitFor({ state: 'hidden' });
|
||||
});
|
||||
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
test('should preserve pre-encoded URL as-is', async ({ pageWithUserData: page }) => {
|
||||
const { sidebar, request, modal } = buildCommonLocators(page);
|
||||
|
||||
expect(generatedCode).toContain('http://base.source/?name=John%20Doe');
|
||||
await openCollection(page, 'encoding-test');
|
||||
await sidebar.request('raw-url-preencoded').click();
|
||||
|
||||
await page.getByTestId('modal-close-button').click();
|
||||
await request.generateCodeButton().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByTestId('modal-close-button').waitFor({ state: 'hidden' });
|
||||
});
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
test('Should generate code with proper URL encoding for encoded input', async ({
|
||||
page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
const collectionName = 'encoded-test-collection';
|
||||
const requestName = 'curl-encoding-encoded';
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
expect(generatedCode).toContain('http://base.source?name=John%20Doe');
|
||||
|
||||
// Create collection and request
|
||||
await createCollection(page, collectionName, await createTmpDir(collectionName));
|
||||
await createRequest(page, requestName, collectionName, { url: 'http://base.source?name=John%20Doe' });
|
||||
|
||||
// Click the request in the sidebar
|
||||
await page.locator('.collection-item-name').filter({ hasText: requestName }).first().click();
|
||||
|
||||
await page.locator('#send-request .infotip').first().click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').locator('.bruno-modal-header-title')).toContainText('Generate Code');
|
||||
|
||||
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
||||
await expect(codeEditor).toBeVisible();
|
||||
|
||||
const generatedCode = await codeEditor.textContent();
|
||||
|
||||
expect(generatedCode).toContain('http://base.source/?name=John%20Doe');
|
||||
|
||||
await page.getByTestId('modal-close-button').click();
|
||||
|
||||
await page.getByTestId('modal-close-button').waitFor({ state: 'hidden' });
|
||||
await modal.closeButton().click();
|
||||
await modal.closeButton().waitFor({ state: 'hidden' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/request/encoding/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
6
tests/request/encoding/init-user-data/preferences.json
Normal file
6
tests/request/encoding/init-user-data/preferences.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/request/encoding/collection"
|
||||
]
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
newRequestUrl: () => page.locator('#new-request-url .CodeMirror'),
|
||||
requestNameInput: () => page.getByPlaceholder('Request Name'),
|
||||
requestTestId: () => page.getByTestId('request-name'),
|
||||
generateCodeButton: () => page.locator('#send-request .infotip').first(),
|
||||
bodyModeSelector: () => page.getByTestId('request-body-mode-selector'),
|
||||
bodyEditor: () => page.getByTestId('request-body-editor')
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user