Feat/improved path params (#2357)

* feat: path parameters (#484)

* add path parameters on bruno-app

* add path parameters on bruno-cli

* fix bruno-schema testing

* fix generate request code not replace path parameter value

---------

Co-authored-by: game5413 <febryanph10@gmail.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>

* feat: Refactor request parameter handling

- Update prepare-request.js to filter and rename 'paths' to 'params' with type 'path'
- Remove 'paths' from export.js and interpolate-vars.js
- Update bru.js to use 'params' instead of 'path'
- Update requestSchema in index.js to use 'keyValueWithTypeSchema' for 'params'

Co-authored-by: game5413 <febryanph10@gmail.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>

* feat: Refactor request parameter handling

* refactor: changes form the review

* refactor: Refactor transformItemsInCollection handling

* refactor: Refactor improved export/import functionalities

* refactor: Remove console.log statement in bruToJson.js

---------

Co-authored-by: game5413 <37659721+game5413@users.noreply.github.com>
Co-authored-by: game5413 <febryanph10@gmail.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
This commit is contained in:
Sanjai Kumar 2024-05-30 15:49:14 +05:30 committed by GitHub
parent 77b1e6d738
commit abfd14a306
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 691 additions and 163 deletions

View File

@ -33,7 +33,7 @@ Bruno jest rozwijane jako aplikacja desktopowa. Musisz załadować aplikację, u
### Lokalny Rozwój
```bash
````bash
# użyj wersji nodejs 18
nvm use
@ -66,7 +66,7 @@ done
# Usuń package-lock w podkatalogach
find . -type f -name "package-lock.json" -delete
```
````
### Testowanie

View File

@ -1,5 +1,4 @@
import React from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
@ -14,7 +13,7 @@ import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { get } from 'lodash';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
@ -81,6 +80,8 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
});
};
const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
// get the length of active params, headers, asserts and vars
const params = item.draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []);
const headers = item.draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []);
@ -99,7 +100,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
Query
Params
{activeParamsLength > 0 && <sup className="ml-1 font-medium">{activeParamsLength}</sup>}
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
@ -136,9 +137,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
) : null}
</div>
<section
className={`flex w-full ${
['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'
}`}
className={classnames('flex w-full', {
'mt-5': !isMultipleContentTab
})}
>
{getTabPanel(focusedTab.requestPaneTab)}
</section>

View File

@ -1,6 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
div.title {
color: var(--color-tab-inactive);
}
table {
width: 100%;
border-collapse: collapse;

View File

@ -1,12 +1,18 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import has from 'lodash/has';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addQueryParam, updateQueryParam, deleteQueryParam } from 'providers/ReduxStore/slices/collections';
import {
addQueryParam,
deleteQueryParam,
updatePathParam,
updateQueryParam
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@ -14,8 +20,10 @@ const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const handleAddParam = () => {
const handleAddQueryParam = () => {
dispatch(
addQueryParam({
itemUid: item.uid,
@ -26,24 +34,39 @@ const QueryParams = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
const handleValueChange = (data, type, value) => {
const _data = cloneDeep(data);
if (!has(_data, type)) {
return;
}
_data[type] = value;
return _data;
};
const handleQueryParamChange = (e, data, type) => {
let value;
switch (type) {
case 'name': {
param.name = e.target.value;
value = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
value = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
value = e.target.checked;
break;
}
}
const param = handleValueChange(data, type, value);
dispatch(
updateQueryParam({
param,
@ -53,7 +76,21 @@ const QueryParams = ({ item, collection }) => {
);
};
const handleRemoveParam = (param) => {
const handlePathParamChange = (e, data) => {
let value = e.target.value;
const path = handleValueChange(data, 'value', value);
dispatch(
updatePathParam({
path,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveQueryParam = (param) => {
dispatch(
deleteQueryParam({
paramUid: param.uid,
@ -64,7 +101,9 @@ const QueryParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<table>
<thead>
<tr>
@ -74,8 +113,8 @@ const QueryParams = ({ item, collection }) => {
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
{queryParams && queryParams.length
? queryParams.map((param, index) => {
return (
<tr key={param.uid}>
<td>
@ -87,7 +126,7 @@ const QueryParams = ({ item, collection }) => {
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
onChange={(e) => handleQueryParamChange(e, param, 'name')}
/>
</td>
<td>
@ -96,7 +135,7 @@ const QueryParams = ({ item, collection }) => {
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleParamChange(
handleQueryParamChange(
{
target: {
value: newValue
@ -117,9 +156,9 @@ const QueryParams = ({ item, collection }) => {
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}>
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
@ -130,9 +169,60 @@ const QueryParams = ({ item, collection }) => {
: null}
</tbody>
</table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddParam}>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<div className="mb-1 title text-xs">Path</div>
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{pathParams && pathParams.length
? pathParams.map((path, index) => {
return (
<tr key={path.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={path.name}
className="mousetrap"
readOnly={true}
/>
</td>
<td>
<SingleLineEditor
value={path.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handlePathParamChange(
{
target: {
value: newValue
}
},
path
)
}
onRun={handleRun}
collection={collection}
/>
</td>
</tr>
);
})
: null}
</tbody>
</table>
</div>
</StyledWrapper>
);
};

View File

@ -2,8 +2,8 @@ import Modal from 'components/Modal/index';
import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url/index';
import get from 'lodash/get';
import { isValidUrl } from 'utils/url';
import { find, get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
// Todo: Fix this
@ -27,6 +27,44 @@ const interpolateUrl = ({ url, envVars, collectionVariables, processEnvVars }) =
});
};
const joinPathUrl = (url, params) => {
const processPaths = (uri, paths) => {
return uri
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
const paramName = segment.slice(1);
const param = paths.find((p) => p.name === paramName && p.type === 'path' && p.enabled);
return param ? param.value : segment;
}
return segment;
})
.join('/');
};
const processQueryParams = (search, params) => {
const queryParams = new URLSearchParams(search);
params
.filter((p) => p.type === 'query' && p.enabled)
.forEach((param) => {
queryParams.set(param.name, param.value);
});
return queryParams.toString();
};
let uri;
try {
uri = new URL(url);
} catch (error) {
uri = new URL(`http://${url}`);
}
const basePath = processPaths(uri.pathname, params);
const queryString = processQueryParams(uri.search, params);
return `${uri.origin}${basePath}${queryString ? `?${queryString}` : ''}`;
};
const languages = [
{
name: 'HTTP',
@ -76,7 +114,10 @@ const languages = [
];
const GenerateCodeItem = ({ collection, item, onClose }) => {
const url = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
const url = joinPathUrl(
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url'),
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let envVars = {};
if (environment) {

View File

@ -1,13 +1,6 @@
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, debounce } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep';
import concat from 'lodash/concat';
import each from 'lodash/each';
import filter from 'lodash/filter';
import find from 'lodash/find';
import forOwn from 'lodash/forOwn';
import get from 'lodash/get';
import map from 'lodash/map';
import set from 'lodash/set';
import {
addDepth,
areItemsTheSameExceptSeqUpdate,
@ -21,9 +14,9 @@ import {
findItemInCollectionByPathname,
isItemARequest
} from 'utils/collections';
import { uuid } from 'utils/common';
import { PATH_SEPARATOR, getDirectoryName, getSubdirectoriesFromRoot } from 'utils/common/platform';
import { parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
import toast from 'react-hot-toast';
const initialState = {
collections: [],
@ -294,10 +287,35 @@ export const collectionsSlice = createSlice({
if (collection && collection.items && collection.items.length) {
const parts = splitOnFirst(action.payload.requestUrl, '?');
const params = parseQueryParams(parts[1]);
each(params, (urlParam) => {
urlParam.enabled = true;
});
const queryParams = parseQueryParams(parts[1]);
let pathParams = [];
try {
pathParams = parsePathParams(parts[0]);
} catch (err) {
console.error(err);
toast.error(err.message);
}
const queryParamObjects = queryParams.map((param) => ({
uid: uuid(),
name: param.key,
value: param.value,
description: '',
type: 'query',
enabled: true
}));
const pathParamObjects = pathParams.map((param) => ({
uid: uuid(),
name: param.key,
value: param.value,
description: '',
type: 'path',
enabled: true
}));
const params = [...queryParamObjects, ...pathParamObjects];
const item = {
uid: action.payload.uid,
@ -351,14 +369,26 @@ export const collectionsSlice = createSlice({
const parts = splitOnFirst(item.draft.request.url, '?');
const urlParams = parseQueryParams(parts[1]);
let urlPaths = [];
try {
urlPaths = parsePathParams(parts[0]);
} catch (err) {
console.error(err);
toast.error(err.message);
}
const disabledParams = filter(item.draft.request.params, (p) => !p.enabled);
let enabledParams = filter(item.draft.request.params, (p) => p.enabled);
let enabledParams = filter(item.draft.request.params, (p) => p.enabled && p.type === 'query');
let oldPaths = filter(item.draft.request.params, (p) => p.enabled && p.type === 'path');
let newPaths = [];
// try and connect as much as old params uid's as possible
each(urlParams, (urlParam) => {
const existingParam = find(enabledParams, (p) => p.name === urlParam.name || p.value === urlParam.value);
urlParam.uid = existingParam ? existingParam.uid : uuid();
urlParam.enabled = true;
urlParam.type = 'query';
// once found, remove it - trying our best here to accommodate duplicate query params
if (existingParam) {
@ -366,10 +396,27 @@ export const collectionsSlice = createSlice({
}
});
// filter the newest path param and compare with previous data that already inserted
newPaths = filter(urlPaths, (urlPath) => {
const existingPath = find(oldPaths, (p) => p.name === urlPath.name);
if (existingPath) {
return false;
}
urlPath.uid = uuid();
urlPath.enabled = true;
urlPath.type = 'path';
return true;
});
// remove path param that not used or deleted when typing url
oldPaths = filter(oldPaths, (urlPath) => {
return find(urlPaths, (p) => p.name === urlPath.name);
});
// ultimately params get replaced with params in url + the disabled ones that existed prior
// the query params are the source of truth, the url in the queryurl input gets constructed using these params
// we however are also storing the full url (with params) in the url itself
item.draft.request.params = concat(urlParams, disabledParams);
item.draft.request.params = concat(urlParams, newPaths, disabledParams, oldPaths);
}
}
},
@ -426,6 +473,7 @@ export const collectionsSlice = createSlice({
name: '',
value: '',
description: '',
type: 'query',
enabled: true
});
}
@ -441,16 +489,20 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
const param = find(item.draft.request.params, (h) => h.uid === action.payload.param.uid);
if (param) {
param.name = action.payload.param.name;
param.value = action.payload.param.value;
param.description = action.payload.param.description;
param.enabled = action.payload.param.enabled;
const queryParam = find(
item.draft.request.params,
(h) => h.uid === action.payload.param.uid && h.type === 'query'
);
if (queryParam) {
queryParam.name = action.payload.param.name;
queryParam.value = action.payload.param.value;
queryParam.enabled = action.payload.param.enabled;
// update request url
const parts = splitOnFirst(item.draft.request.url, '?');
const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled));
const query = stringifyQueryParams(
filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')
);
// if no query is found, then strip the query params in url
if (!query || !query.length) {
@ -486,7 +538,7 @@ export const collectionsSlice = createSlice({
// update request url
const parts = splitOnFirst(item.draft.request.url, '?');
const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled));
const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query'));
if (query && query.length) {
item.draft.request.url = parts[0] + '?' + query;
} else {
@ -495,6 +547,26 @@ export const collectionsSlice = createSlice({
}
}
},
updatePathParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
const param = find(item.draft.request.params, (p) => p.uid === action.payload.path.uid && p.type === 'path');
if (param) {
param.name = action.payload.path.name;
param.value = action.payload.path.value;
}
}
}
},
addRequestHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -1418,6 +1490,7 @@ export const {
addQueryParam,
updateQueryParam,
deleteQueryParam,
updatePathParam,
addRequestHeader,
updateRequestHeader,
deleteRequestHeader,

View File

@ -24,7 +24,7 @@ const createHeaders = (headers) => {
const createQuery = (queryParams = []) => {
return queryParams
.filter((param) => param.enabled)
.filter((param) => param.enabled && param.type === 'query')
.map((param) => ({
name: param.name,
value: param.value

View File

@ -29,9 +29,6 @@ export const deleteUidsInItems = (items) => {
export const transformItem = (items = []) => {
each(items, (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
item.request.query = item.request.params;
delete item.request.params;
if (item.type === 'graphql-request') {
item.type = 'graphql';
}

View File

@ -228,13 +228,14 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
const copyQueryParams = (params) => {
const copyParams = (params) => {
return map(params, (param) => {
return {
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
type: param.type,
enabled: param.enabled
};
});
@ -283,7 +284,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
url: si.request.url,
method: si.request.method,
headers: copyHeaders(si.request.headers),
params: copyQueryParams(si.request.params),
params: copyParams(si.request.params),
body: {
mode: si.request.body.mode,
json: si.request.body.json,
@ -441,6 +442,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: param.name,
value: param.value,
description: param.description,
type: param.type,
enabled: param.enabled
});
});

View File

@ -171,11 +171,43 @@ export const exportCollection = (collection) => {
}
};
const generateHost = (url) => {
try {
const { hostname } = new URL(url);
return hostname.split('.');
} catch (error) {
console.error(`Invalid URL: ${url}`, error);
return [];
}
};
const generatePathParams = (params) => {
return params.filter((param) => param.type === 'path').map((param) => `:${param.name}`);
};
const generateQueryParams = (params) => {
return params
.filter((param) => param.type === 'query')
.map(({ name, value, description }) => ({ key: name, value, description }));
};
const generateVariables = (params) => {
return params
.filter((param) => param.type === 'path')
.map(({ name, value, description }) => ({ key: name, value, description }));
};
const generateRequestSection = (itemRequest) => {
const requestObject = {
method: itemRequest.method,
header: generateHeaders(itemRequest.headers),
url: itemRequest.url,
url: {
raw: itemRequest.url,
host: generateHost(itemRequest.url),
path: generatePathParams(itemRequest.params),
query: generateQueryParams(itemRequest.params),
variable: generateVariables(itemRequest.params)
},
auth: generateAuth(itemRequest.auth)
};

View File

@ -29,7 +29,6 @@ export const updateUidsInCollection = (_collection) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.query'), (param) => (param.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
@ -66,8 +65,13 @@ export const transformItemsInCollection = (collection) => {
if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`;
if (item.request.query) {
item.request.params = item.request.query;
item.request.params = item.request.query.map((queryItem) => ({
...queryItem,
type: 'query',
uid: queryItem.uid || uuid()
}));
}
delete item.request.query;

View File

@ -112,10 +112,22 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
name: param.name,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(request.pathParameters, (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: param.value,
description: '',
type: 'path',
enabled: true
});
});
const authType = get(request, 'authentication.type', '');
if (authType === 'basic') {

View File

@ -92,7 +92,17 @@ const transformOpenapiRequestItem = (request) => {
name: param.name,
value: '',
description: param.description || '',
enabled: param.required
enabled: param.required,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: '',
description: param.description || '',
enabled: param.required,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({

View File

@ -275,10 +275,22 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
name: param.key,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable'), (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
type: 'path',
enabled: true
});
});
brunoParent.items.push(brunoRequestItem);
}
}

View File

@ -2,6 +2,7 @@ import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim';
import each from 'lodash/each';
import filter from 'lodash/filter';
import find from 'lodash/find';
const hasLength = (str) => {
if (!str || !str.length) {
@ -26,6 +27,41 @@ export const parseQueryParams = (query) => {
return filter(params, (p) => hasLength(p.name));
};
export const parsePathParams = (url) => {
let uri = url.slice();
if (!uri || !uri.length) {
return [];
}
if (!uri.startsWith('http://') && !uri.startsWith('https://')) {
uri = `http://${uri}`;
}
try {
uri = new URL(uri);
} catch (e) {
throw e;
}
let paths = uri.pathname.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: '' });
}
}
}
return acc;
}, []);
return paths;
};
export const stringifyQueryParams = (params) => {
if (!params || isEmpty(params)) {
return '';

View File

@ -1,4 +1,4 @@
import { parseQueryParams, splitOnFirst } from './index';
import { parseQueryParams, splitOnFirst, parsePathParams } from './index';
describe('Url Utils - parseQueryParams', () => {
it('should parse query - case 1', () => {
@ -51,6 +51,51 @@ describe('Url Utils - parseQueryParams', () => {
});
});
describe('Url Utils - parsePathParams', () => {
it('should parse path - case 1', () => {
const params = parsePathParams('www.example.com');
expect(params).toEqual([]);
});
it('should parse path - case 2', () => {
const params = parsePathParams('http://www.example.com');
expect(params).toEqual([]);
});
it('should parse path - case 3', () => {
const params = parsePathParams('https://www.example.com');
expect(params).toEqual([]);
});
it('should parse path - case 4', () => {
const params = parsePathParams('https://www.example.com/users/:id');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 5', () => {
const params = parsePathParams('https://www.example.com/users/:id/');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 6', () => {
const params = parsePathParams('https://www.example.com/users/:id/:');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 7', () => {
const params = parsePathParams('https://www.example.com/users/:id/posts/:id');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 8', () => {
const params = parsePathParams('https://www.example.com/users/:id/posts/:postId');
expect(params).toEqual([
{ name: 'id', value: '' },
{ name: 'postId', value: '' }
]);
});
});
describe('Url Utils - splitOnFirst', () => {
it('should split on first - case 1', () => {
const params = splitOnFirst('a', '=');

View File

@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash');
const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => {
let contentType = '';
@ -86,6 +86,36 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
param.value = _interpolate(param.value);
});
if (request.params.length) {
let url = request.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
}
try {
url = new URL(url);
} catch (e) {
throw { message: 'Invalid URL format', originalError: e.message };
}
const urlPaths = url.pathname
.split('/')
.filter((path) => path !== '')
.map((path) => {
if (path[0] !== ':') {
return '/' + path;
} else {
const name = path.slice(1);
const existingPathParam = request.params.find((param) => param.type === 'path' && param.name === name);
return existingPathParam ? '/' + existingPathParam.value : '';
}
})
.join('');
request.url = url.origin + urlPaths + url.search;
}
if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname);

View File

@ -29,7 +29,8 @@ const prepareRequest = (request, collectionRoot) => {
let axiosRequest = {
method: request.method,
url: request.url,
headers: headers
headers: headers,
paths: request.paths
};
const collectionAuth = get(collectionRoot, 'request.auth');

View File

@ -13,7 +13,7 @@ const collectionBruToJson = (bru) => {
const transformedJson = {
request: {
params: _.get(json, 'query', []),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
@ -60,7 +60,7 @@ const bruToJson = (bru) => {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
auth: _.get(json, 'auth', {}),
params: _.get(json, 'query', []),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
body: _.get(json, 'body', {}),
vars: _.get(json, 'vars', []),

View File

@ -14,7 +14,7 @@ const collectionBruToJson = (bru) => {
const transformedJson = {
request: {
params: _.get(json, 'query', []),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
@ -33,7 +33,7 @@ const collectionBruToJson = (bru) => {
const jsonToCollectionBru = (json) => {
try {
const collectionBruJson = {
query: _.get(json, 'request.params', []),
params: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
script: {
@ -111,7 +111,7 @@ const bruToJson = (bru) => {
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
params: _.get(json, 'query', []),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
@ -162,7 +162,7 @@ const jsonToBru = (json) => {
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
params: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),

View File

@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash');
const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => {
let contentType = '';
@ -86,6 +86,36 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
param.value = _interpolate(param.value);
});
if (request.params.length) {
let url = request.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
}
try {
url = new URL(url);
} catch (e) {
throw { message: 'Invalid URL format', originalError: e.message };
}
const urlPaths = url.pathname
.split('/')
.filter((path) => path !== '')
.map((path) => {
if (path[0] !== ':') {
return '/' + path;
} else {
const name = path.slice(1);
const existingPathParam = request.params.find((param) => param.type === 'path' && param.name === name);
return existingPathParam ? '/' + existingPathParam.value : '';
}
})
.join('');
request.url = url.origin + urlPaths + url.search;
}
if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname);

View File

@ -161,6 +161,7 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
method: request.method,
url,
headers,
params: request.params.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};

View File

@ -22,10 +22,11 @@ const { outdentString } = require('../../v1/src/utils');
*
*/
const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
params = paramspath | paramsquery
nl = "\\r"? "\\n"
st = " " | "\\t"
@ -74,6 +75,8 @@ const grammar = ohm.grammar(`Bru {
headers = "headers" dictionary
query = "query" dictionary
paramspath = "params:path" dictionary
paramsquery = "params:query" dictionary
varsandassert = varsreq | varsres | assert
varsreq = "vars:pre-request" dictionary
@ -133,6 +136,28 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
});
};
const mapPairListToKeyValPairsWithType = (pairList = [], type) => {
if (!pairList.length) {
return [];
}
return _.map(pairList[0], (pair) => {
let name = _.keys(pair)[0];
let value = pair[name];
let enabled = true;
if (name && name.length && name.charAt(0) === '~') {
name = name.slice(1);
enabled = false;
}
return {
name,
value,
enabled,
type
};
});
};
const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
@ -321,7 +346,17 @@ const sem = grammar.createSemantics().addAttribute('ast', {
},
query(_1, dictionary) {
return {
query: mapPairListToKeyValPairs(dictionary.ast)
params: mapPairListToKeyValPairsWithType(dictionary.ast, 'query')
};
},
paramspath(_1, dictionary) {
return {
params: mapPairListToKeyValPairsWithType(dictionary.ast, 'path')
};
},
paramsquery(_1, dictionary) {
return {
params: mapPairListToKeyValPairsWithType(dictionary.ast, 'query')
};
},
headers(_1, dictionary) {

View File

@ -30,7 +30,7 @@ const getValueString = (value) => {
};
const jsonToBru = (json) => {
const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json;
const { meta, http, params, headers, auth, body, script, tests, vars, assertions, docs } = json;
let bru = '';
@ -62,19 +62,23 @@ const jsonToBru = (json) => {
`;
}
if (query && query.length) {
bru += 'query {';
if (enabled(query).length) {
if (params && params.length) {
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
if (queryParams.length) {
bru += 'params:query {';
if (enabled(queryParams).length) {
bru += `\n${indentString(
enabled(query)
enabled(queryParams)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
}
if (disabled(query).length) {
if (disabled(queryParams).length) {
bru += `\n${indentString(
disabled(query)
disabled(queryParams)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
@ -83,6 +87,15 @@ const jsonToBru = (json) => {
bru += '\n}\n\n';
}
if (pathParams.length) {
bru += 'params:path {';
bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += '\n}\n\n';
}
}
if (headers && headers.length) {
bru += 'headers {';
if (enabled(headers).length) {

View File

@ -185,6 +185,17 @@ const authSchema = Yup.object({
.noUnknown(true)
.strict();
const keyValueWithTypeSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
type: Yup.string().oneOf(['query', 'path']).required('type is required'),
enabled: Yup.boolean()
})
.noUnknown(true)
.strict();
// Right now, the request schema is very tightly coupled with http request
// As we introduce more request types in the future, we will improve the definition to support
// schema structure based on other request type
@ -192,7 +203,7 @@ const requestSchema = Yup.object({
url: requestUrlSchema,
method: requestMethodSchema,
headers: Yup.array().of(keyValueSchema).required('headers are required'),
params: Yup.array().of(keyValueSchema).required('params are required'),
params: Yup.array().of(keyValueWithTypeSchema).required('params are required'),
auth: authSchema,
body: requestBodySchema,
script: Yup.object({

View File

@ -59,6 +59,7 @@ describe('Collection Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}
@ -116,6 +117,7 @@ describe('Collection Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}

View File

@ -9,6 +9,7 @@ describe('Request Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}
@ -24,6 +25,7 @@ describe('Request Schema Validation', () => {
method: 'GET-junk',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}

View File

@ -43,3 +43,48 @@ describe('bruno toml', () => {
});
});
});
describe('joinPathUrl', () => {
it('should join path and query params correctly', () => {
const url = 'https://example.com/api/:id';
const params = [
{ name: 'id', type: 'path', enabled: true, value: '123' },
{ name: 'sort', type: 'query', enabled: true, value: 'asc' },
{ name: 'filter', type: 'query', enabled: true, value: 'active' }
];
const expectedUrl = 'https://example.com/api/123?sort=asc&filter=active';
const result = joinPathUrl(url, params);
expect(result).toEqual(expectedUrl);
});
it('should handle empty path and query params', () => {
const url = 'https://example.com/api';
const params = [];
const expectedUrl = 'https://example.com/api';
const result = joinPathUrl(url, params);
expect(result).toEqual(expectedUrl);
});
it('should handle empty query params', () => {
const url = 'https://example.com/api/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
const expectedUrl = 'https://example.com/api/123';
const result = joinPathUrl(url, params);
expect(result).toEqual(expectedUrl);
});
it('should handle invalid URL', () => {
const url = 'example.com/api/:id';
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
const expectedUrl = 'http://example.com/api/123';
const result = joinPathUrl(url, params);
expect(result).toEqual(expectedUrl);
});
});