mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
Merge pull request #5615 from usebruno/fix/odata-style-pathparams
Support for Odata style path params
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
9
tests/interpolation/collection/bruno.json
Normal file
9
tests/interpolation/collection/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "interpolation",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
21
tests/interpolation/collection/echo-request-odata.bru
Normal file
21
tests/interpolation/collection/echo-request-odata.bru
Normal file
@@ -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
|
||||
}
|
||||
18
tests/interpolation/collection/echo-request-url.bru
Normal file
18
tests/interpolation/collection/echo-request-url.bru
Normal file
@@ -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
|
||||
}
|
||||
10
tests/interpolation/init-user-data/collection-security.json
Normal file
10
tests/interpolation/init-user-data/collection-security.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/interpolation/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
6
tests/interpolation/init-user-data/preferences.json
Normal file
6
tests/interpolation/init-user-data/preferences.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/interpolation/collection"
|
||||
]
|
||||
}
|
||||
24
tests/interpolation/interpolate-request-url.spec.ts
Normal file
24
tests/interpolation/interpolate-request-url.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user