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) **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 !! ## 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) [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) | **日本語** | [简体中文](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 ### Lokalny Rozwój
```bash ````bash
# użyj wersji nodejs 18 # użyj wersji nodejs 18
nvm use nvm use
@ -66,7 +66,7 @@ done
# Usuń package-lock w podkatalogach # Usuń package-lock w podkatalogach
find . -type f -name "package-lock.json" -delete find . -type f -name "package-lock.json" -delete
``` ````
### Testowanie ### 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) | **日本語** [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も含む)ですが、 私たちの機能の大部分が無料でオープンソース(REST GraphQL API も含む)ですが、
私たちはオープンソースの原則と長期的な維持の間でよいバランスをとれるように努力しています- https://github.com/usebruno/bruno/discussions/269 私たちはオープンソースの原則と長期的な維持の間でよいバランスをとれるように努力しています- https://github.com/usebruno/bruno/discussions/269

View File

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

View File

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

View File

@ -1,12 +1,18 @@
import React from 'react'; import React from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import has from 'lodash/has';
import { IconTrash } from '@tabler/icons'; import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme'; 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 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'; import StyledWrapper from './StyledWrapper';
@ -14,8 +20,10 @@ const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { storedTheme } = useTheme(); const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params'); 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( dispatch(
addQueryParam({ addQueryParam({
itemUid: item.uid, itemUid: item.uid,
@ -26,24 +34,39 @@ const QueryParams = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, 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) { switch (type) {
case 'name': { case 'name': {
param.name = e.target.value; value = e.target.value;
break; break;
} }
case 'value': { case 'value': {
param.value = e.target.value; value = e.target.value;
break; break;
} }
case 'enabled': { case 'enabled': {
param.enabled = e.target.checked; value = e.target.checked;
break; break;
} }
} }
const param = handleValueChange(data, type, value);
dispatch( dispatch(
updateQueryParam({ updateQueryParam({
param, 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( dispatch(
deleteQueryParam({ deleteQueryParam({
paramUid: param.uid, paramUid: param.uid,
@ -64,75 +101,128 @@ const QueryParams = ({ item, collection }) => {
}; };
return ( return (
<StyledWrapper className="w-full"> <StyledWrapper className="w-full flex flex-col">
<table> <div className="flex-1 mt-2">
<thead> <div className="mb-1 title text-xs">Query</div>
<tr> <table>
<td>Name</td> <thead>
<td>Value</td> <tr>
<td></td> <td>Name</td>
</tr> <td>Value</td>
</thead> <td></td>
<tbody> </tr>
{params && params.length </thead>
? params.map((param, index) => { <tbody>
return ( {queryParams && queryParams.length
<tr key={param.uid}> ? queryParams.map((param, index) => {
<td> return (
<input <tr key={param.uid}>
type="text" <td>
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">
<input <input
type="checkbox" type="text"
checked={param.enabled} autoComplete="off"
tabIndex="-1" autoCorrect="off"
className="mr-3 mousetrap" autoCapitalize="off"
onChange={(e) => handleParamChange(e, param, 'enabled')} spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleQueryParamChange(e, param, 'name')}
/> />
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}> </td>
<IconTrash strokeWidth={1.5} size={20} /> <td>
</button> <SingleLineEditor
</div> value={param.value}
</td> theme={storedTheme}
</tr> onSave={onSave}
); onChange={(newValue) =>
}) handleQueryParamChange(
: null} {
</tbody> target: {
</table> value: newValue
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddParam}> }
+&nbsp;<span>Add Param</span> },
</button> 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> </StyledWrapper>
); );
}; };

View File

@ -2,8 +2,8 @@ import Modal from 'components/Modal/index';
import { useState } from 'react'; import { useState } from 'react';
import CodeView from './CodeView'; import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url/index'; import { isValidUrl } from 'utils/url';
import get from 'lodash/get'; import { find, get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections'; import { findEnvironmentInCollection } from 'utils/collections';
// Todo: Fix this // 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 = [ const languages = [
{ {
name: 'HTTP', name: 'HTTP',
@ -76,7 +114,10 @@ const languages = [
]; ];
const GenerateCodeItem = ({ collection, item, onClose }) => { 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); const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let envVars = {}; let envVars = {};
if (environment) { 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 { 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 { import {
addDepth, addDepth,
areItemsTheSameExceptSeqUpdate, areItemsTheSameExceptSeqUpdate,
@ -21,9 +14,9 @@ import {
findItemInCollectionByPathname, findItemInCollectionByPathname,
isItemARequest isItemARequest
} from 'utils/collections'; } from 'utils/collections';
import { uuid } from 'utils/common'; import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { PATH_SEPARATOR, getDirectoryName, getSubdirectoriesFromRoot } from 'utils/common/platform'; import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
import { parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url'; import toast from 'react-hot-toast';
const initialState = { const initialState = {
collections: [], collections: [],
@ -294,10 +287,35 @@ export const collectionsSlice = createSlice({
if (collection && collection.items && collection.items.length) { if (collection && collection.items && collection.items.length) {
const parts = splitOnFirst(action.payload.requestUrl, '?'); const parts = splitOnFirst(action.payload.requestUrl, '?');
const params = parseQueryParams(parts[1]); const queryParams = parseQueryParams(parts[1]);
each(params, (urlParam) => {
urlParam.enabled = true; 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 = { const item = {
uid: action.payload.uid, uid: action.payload.uid,
@ -351,14 +369,26 @@ export const collectionsSlice = createSlice({
const parts = splitOnFirst(item.draft.request.url, '?'); const parts = splitOnFirst(item.draft.request.url, '?');
const urlParams = parseQueryParams(parts[1]); 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); 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 // try and connect as much as old params uid's as possible
each(urlParams, (urlParam) => { each(urlParams, (urlParam) => {
const existingParam = find(enabledParams, (p) => p.name === urlParam.name || p.value === urlParam.value); const existingParam = find(enabledParams, (p) => p.name === urlParam.name || p.value === urlParam.value);
urlParam.uid = existingParam ? existingParam.uid : uuid(); urlParam.uid = existingParam ? existingParam.uid : uuid();
urlParam.enabled = true; urlParam.enabled = true;
urlParam.type = 'query';
// once found, remove it - trying our best here to accommodate duplicate query params // once found, remove it - trying our best here to accommodate duplicate query params
if (existingParam) { 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 // 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 // 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 // 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: '', name: '',
value: '', value: '',
description: '', description: '',
type: 'query',
enabled: true enabled: true
}); });
} }
@ -441,16 +489,20 @@ export const collectionsSlice = createSlice({
if (!item.draft) { if (!item.draft) {
item.draft = cloneDeep(item); item.draft = cloneDeep(item);
} }
const param = find(item.draft.request.params, (h) => h.uid === action.payload.param.uid); const queryParam = find(
if (param) { item.draft.request.params,
param.name = action.payload.param.name; (h) => h.uid === action.payload.param.uid && h.type === 'query'
param.value = action.payload.param.value; );
param.description = action.payload.param.description; if (queryParam) {
param.enabled = action.payload.param.enabled; queryParam.name = action.payload.param.name;
queryParam.value = action.payload.param.value;
queryParam.enabled = action.payload.param.enabled;
// update request url // update request url
const parts = splitOnFirst(item.draft.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 no query is found, then strip the query params in url
if (!query || !query.length) { if (!query || !query.length) {
@ -486,7 +538,7 @@ export const collectionsSlice = createSlice({
// update request url // update request url
const parts = splitOnFirst(item.draft.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) { if (query && query.length) {
item.draft.request.url = parts[0] + '?' + query; item.draft.request.url = parts[0] + '?' + query;
} else { } 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) => { addRequestHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -1418,6 +1490,7 @@ export const {
addQueryParam, addQueryParam,
updateQueryParam, updateQueryParam,
deleteQueryParam, deleteQueryParam,
updatePathParam,
addRequestHeader, addRequestHeader,
updateRequestHeader, updateRequestHeader,
deleteRequestHeader, deleteRequestHeader,

View File

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

View File

@ -29,9 +29,6 @@ export const deleteUidsInItems = (items) => {
export const transformItem = (items = []) => { export const transformItem = (items = []) => {
each(items, (item) => { each(items, (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) { if (['http-request', 'graphql-request'].includes(item.type)) {
item.request.query = item.request.params;
delete item.request.params;
if (item.type === 'graphql-request') { if (item.type === 'graphql-request') {
item.type = 'graphql'; 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 map(params, (param) => {
return { return {
uid: param.uid, uid: param.uid,
name: param.name, name: param.name,
value: param.value, value: param.value,
description: param.description, description: param.description,
type: param.type,
enabled: param.enabled enabled: param.enabled
}; };
}); });
@ -283,7 +284,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
url: si.request.url, url: si.request.url,
method: si.request.method, method: si.request.method,
headers: copyHeaders(si.request.headers), headers: copyHeaders(si.request.headers),
params: copyQueryParams(si.request.params), params: copyParams(si.request.params),
body: { body: {
mode: si.request.body.mode, mode: si.request.body.mode,
json: si.request.body.json, json: si.request.body.json,
@ -441,6 +442,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: param.name, name: param.name,
value: param.value, value: param.value,
description: param.description, description: param.description,
type: param.type,
enabled: param.enabled 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 generateRequestSection = (itemRequest) => {
const requestObject = { const requestObject = {
method: itemRequest.method, method: itemRequest.method,
header: generateHeaders(itemRequest.headers), 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) auth: generateAuth(itemRequest.auth)
}; };

View File

@ -29,7 +29,6 @@ export const updateUidsInCollection = (_collection) => {
item.uid = uuid(); item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.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.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid())); each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (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)) { if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`; item.type = `${item.type}-request`;
if (item.request.query) { 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; delete item.request.query;

View File

@ -112,10 +112,22 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
name: param.name, name: param.name,
value: param.value, value: param.value,
description: param.description, description: param.description,
type: 'query',
enabled: !param.disabled 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', ''); const authType = get(request, 'authentication.type', '');
if (authType === 'basic') { if (authType === 'basic') {

View File

@ -92,7 +92,17 @@ const transformOpenapiRequestItem = (request) => {
name: param.name, name: param.name,
value: '', value: '',
description: param.description || '', 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') { } else if (param.in === 'header') {
brunoRequestItem.request.headers.push({ brunoRequestItem.request.headers.push({

View File

@ -275,10 +275,22 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
name: param.key, name: param.key,
value: param.value, value: param.value,
description: param.description, description: param.description,
type: 'query',
enabled: !param.disabled 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); brunoParent.items.push(brunoRequestItem);
} }
} }

View File

@ -2,6 +2,7 @@ import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import each from 'lodash/each'; import each from 'lodash/each';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import find from 'lodash/find';
const hasLength = (str) => { const hasLength = (str) => {
if (!str || !str.length) { if (!str || !str.length) {
@ -26,6 +27,41 @@ export const parseQueryParams = (query) => {
return filter(params, (p) => hasLength(p.name)); 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) => { export const stringifyQueryParams = (params) => {
if (!params || isEmpty(params)) { if (!params || isEmpty(params)) {
return ''; return '';

View File

@ -1,4 +1,4 @@
import { parseQueryParams, splitOnFirst } from './index'; import { parseQueryParams, splitOnFirst, parsePathParams } from './index';
describe('Url Utils - parseQueryParams', () => { describe('Url Utils - parseQueryParams', () => {
it('should parse query - case 1', () => { 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', () => { describe('Url Utils - splitOnFirst', () => {
it('should split on first - case 1', () => { it('should split on first - case 1', () => {
const params = splitOnFirst('a', '='); const params = splitOnFirst('a', '=');

View File

@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common'); const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash'); const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => { const getContentType = (headers = {}) => {
let contentType = ''; let contentType = '';
@ -86,6 +86,36 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
param.value = _interpolate(param.value); 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) { if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol); request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname); request.proxy.hostname = _interpolate(request.proxy.hostname);

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common'); const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash'); const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => { const getContentType = (headers = {}) => {
let contentType = ''; let contentType = '';
@ -86,6 +86,36 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
param.value = _interpolate(param.value); 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) { if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol); request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname); request.proxy.hostname = _interpolate(request.proxy.hostname);

View File

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

View File

@ -22,10 +22,11 @@ const { outdentString } = require('../../v1/src/utils');
* *
*/ */
const grammar = ohm.grammar(`Bru { 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 auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart bodyforms = bodyformurlencoded | bodymultipart
params = paramspath | paramsquery
nl = "\\r"? "\\n" nl = "\\r"? "\\n"
st = " " | "\\t" st = " " | "\\t"
@ -74,6 +75,8 @@ const grammar = ohm.grammar(`Bru {
headers = "headers" dictionary headers = "headers" dictionary
query = "query" dictionary query = "query" dictionary
paramspath = "params:path" dictionary
paramsquery = "params:query" dictionary
varsandassert = varsreq | varsres | assert varsandassert = varsreq | varsres | assert
varsreq = "vars:pre-request" dictionary 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 mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled); const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
@ -321,7 +346,17 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}, },
query(_1, dictionary) { query(_1, dictionary) {
return { 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) { headers(_1, dictionary) {

View File

@ -30,7 +30,7 @@ const getValueString = (value) => {
}; };
const jsonToBru = (json) => { 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 = ''; let bru = '';
@ -62,25 +62,38 @@ const jsonToBru = (json) => {
`; `;
} }
if (query && query.length) { if (params && params.length) {
bru += 'query {'; const queryParams = params.filter((param) => param.type === 'query');
if (enabled(query).length) { const pathParams = params.filter((param) => param.type === 'path');
bru += `\n${indentString(
enabled(query) if (queryParams.length) {
.map((item) => `${item.name}: ${item.value}`) bru += 'params:query {';
.join('\n') 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) { if (pathParams.length) {
bru += `\n${indentString( bru += 'params:path {';
disabled(query)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
}
bru += '\n}\n\n'; bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += '\n}\n\n';
}
} }
if (headers && headers.length) { if (headers && headers.length) {

View File

@ -185,6 +185,17 @@ const authSchema = Yup.object({
.noUnknown(true) .noUnknown(true)
.strict(); .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 // 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 // As we introduce more request types in the future, we will improve the definition to support
// schema structure based on other request type // schema structure based on other request type
@ -192,7 +203,7 @@ const requestSchema = Yup.object({
url: requestUrlSchema, url: requestUrlSchema,
method: requestMethodSchema, method: requestMethodSchema,
headers: Yup.array().of(keyValueSchema).required('headers are required'), 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, auth: authSchema,
body: requestBodySchema, body: requestBodySchema,
script: Yup.object({ script: Yup.object({

View File

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

View File

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