diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js index f3cc73f48..12df747b1 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js @@ -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" /> diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index c9943f9df..5ca4a8697 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -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 diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js index 06e02a921..a8a64b513 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -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 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); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js index 6101adab3..f6d492213 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -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}'`); + }); +}); diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js index 46076a5eb..8fbf2ff49 100644 --- a/packages/bruno-app/src/utils/url/index.js +++ b/packages/bruno-app/src/utils/url/index.js @@ -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) { diff --git a/packages/bruno-app/src/utils/url/index.spec.js b/packages/bruno-app/src/utils/url/index.spec.js index dfd18c96c..92fecb523 100644 --- a/packages/bruno-app/src/utils/url/index.spec.js +++ b/packages/bruno-app/src/utils/url/index.spec.js @@ -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'); + }); +}); diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index 3a62c2a08..af51d5274 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -1,7 +1,8 @@ export { encodeUrl, parseQueryParams, - buildQueryString + buildQueryString, + stripOrigin } from './url'; export { diff --git a/packages/bruno-common/src/utils/url/index.ts b/packages/bruno-common/src/utils/url/index.ts index 5adf13201..58dabb018 100644 --- a/packages/bruno-common/src/utils/url/index.ts +++ b/packages/bruno-common/src/utils/url/index.ts @@ -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 diff --git a/tests/request/encoding/collection/bruno.json b/tests/request/encoding/collection/bruno.json new file mode 100644 index 000000000..6c67bddb9 --- /dev/null +++ b/tests/request/encoding/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "encoding-test", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/tests/request/encoding/collection/encode-url-preencoded.bru b/tests/request/encoding/collection/encode-url-preencoded.bru new file mode 100644 index 000000000..716f52684 --- /dev/null +++ b/tests/request/encoding/collection/encode-url-preencoded.bru @@ -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 +} \ No newline at end of file diff --git a/tests/request/encoding/collection/encode-url-unencoded.bru b/tests/request/encoding/collection/encode-url-unencoded.bru new file mode 100644 index 000000000..7e064ff23 --- /dev/null +++ b/tests/request/encoding/collection/encode-url-unencoded.bru @@ -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 +} \ No newline at end of file diff --git a/tests/request/encoding/collection/raw-url-preencoded.bru b/tests/request/encoding/collection/raw-url-preencoded.bru new file mode 100644 index 000000000..98da3c7d2 --- /dev/null +++ b/tests/request/encoding/collection/raw-url-preencoded.bru @@ -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 +} \ No newline at end of file diff --git a/tests/request/encoding/collection/raw-url-unencoded.bru b/tests/request/encoding/collection/raw-url-unencoded.bru new file mode 100644 index 000000000..3a2a5c43e --- /dev/null +++ b/tests/request/encoding/collection/raw-url-unencoded.bru @@ -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 +} \ No newline at end of file diff --git a/tests/request/encoding/curl-encoding.spec.ts b/tests/request/encoding/curl-encoding.spec.ts index 5530595ee..c1c7ac2ac 100644 --- a/tests/request/encoding/curl-encoding.spec.ts +++ b/tests/request/encoding/curl-encoding.spec.ts @@ -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' }); + }); }); }); diff --git a/tests/request/encoding/init-user-data/collection-security.json b/tests/request/encoding/init-user-data/collection-security.json new file mode 100644 index 000000000..93f850894 --- /dev/null +++ b/tests/request/encoding/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/request/encoding/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} \ No newline at end of file diff --git a/tests/request/encoding/init-user-data/preferences.json b/tests/request/encoding/init-user-data/preferences.json new file mode 100644 index 000000000..e84af8037 --- /dev/null +++ b/tests/request/encoding/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/request/encoding/collection" + ] +} \ No newline at end of file diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 239f2ff49..c7d6fa95b 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -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') },