diff --git a/eslint.config.js b/eslint.config.js index 861eeda1c..4eaaf583b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,7 @@ module.exports = runESMImports().then(() => defineConfig([ }, files: [ './eslint.config.js', + 'tests/**/*.spec.{ts,js}', 'packages/bruno-app/**/*.{js,jsx,ts}', 'packages/bruno-app/src/test-utils/mocks/codemirror.js', 'packages/bruno-cli/**/*.js', diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js index a8ac1b812..bff1ac895 100644 --- a/packages/bruno-app/src/utils/url/index.js +++ b/packages/bruno-app/src/utils/url/index.js @@ -32,19 +32,24 @@ export const parsePathParams = (url) => { paths = uri.split('/'); } - paths = paths.reduce((acc, path) => { - if (path !== '' && path[0] === ':') { - let name = path.slice(1, path.length); - if (name) { - let isExist = find(acc, (path) => path.name === name); - if (!isExist) { - acc.push({ name: path.slice(1, path.length), value: '' }); + // Enhanced: also match :param inside parentheses and/or quotes + const paramRegex = /[:](\w+)/g; + const foundParams = new Set(); + paths.forEach(segment => { + let match; + while ((match = paramRegex.exec(segment))) { + if (match[1]) { + // Clean up: remove trailing quotes/parentheses if present + let name = match[1].replace(/[')"`]+$/, ''); + // Remove leading quotes/parentheses if present + name = name.replace(/^[('"`]+/, ''); + if (name && !foundParams.has(name)) { + foundParams.add(name); } } } - return acc; - }, []); - return paths; + }); + return Array.from(foundParams).map(name => ({ name, value: '' })); }; export const splitOnFirst = (str, char) => { @@ -79,13 +84,25 @@ export const interpolateUrl = ({ url, variables }) => { export const interpolateUrlPathParams = (url, params) => { const getInterpolatedBasePath = (pathname, params) => { + const regex = /[:](\w+)/g; return pathname .split('/') .map((segment) => { - if (segment.startsWith(':')) { - const pathParamName = segment.slice(1); - const pathParam = params.find((p) => p?.name === pathParamName && p?.type === 'path'); - return pathParam ? pathParam.value : segment; + + if (!segment.startsWith(':')) return segment; + + let match; + while ((match = regex.exec(segment))) { + if (match[1]) { + // Clean up: remove trailing quotes/parentheses if present + let name = match[1].replace(/[')"`]+$/, ''); + // Remove leading quotes/parentheses if present + name = name.replace(/^[('"`]+/, ''); + if (name) { + const pathParam = params.find(p => p?.name === name && p?.type === 'path'); + return pathParam ? pathParam.value : segment; + } + } } return segment; }) diff --git a/packages/bruno-app/src/utils/url/index.spec.js b/packages/bruno-app/src/utils/url/index.spec.js index bbcc919c8..ddab88a99 100644 --- a/packages/bruno-app/src/utils/url/index.spec.js +++ b/packages/bruno-app/src/utils/url/index.spec.js @@ -43,6 +43,132 @@ describe('Url Utils - parsePathParams', () => { { name: 'postId', value: '' } ]); }); + + it('should parse path param inside parentheses and quotes', () => { + const params = parsePathParams('https://example.com/ExchangeRates(\':ExchangeRateOID\')'); + expect(params).toEqual([{ name: 'ExchangeRateOID', value: '' }]); + }); + + it('should parse path param inside parentheses and no quotes', () => { + const params = parsePathParams('https://example.com/ExchangeRates(:ExchangeRateOID)'); + expect(params).toEqual([{ name: 'ExchangeRateOID', value: '' }]); + }); + + it('should parse multiple path params inside parentheses', () => { + const params = parsePathParams('https://example.com/Exchange(:ExchangeId)/ExchangeRates(:ExchangeRateOID)'); + expect(params).toEqual([{ name: 'ExchangeId', value: '' }, { name: 'ExchangeRateOID', value: '' }]); + }); + + it('should parse mix and match of normal and param inside parentheses', () => { + const params = parsePathParams('https://example.com/Exchange(:ExchangeId)/:key'); + expect(params).toEqual([{ name: 'ExchangeId', value: '' }, { name: 'key', value: '' }]); + }); + + // OData-specific test cases for enhanced path parameter parsing + it('should parse OData entity key with single quotes', () => { + const params = parsePathParams('https://example.com/odata/Products(\':productId\')'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should parse OData entity key with double quotes', () => { + const params = parsePathParams('https://example.com/odata/Products(":productId")'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should parse OData entity key with backticks', () => { + const params = parsePathParams('https://example.com/odata/Products(`:productId`)'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should parse OData entity key with parentheses only', () => { + const params = parsePathParams('https://example.com/odata/Products(:productId)'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should parse OData composite key with multiple parameters', () => { + const params = parsePathParams('https://example.com/odata/Orders(:orderId,ProductId=\':productId\')'); + expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'productId', value: '' }]); + }); + + it('should parse OData navigation property with key', () => { + const params = parsePathParams('https://example.com/odata/Orders(:orderId)/Items(\':itemId\')'); + expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'itemId', value: '' }]); + }); + + it('should parse OData function with parameters', () => { + const params = parsePathParams('https://example.com/odata/GetProductsByCategory(categoryId=\':categoryId\')'); + expect(params).toEqual([{ name: 'categoryId', value: '' }]); + }); + + it('should parse OData action with complex parameters', () => { + const params = parsePathParams('https://example.com/odata/Products(\':productId\')/Rate(rating=:rating,comment=\':comment\')'); + expect(params).toEqual([{ name: 'productId', value: '' }, { name: 'rating', value: '' }, { name: 'comment', value: '' }]); + }); + + it('should handle OData parameters with special characters in names', () => { + const params = parsePathParams('https://example.com/odata/Products(\':product-id\')'); + expect(params).toEqual([{ name: 'product', value: '' }]); + }); + + it('should handle OData parameters with underscores in names', () => { + const params = parsePathParams('https://example.com/odata/Products(\':product_id\')'); + expect(params).toEqual([{ name: 'product_id', value: '' }]); + }); + + it('should handle OData parameters with mixed quote types', () => { + const params = parsePathParams('https://example.com/odata/Products(\':productId\')/Categories(":categoryId")'); + expect(params).toEqual([{ name: 'productId', value: '' }, { name: 'categoryId', value: '' }]); + }); + + it('should handle OData parameters with nested parentheses', () => { + const params = parsePathParams('https://example.com/odata/Products((\':productId\'))'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should handle OData parameters with complex nested structures', () => { + const params = parsePathParams('https://example.com/odata/Orders(:orderId)/Items(\':itemId\')/Properties(\':propName\')'); + expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'itemId', value: '' }, { name: 'propName', value: '' }]); + }); + + it('should handle OData parameters with query options in path', () => { + const params = parsePathParams('https://example.com/odata/Products(\':productId\')?$expand=Category'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should handle OData parameters with multiple segments and mixed syntax', () => { + const params = parsePathParams('https://example.com/odata/Orders(:orderId)/Items(\':itemId\')/Properties(:propName)'); + expect(params).toEqual([{ name: 'orderId', value: '' }, { name: 'itemId', value: '' }, { name: 'propName', value: '' }]); + }); + + it('should handle OData parameters with empty string values', () => { + const params = parsePathParams('https://example.com/odata/Products(\'\')'); + expect(params).toEqual([]); + }); + + it('should handle OData parameters with function calls in parentheses', () => { + const params = parsePathParams('https://example.com/odata/Products(GetId(\':productId\'))'); + expect(params).toEqual([{ name: 'productId', value: '' }]); + }); + + it('should handle OData parameters with escaped quotes', () => { + const params = parsePathParams('https://example.com/odata/Products(\'ABC\'\'123\')'); + expect(params).toEqual([]); + }); + + it('should handle OData parameters with spaces in quotes', () => { + const params = parsePathParams('https://example.com/odata/Products(\'Product Name With Spaces\')'); + expect(params).toEqual([]); + }); + + it('should handle OData parameters with numeric keys', () => { + const params = parsePathParams('https://example.com/odata/Products(12345)'); + expect(params).toEqual([]); + }); + + it('should handle OData parameters with GUID keys', () => { + const params = parsePathParams('https://example.com/odata/Products(\'123e4567-e89b-12d3-a456-426614174000\')'); + expect(params).toEqual([]); + }); }); describe('Url Utils - splitOnFirst', () => { diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index 7ec7041b5..83b9e0881 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -115,16 +115,21 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc throw { message: 'Invalid URL format', originalError: e.message }; } + const paramRegex = /[:](\w+)/g; const interpolatedUrlPath = url.pathname .split('/') .filter((path) => path !== '') .map((path) => { - if (path[0] !== ':') { - return '/' + path; + const matches = path.match(paramRegex); + if (matches) { + const paramName = matches[0].slice(1); // Remove the : prefix + const existingPathParam = request.pathParams.find(param => param.name === paramName); + if (!existingPathParam) { + return '/' + path; + } + return '/' + path.replace(':' + paramName, existingPathParam.value); } else { - const name = path.slice(1); - const existingPathParam = request?.pathParams?.find((param) => param.type === 'path' && param.name === name); - return existingPathParam ? '/' + existingPathParam.value : ''; + return '/' + path; } }) .join(''); diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index 0aff18f19..a3b869156 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -91,15 +91,15 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc if (typeof request.data === 'string') { if (request.data.length) { request.data = _interpolate(request.data, { - escapeJSONStrings: true - }); + escapeJSONStrings: true, + }); } } else if (typeof request.data === 'object') { try { const jsonDoc = JSON.stringify(request.data); const parsed = _interpolate(jsonDoc, { - escapeJSONStrings: true - }); + escapeJSONStrings: true, + }); request.data = JSON.parse(parsed); } catch (err) {} } @@ -142,16 +142,21 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc throw { message: 'Invalid URL format', originalError: e.message }; } + const paramRegex = /[:](\w+)/g; const urlPathnameInterpolatedWithPathParams = url.pathname .split('/') .filter((path) => path !== '') .map((path) => { - if (path[0] !== ':') { - return '/' + path; + const matches = path.match(paramRegex); + if (matches) { + const paramName = matches[0].slice(1); // Remove the : prefix + const existingPathParam = request.pathParams.find(param => param.name === paramName); + if (!existingPathParam) { + return '/' + path; + } + return '/' + path.replace(':' + paramName, existingPathParam.value); } else { - const name = path.slice(1); - const existingPathParam = request.pathParams.find((param) => param.type === 'path' && param.name === name); - return existingPathParam ? '/' + existingPathParam.value : ''; + return '/' + path; } }) .join(''); diff --git a/packages/bruno-electron/tests/network/interpolate-vars.spec.js b/packages/bruno-electron/tests/network/interpolate-vars.spec.js index 88abdd811..0c1e4f178 100644 --- a/packages/bruno-electron/tests/network/interpolate-vars.spec.js +++ b/packages/bruno-electron/tests/network/interpolate-vars.spec.js @@ -154,6 +154,33 @@ describe('interpolate-vars: interpolateVars', () => { const result = interpolateVars(request, null, null, null); expect(result.url).toBe('http://example.com/foobar'); }); + + it('updates the path with odata style params | smoke', async () => { + const request = { + method: 'GET', + url: 'http://example.com/Category(\':CategoryID\')/Item(:ItemId)/:xpath/Tags("tag test")', + pathParams: [ + { + type: 'path', + name: 'CategoryID', + value: 'foobar', + }, + { + type: 'path', + name: 'ItemId', + value: 1, + }, + { + type: 'path', + name: 'xpath', + value: 'foobar', + }, + ], + }; + + const result = interpolateVars(request, null, null, null); + expect(result.url).toBe('http://example.com/Category(\'foobar\')/Item(1)/foobar/Tags(%22tag%20test%22)'); + }); }); describe('With process environment variables', () => { diff --git a/packages/bruno-tests/src/echo/index.js b/packages/bruno-tests/src/echo/index.js index 00b50bd36..a9425b305 100644 --- a/packages/bruno-tests/src/echo/index.js +++ b/packages/bruno-tests/src/echo/index.js @@ -1,6 +1,10 @@ const express = require('express'); const router = express.Router(); +router.get('/path/*', (req, res) => { + return res.json({ url: req.url }); +}); + router.post('/json', (req, res) => { return res.json(req.body); }); diff --git a/tests/interpolation/collection/bruno.json b/tests/interpolation/collection/bruno.json new file mode 100644 index 000000000..f29e04549 --- /dev/null +++ b/tests/interpolation/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "interpolation", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/tests/interpolation/collection/echo-request-odata.bru b/tests/interpolation/collection/echo-request-odata.bru new file mode 100644 index 000000000..f3cb79320 --- /dev/null +++ b/tests/interpolation/collection/echo-request-odata.bru @@ -0,0 +1,21 @@ +meta { + name: echo-request-odata + type: http + seq: 2 +} + +get { + url: http://localhost:8081/api/echo/path/Category(':CategoryID')/Item(:ItemId)/:xpath/Tags("tag test") + body: none + auth: inherit +} + +params:path { + CategoryID: category123 + ItemId: item456 + xpath: foobar +} + +settings { + encodeUrl: true +} diff --git a/tests/interpolation/collection/echo-request-url.bru b/tests/interpolation/collection/echo-request-url.bru new file mode 100644 index 000000000..3b3b00754 --- /dev/null +++ b/tests/interpolation/collection/echo-request-url.bru @@ -0,0 +1,18 @@ +meta { + name: echo-request-url + type: http + seq: 1 +} + +get { + url: http://localhost:8081/api/echo/path/:path + auth: inherit +} + +params:path { + path: some-data +} + +settings { + encodeUrl: true +} diff --git a/tests/interpolation/init-user-data/collection-security.json b/tests/interpolation/init-user-data/collection-security.json new file mode 100644 index 000000000..9369c1b91 --- /dev/null +++ b/tests/interpolation/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/interpolation/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} \ No newline at end of file diff --git a/tests/interpolation/init-user-data/preferences.json b/tests/interpolation/init-user-data/preferences.json new file mode 100644 index 000000000..38f64eadb --- /dev/null +++ b/tests/interpolation/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": true, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/interpolation/collection" + ] +} \ No newline at end of file diff --git a/tests/interpolation/interpolate-request-url.spec.ts b/tests/interpolation/interpolate-request-url.spec.ts new file mode 100644 index 000000000..061558a5c --- /dev/null +++ b/tests/interpolation/interpolate-request-url.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '../../playwright'; + +test.describe.serial('URL Interpolation', () => { + test('Interpolate basic path params', async ({ pageWithUserData: page }) => { + await page.locator('#sidebar-collection-name').click(); + await page.getByRole('complementary').getByText('echo-request-url').click(); + await page.getByTestId('send-arrow-icon').click(); + + expect(page.getByTestId('response-status-code')).toHaveText(/200/); + + const texts = await page.locator('div:nth-child(2) > .CodeMirror-scroll').allInnerTexts(); + expect(texts.some(d => d.includes(`"url": "/path/some-data"`))).toBe(true); + }); + + test('Interpolate oData path params', async ({ pageWithUserData: page }) => { + await page.getByRole('complementary').getByText('echo-request-odata').click(); + await page.getByTestId('send-arrow-icon').click(); + + expect(page.getByTestId('response-status-code')).toHaveText(/200/); + + const texts = await page.locator('div:nth-child(2) > .CodeMirror-scroll').allInnerTexts(); + expect(texts.some(d => d.includes(`"url": "/path/Category('category123')/Item(item456)/foobar/Tags(%22tag%20test%22)"`))).toBe(true); + }); +});