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

@ -1,5 +1,5 @@
**English** | [Українська](docs/contributing/contributing_ua.md) | [Русский](docs/contributing/contributing_ru.md) | [Türkçe](docs/contributing/contributing_tr.md) | [Deutsch](docs/contributing/contributing_de.md) | [Français](docs/contributing/contributing_fr.md) | [Português (BR)](docs/contributing/contributing_pt_br.md) | [বাংলা](docs/contributing/contributing_bn.md) | [Español](docs/contributing/contributing_es.md) | [Română](docs/contributing/contributing_ro.md) | [Polski](docs/contributing/contributing_pl.md)
| [简体中文](docs/contributing/contributing_cn.md) | [正體中文](docs/contributing/contributing_zhtw.md) | [日本語](docs/contributing/contributing_ja.md) | [हिंदी](docs/contributing/contributing_hi.md)
| [简体中文](docs/contributing/contributing_cn.md) | [正體中文](docs/contributing/contributing_zhtw.md) | [日本語](docs/contributing/contributing_ja.md) | [हिंदी](docs/contributing/contributing_hi.md)
## Let's make Bruno better, together !!

View File

@ -1,13 +1,13 @@
[English](../../contributing.md) | [Українська](docs/contributing/contributing_ua.md) | [Русский](docs/contributing/contributing_ru.md) | [Türkçe](docs/contributing/contributing_tr.md) | [Deutsch](docs/contributing/contributing_de.md) | [Français](docs/contributing/contributing_fr.md) | [Português (BR)](docs/contributing/contributing_pt_br.md) | [বাংলা](docs/contributing/contributing_bn.md) | [Español](docs/contributing/contributing_es.md) | [Română](docs/contributing/contributing_ro.md) | [Polski](docs/contributing/contributing_pl.md)
| [简体中文](docs/contributing/contributing_cn.md) | [正體中文](docs/contributing/contributing_zhtw.md) | **日本語**
## 一緒にBrunoをよりよいものにしていきましょう
## 一緒に Bruno をよりよいものにしていきましょう!!
Brunoを改善していただけるのは歓迎です。以下はあなたの環境でBrunoを起動するためのガイドラインです。
Bruno を改善していただけるのは歓迎です。以下はあなたの環境で Bruno を起動するためのガイドラインです。
### 技術スタック
BrunoはNext.jsとReactで作られています。デスクトップアプリ(ローカルのコレクションに対応しています)にはelectronも使用しています。
Bruno Next.js React で作られています。デスクトップアプリ(ローカルのコレクションに対応しています)には electron も使用しています。
使用ライブラリ
@ -22,11 +22,11 @@ BrunoはNext.jsとReactで作られています。デスクトップアプリ(
### 依存関係
[Node v18.x もしくは最新のLTSバージョン](https://nodejs.org/en/)とnpm 8.xが必要です。プロジェクトにnpmワークスペースを使用しています。
[Node v18.x もしくは最新の LTS バージョン](https://nodejs.org/en/)と npm 8.x が必要です。プロジェクトに npm ワークスペースを使用しています。
## 開発
Brunoはデスクトップアプリとして開発されています。一つのターミナルでNext.jsアプリを立ち上げ、もう一つのターミナルでelectronアプリを立ち上げてアプリを読み込む必要があります。
Bruno はデスクトップアプリとして開発されています。一つのターミナルで Next.js アプリを立ち上げ、もう一つのターミナルで electron アプリを立ち上げてアプリを読み込む必要があります。
### ローカル環境での開発

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,8 +1,8 @@
[English](../../publishing.md) | [Português (BR)](docs/publishing/publishing_pt_br.md) | [Română](docs/publishing/publishing_ro.md) | [Polski](docs/publishing/publishing_pl.md) | [বাংলা](docs/publishing/publishing_bn.md) | [Français](docs/publishing/publishing_fr.md) | [正體中文](docs/publishing/publishing_zhtw.md) | **日本語**
### Brunoを新しいパッケージマネージャに公開する場合の注意
### Bruno を新しいパッケージマネージャに公開する場合の注意
私たちのソースコードはオープンソースで誰でも使用できますが、新しいパッケージマネージャで公開を検討する前に、私たちにご連絡ください。私はBrunoの製作者として、このプロジェクト「Bruno」の商標を保有しており、その配布を管理したいと考えています。もし新しいパッケージマネージャでBrunoを使いたい場合は、GitHubのissueを立ててください。
私たちのソースコードはオープンソースで誰でも使用できますが、新しいパッケージマネージャで公開を検討する前に、私たちにご連絡ください。私は Bruno の製作者として、このプロジェクト「Bruno」の商標を保有しており、その配布を管理したいと考えています。もし新しいパッケージマネージャで Bruno を使いたい場合は、GitHub issue を立ててください。
私たちの機能の大部分が無料でオープンソース(RESTやGraphQLのAPIも含む)ですが、
私たちはオープンソースの原則と長期的な維持の間でよいバランスをとれるように努力しています- https://github.com/usebruno/bruno/discussions/269
私たちの機能の大部分が無料でオープンソース(REST GraphQL API も含む)ですが、
私たちはオープンソースの原則と長期的な維持の間でよいバランスをとれるように努力しています- https://github.com/usebruno/bruno/discussions/269

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,75 +101,128 @@ const QueryParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
/>
</td>
<td>
<SingleLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<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>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{queryParams && queryParams.length
? queryParams.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleQueryParamChange(e, param, 'name')}
/>
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddParam}>
+&nbsp;<span>Add Param</span>
</button>
</td>
<td>
<SingleLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleQueryParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<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,25 +62,38 @@ const jsonToBru = (json) => {
`;
}
if (query && query.length) {
bru += 'query {';
if (enabled(query).length) {
bru += `\n${indentString(
enabled(query)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
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(queryParams)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
}
if (disabled(queryParams).length) {
bru += `\n${indentString(
disabled(queryParams)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
}
bru += '\n}\n\n';
}
if (disabled(query).length) {
bru += `\n${indentString(
disabled(query)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
}
if (pathParams.length) {
bru += 'params:path {';
bru += '\n}\n\n';
bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += '\n}\n\n';
}
}
if (headers && 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);
});
});