Merge branch 'usebruno:main' into main

This commit is contained in:
Pragadesh-45
2025-02-07 10:01:15 +05:30
committed by GitHub
44 changed files with 961 additions and 160 deletions

View File

@@ -33,7 +33,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.6",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",
"immer": "^9.0.15",

View File

@@ -6,10 +6,9 @@ import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform';
import slash from 'utils/common/slash';
const FilePickerEditor = ({ value, onChange, collection }) => {
value = value || [];
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
const dispatch = useDispatch();
const filenames = value
const filenames = (isSingleFilePicker ? [value] : value || [])
.filter((v) => v != null && v != '')
.map((v) => {
const separator = isWindowsOS() ? '\\' : '/';
@@ -20,7 +19,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const title = filenames.map((v) => `- ${v}`).join('\n');
const browse = () => {
dispatch(browseFiles())
dispatch(browseFiles([], [!isSingleFilePicker ? "multiSelections": ""]))
.then((filePaths) => {
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
@@ -34,7 +33,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
return filePath;
});
onChange(filePaths);
onChange(isSingleFilePicker ? filePaths[0] : filePaths);
})
.catch((error) => {
console.error(error);
@@ -42,14 +41,14 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
};
const clear = () => {
onChange([]);
onChange(isSingleFilePicker ? '' : []);
};
const renderButtonText = (filenames) => {
if (filenames.length == 1) {
return filenames[0];
}
return filenames.length + ' files selected';
return filenames.length + ' file(s) selected';
};
return filenames.length > 0 ? (
@@ -66,9 +65,9 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
</div>
) : (
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
Select Files
{isSingleFilePicker ? 'Select File' : 'Select Files'}
</button>
);
};
export default FilePickerEditor;
export default FilePickerEditor;

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(2) {
width: 45%;
}
&:nth-child(3) {
width: 25%;
}
&:nth-child(4) {
width: 70px;
}
}
}
.btn-add-param {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
background: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='radio'] {
cursor: pointer;
position: relative;
top: 1px;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { get, cloneDeep, isArray } from 'lodash';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addFile as _addFile, updateFile, deleteFile } from 'providers/ReduxStore/slices/collections/index';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor/index';
import SingleLineEditor from 'components/SingleLineEditor/index';
const FileBody = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.body.file') : get(item, 'request.body.file');
const [enabledFileUid, setEnableFileUid] = useState(params && params.length ? params[0].uid : '');
const addFile = () => {
dispatch(
_addFile({
itemUid: item.uid,
collectionUid: collection.uid,
})
);
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
switch (type) {
case 'filePath': {
param.filePath = e.target.filePath;
param.contentType = "";
break;
}
case 'contentType': {
param.contentType = e.target.contentType;
break;
}
case 'selected': {
param.selected = e.target.selected;
setEnableFileUid(param.uid)
break;
}
}
dispatch(
updateFile({
param: param,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveParams = (param) => {
dispatch(
deleteFile({
paramUid: param.uid,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>
<div className="flex items-center justify-center">File</div>
</td>
<td>
<div className="flex items-center justify-center">Content-Type</div>
</td>
<td>
<div className="flex items-center justify-center">Selected</div>
</td>
<td></td>
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<FilePickerEditor
isSingleFilePicker={true}
value={param.filePath}
onChange={(path) =>
handleParamChange(
{
target: {
filePath: path
}
},
param,
'filePath'
)
}
collection={collection}
/>
</td>
<td>
<SingleLineEditor
className="flex items-center justify-center"
onSave={onSave}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={(newValue) =>
handleParamChange(
{
target: {
contentType: newValue
}
},
param,
'contentType'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center justify-center">
<input
key={param.uid}
type="radio"
name="selected"
checked={enabledFileUid === param.uid || param.selected}
tabIndex="-1"
className="mr-1 mousetrap"
onChange={(e) => handleParamChange(e, param, 'selected')}
/>
</div>
</td>
<td>
<div className="flex items-center justify-center">
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<div>
<button className="btn-add-param text-link pr-2 pt-3 select-none" onClick={addFile}>
+ Add File
</button>
</div>
</StyledWrapper>
);
};
export default FileBody;

View File

@@ -128,6 +128,15 @@ const RequestBodyMode = ({ item, collection }) => {
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FileBody from '../FileBody/index';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -62,6 +63,10 @@ const RequestBody = ({ item, collection }) => {
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
}
if (bodyMode === 'formUrlEncoded') {
return <FormUrlEncodedParams item={item} collection={collection} />;
}
@@ -72,4 +77,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -2,15 +2,15 @@ import React from 'react';
import CloseTabIcon from './CloseTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName }) => {
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
const getTabInfo = (type, tabName) => {
switch (type) {
case 'collection-settings': {
return (
<>
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
</>
</div>
);
}
case 'collection-overview': {
@@ -31,7 +31,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
}
case 'folder-settings': {
return (
<div className="flex items-center flex-nowrap overflow-hidden">
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
@@ -73,13 +73,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-1"
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} tabName={folder?.name} />
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} />
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
</StyledWrapper>
);
@@ -144,8 +144,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
<div
className="flex items-baseline tab-label pl-2"
className={`flex items-baseline tab-label pl-2 ${tab.preview ? "italic" : ""}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);

View File

@@ -5,7 +5,7 @@ import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
@@ -23,7 +23,9 @@ import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemIcon from './CollectionItemIcon/index';
import { findItemInCollection } from 'utils/collections';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
@@ -83,13 +85,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
'item-hovered': isOver
});
const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
@@ -99,10 +94,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
const handleClick = (event) => {
if (event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
if (isItemARequest(item)) {
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
@@ -112,20 +110,21 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
);
return;
}
} else {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
type: 'folder-settings',
})
);
dispatch(
@@ -134,9 +133,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
collectionUid: collection.uid
})
);
}
};
const handleFolderCollapse = () => {
const handleFolderCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
dispatch(
collectionFolderClicked({
itemUid: item.uid,
@@ -156,10 +158,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleDoubleClick = (event) => {
setRenameItemModalOpen(true);
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
@@ -180,6 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
}
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
};
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
@@ -280,6 +282,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
style={{
paddingLeft: 8
}}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<div style={{ width: 16, minWidth: 16 }}>
{isFolder ? (
@@ -295,9 +300,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>

View File

@@ -7,8 +7,8 @@ import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
@@ -20,7 +20,8 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections';
import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -29,6 +30,7 @@ const Collection = ({ collection, searchText }) => {
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
@@ -52,6 +54,16 @@ const Collection = ({ collection, searchText }) => {
);
};
const ensureCollectionIsMounted = () => {
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
}
const hasSearchText = searchText && searchText?.trim()?.length;
const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
@@ -60,30 +72,37 @@ const Collection = ({ collection, searchText }) => {
});
const handleClick = (event) => {
if (event.detail != 1) return;
// Check if the click came from the chevron icon
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
dispatch(collapseCollection(collection.uid));
setTimeout(scrollToTheActiveTab, 50);
// Only open collection settings if not clicking the chevron
ensureCollectionIsMounted();
dispatch(collapseCollection(collection.uid));
if(!isChevronClick) {
dispatch(
addTab({
uid: uuid(),
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
type: 'collection-settings',
})
);
}
};
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: collection.uid }))
};
const handleCollectionCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
ensureCollectionIsMounted();
dispatch(collapseCollection(collection.uid));
}
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
@@ -158,6 +177,7 @@ const Collection = ({ collection, searchText }) => {
<div
className="flex flex-grow items-center overflow-hidden"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight
@@ -165,6 +185,7 @@ const Collection = ({ collection, searchText }) => {
strokeWidth={2}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleCollectionCollapse}
/>
<div className="ml-1" id="sidebar-collection-name">
{collection.name}

View File

@@ -6,12 +6,13 @@ import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
const isDevEnv = () => {
return import.meta.env.MODE === 'development';
};
let middleware = [tasksMiddleware.middleware];
let middleware = [tasksMiddleware.middleware, draftDetectMiddleware];
if (isDevEnv()) {
middleware = [...middleware, debugMiddleware.middleware];
}

View File

@@ -0,0 +1,56 @@
import { handleMakeTabParmanent } from "./utils";
const actionsToIntercept = [
'collections/requestUrlChanged',
'collections/updateAuth',
'collections/addQueryParam',
'collections/moveQueryParam',
'collections/updateQueryParam',
'collections/deleteQueryParam',
'collections/updatePathParam',
'collections/addRequestHeader',
'collections/updateRequestHeader',
'collections/deleteRequestHeader',
'collections/moveRequestHeader',
'collections/addFormUrlEncodedParam',
'collections/updateFormUrlEncodedParam',
'collections/deleteFormUrlEncodedParam',
'collections/moveFormUrlEncodedParam',
'collections/addMultipartFormParam',
'collections/updateMultipartFormParam',
'collections/deleteMultipartFormParam',
'collections/moveMultipartFormParam',
'collections/updateRequestAuthMode',
'collections/updateRequestBodyMode',
'collections/updateRequestBody',
'collections/updateRequestGraphqlQuery',
'collections/updateRequestGraphqlVariables',
'collections/updateRequestScript',
'collections/updateResponseScript',
'collections/updateRequestTests',
'collections/updateRequestMethod',
'collections/addAssertion',
'collections/updateAssertion',
'collections/deleteAssertion',
'collections/moveAssertion',
'collections/addVar',
'collections/updateVar',
'collections/deleteVar',
'collections/moveVar',
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/updateRequestDocs',
'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink.
];
export const draftDetectMiddleware = ({ dispatch, getState }) => (next) => (action) => {
if (actionsToIntercept.includes(action.type)) {
const state = getState();
handleMakeTabParmanent(state, action, dispatch);
}
return next(action);
};

View File

@@ -0,0 +1,21 @@
import { makeTabPermanent } from "providers/ReduxStore/slices/tabs";
import { findCollectionByUid, findItemInCollection } from "utils/collections/index";
import find from 'lodash/find';
function handleMakeTabParmanent(state, action, dispatch) {
const tabs = state.tabs.tabs;
const activeTabUid = state.tabs.activeTabUid;
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const itemUid = action.payload.itemUid || action.payload.folderUid
const collection = findCollectionByUid(state.collections.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && focusedTab.preview == true) {
dispatch(makeTabPermanent({ uid: itemUid }));
}
}
}
export {
handleMakeTabParmanent
}

View File

@@ -758,7 +758,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
xml: null,
sparql: null,
multipartForm: null,
formUrlEncoded: null
formUrlEncoded: null,
file: null
},
auth: auth ?? {
mode: 'none'
@@ -1038,14 +1039,17 @@ export const browseDirectory = () => (dispatch, getState) => {
};
export const browseFiles =
(filters = []) =>
(dispatch, getState) => {
(filters, properties) =>
(_dispatch, _getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject);
ipcRenderer
.invoke('renderer:browse-files', filters, properties)
.then(resolve)
.catch(reject);
});
};
};
export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => {
const state = getState();

View File

@@ -18,6 +18,8 @@ import {
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';
import path from 'node:path';
const initialState = {
collections: [],
@@ -895,6 +897,76 @@ export const collectionsSlice = createSlice({
}
}
},
addFile: (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);
}
item.draft.request.body.file = item.draft.request.body.file || [];
item.draft.request.body.file.push({
uid: uuid(),
filePath: '',
contentType: '',
selected: false
});
}
}
},
updateFile: (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.body.file, (p) => p.uid === action.payload.param.uid);
if (param) {
const contentType = mime.contentType(path.extname(action.payload.param.filePath));
param.filePath = action.payload.param.filePath;
param.contentType = action.payload.param.contentType || contentType || '';
param.selected = action.payload.param.selected;
item.draft.request.body.file = item.draft.request.body.file.map((p) => {
p.selected = p.uid === param.uid;
return p;
});
}
}
}
},
deleteFile: (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);
}
item.draft.request.body.file = filter(
item.draft.request.body.file,
(p) => p.uid !== action.payload.paramUid
);
if (item.draft.request.body.file.length > 0) {
item.draft.request.body.file[0].selected = true;
}
}
}
},
updateRequestAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -951,6 +1023,10 @@ export const collectionsSlice = createSlice({
item.draft.request.body.sparql = action.payload.content;
break;
}
case 'file': {
item.draft.request.body.file = action.payload.content;
break;
}
case 'formUrlEncoded': {
item.draft.request.body.formUrlEncoded = action.payload.content;
break;
@@ -1952,6 +2028,9 @@ export const {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
addFile,
updateFile,
deleteFile,
moveMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,

View File

@@ -1,4 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { findIndex } from 'lodash';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
@@ -10,40 +11,55 @@ const initialState = {
activeTabUid: null
};
const tabTypeAlreadyExists = (tabs, collectionUid, type) => {
return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type);
};
export const tabsSlice = createSlice({
name: 'tabs',
initialState,
reducers: {
addTab: (state, action) => {
const alreadyExists = find(state.tabs, (tab) => tab.uid === action.payload.uid);
if (alreadyExists) {
const { uid, collectionUid, type, requestPaneTab, preview } = action.payload;
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
if (existingTab) {
state.activeTabUid = existingTab.uid;
return;
}
const nonReplaceableTabTypes = [
"variables",
"collection-runner",
"security-settings",
];
if (
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type)
) {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
if (tab) {
state.activeTabUid = tab.uid;
return;
const lastTab = state.tabs[state.tabs.length - 1];
if (state.tabs.length > 0 && lastTab.preview) {
state.tabs[state.tabs.length - 1] = {
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: type || 'request',
preview: true,
...(uid ? { folderUid: uid } : {})
}
}
state.activeTabUid = uid;
return
}
state.tabs.push({
uid: action.payload.uid,
collectionUid: action.payload.collectionUid,
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: action.payload.requestPaneTab || 'params',
requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
type: action.payload.type || 'request',
...(action.payload.uid ? { folderUid: action.payload.uid } : {})
type: type || 'request',
...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type)
});
state.activeTabUid = action.payload.uid;
state.activeTabUid = uid;
},
focusTab: (state, action) => {
state.activeTabUid = action.payload.uid;
@@ -124,6 +140,15 @@ export const tabsSlice = createSlice({
const collectionUid = action.payload.collectionUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
state.activeTabUid = null;
},
makeTabPermanent: (state, action) => {
const { uid } = action.payload;
const tab = find(state.tabs, (t) => t.uid === uid);
if (tab) {
tab.preview = false;
} else{
console.error("Tab not found!")
}
}
}
});
@@ -136,7 +161,8 @@ export const {
updateRequestPaneTab,
updateResponsePaneTab,
closeTabs,
closeAllCollectionTabs
closeAllCollectionTabs,
makeTabPermanent
} = tabsSlice.actions;
export default tabsSlice.reducer;
export default tabsSlice.reducer;

View File

@@ -14,6 +14,8 @@ const createContentType = (mode) => {
return 'application/json';
case 'multipartForm':
return 'multipart/form-data';
case 'file':
return 'application/octet-stream';
default:
return '';
}
@@ -60,22 +62,51 @@ const createPostData = (body, type) => {
}
const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
} else {
return {
mimeType: contentType,
text: body[body.mode]
};
switch (body.mode) {
case 'formUrlEncoded':
return {
mimeType: contentType,
text: new URLSearchParams(
body[body.mode]
.filter((param) => param.enabled)
.reduce((acc, param) => {
acc[param.name] = param.value;
return acc;
}, {})
).toString(),
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value
}))
};
case 'multipartForm':
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
case 'file':
return {
mimeType: body[body.mode].filter((param) => param.enabled)[0].contentType,
params: body[body.mode]
.filter((param) => param.selected)
.map((param) => ({
value: param.filePath,
}))
};
default:
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
@@ -89,6 +120,7 @@ export const buildHarRequest = ({ request, headers, type }) => {
queryString: createQuery(request.params),
postData: createPostData(request.body, type),
headersSize: 0,
bodySize: 0
bodySize: 0,
binary: true
};
};

View File

@@ -14,6 +14,7 @@ export const deleteUidsInItems = (items) => {
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
each(get(item, 'request.body.file'), (param) => delete param.uid);
}
if (item.items && item.items.length) {

View File

@@ -281,6 +281,17 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
const copyFileParams = (params = []) => {
return map(params, (param) => {
return {
uid: param.uid,
filePath: param.filePath,
contentType: param.contentType,
selected: param.selected
}
});
}
const copyItems = (sourceItems, destItems) => {
each(sourceItems, (si) => {
if (!isItemAFolder(si) && !isItemARequest(si) && si.type !== 'js') {
@@ -308,7 +319,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
graphql: si.request.body.graphql,
sparql: si.request.body.sparql,
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
multipartForm: copyMultipartFormParams(si.request.body.multipartForm),
file: copyFileParams(si.request.body.file)
},
script: si.request.script,
vars: si.request.vars,
@@ -661,6 +673,10 @@ export const humanizeRequestBodyMode = (mode) => {
label = 'SPARQL';
break;
}
case 'file': {
label = 'File / Binary';
break;
}
case 'formUrlEncoded': {
label = 'Form URL Encoded';
break;
@@ -761,6 +777,7 @@ export const refreshUidsInItem = (item) => {
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
return item;
};
@@ -771,11 +788,13 @@ export const deleteUidsInItem = (item) => {
const headers = get(item, 'request.headers', []);
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
const file = get(item, 'request.body.file', []);
params.forEach((param) => delete param.uid);
headers.forEach((header) => delete header.uid);
bodyFormUrlEncoded.forEach((param) => delete param.uid);
bodyMultipartForm.forEach((param) => delete param.uid);
file.forEach((param) => delete param.uid);
return item;
};

View File

@@ -159,6 +159,8 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
if (contentType.includes('json')) {
return 'application/ld+json';
} else if (contentType.includes('image')) {
return 'application/image';
} else if (contentType.includes('xml')) {
return 'application/xml';
} else if (contentType.includes('html')) {
@@ -169,8 +171,6 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
return 'application/xml';
} else if (contentType.includes('yaml')) {
return 'application/yaml';
} else if (contentType.includes('image')) {
return 'application/image';
} else {
return 'application/text';
}

View File

@@ -94,6 +94,8 @@ export const getContentType = (headers) => {
if (contentType && contentType.length) {
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
return 'application/ld+json';
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
return 'image/svg+xml';
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}

View File

@@ -9,6 +9,7 @@
import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import * as path from 'path';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -99,9 +100,30 @@ function getMultipleDataString(request, parsedQueryString) {
function getFilesString(request) {
const data = {};
data.files = {};
data.data = {};
if (request.isDataBinary) {
let filePath = '';
if (request.data.startsWith('@')) {
filePath = request.data.slice(1);
} else {
filePath = request.data;
}
data.data = [
{
filePath: repr(filePath),
contentType: request.headers['Content-Type'],
selected: true,
}
];
return data;
}
data.files = {};
for (const multipartKey in request.multipartUploads) {
const multipartValue = request.multipartUploads[multipartKey];
if (multipartValue.startsWith('@')) {
@@ -140,6 +162,7 @@ const curlToJson = (curlCommand) => {
requestJson.url = request.urlWithoutQuery;
requestJson.raw_url = request.url;
requestJson.method = request.method;
requestJson.isDataBinary = request.isDataBinary;
if (request.cookies) {
const cookies = {};
@@ -161,12 +184,10 @@ const curlToJson = (curlCommand) => {
if (request.query) {
requestJson.queries = getQueries(request);
}
if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
} else if (request.multipartUploads) {
} else if (request.multipartUploads || request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
}
if (request.insecure) {

View File

@@ -86,4 +86,38 @@ describe('curlToJson', () => {
method: 'get'
});
});
it('should return a parse a curl with a post body with binary file type', () => {
const curlCommand = `curl 'https://www.usebruno.com'
-H 'Accept: application/json, text/plain, */*'
-H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
-H 'Content-Type: application/json;charset=utf-8'
-H 'Origin: https://www.usebruno.com'
-H 'Referer: https://www.usebruno.com/'
--data-binary '@/path/to/file'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com',
raw_url: 'https://www.usebruno.com',
method: 'post',
headers: {
Accept: 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
'Content-Type': 'application/json;charset=utf-8',
Origin: 'https://www.usebruno.com',
Referer: 'https://www.usebruno.com/'
},
isDataBinary: true,
data: [
{
filePath: '/path/to/file',
contentType: 'application/json;charset=utf-8',
selected: true
}
]
});
});
});

View File

@@ -50,14 +50,18 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
sparql: null,
multipartForm: null,
formUrlEncoded: null,
graphql: null
graphql: null,
file: null
};
if (parsedBody && contentType && typeof contentType === 'string') {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (contentType.includes('application/json')) {
} else if (requestType === 'http-request' && request.isDataBinary) {
body.mode = 'file';
body.file = parsedBody;
}else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
} else if (contentType.includes('xml')) {

View File

@@ -35,6 +35,7 @@ export const updateUidsInCollection = (_collection) => {
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);

View File

@@ -11,3 +11,10 @@ export const isItemAFolder = (item) => {
export const itemIsOpenedInTabs = (item, tabs) => {
return find(tabs, (t) => t.uid === item.uid);
};
export const scrollToTheActiveTab = () => {
const activeTab = document.querySelector('.request-tab.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};

View File

@@ -62,7 +62,10 @@ const makeJUnitOutput = async (results, outputPath) => {
suite.testcase.push(testcase);
});
if (result.error) {
if (result?.skipped) {
suite['@skipped'] = 1;
}
else if (result.error) {
suite['@errors'] = 1;
suite['@tests'] = 1;
suite.testcase = [

View File

@@ -265,7 +265,30 @@ const runSingleRequest = async function (
if (!options.disableCookies) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
request.headers['cookie'] = cookieString;
const existingCookieHeaderName = Object.keys(request.headers).find(
name => name.toLowerCase() === 'cookie'
);
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
// Helper function to parse cookies into an object
const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
const [name, ...rest] = cookie.split('=');
if (name && name.trim()) {
cookies[name.trim()] = rest.join('=').trim();
}
return cookies;
}, {});
const mergedCookies = {
...parseCookies(existingCookieString),
...parseCookies(cookieString),
};
const combinedCookieString = Object.entries(mergedCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
}
}

View File

@@ -1,8 +1,9 @@
const { sizeInMB } = require("../../utils/filesystem");
const WorkerQueue = require("../../workers");
const path = require("path");
const getSize = (data) => {
return typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8');
return sizeInMB(typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8'));
}
/**
@@ -12,7 +13,13 @@ const getSize = (data) => {
* This helps with parsing performance.
*/
const LANES = [{
maxSize: 0.005
},{
maxSize: 0.1
},{
maxSize: 1
},{
maxSize: 10
},{
maxSize: 100
}];

View File

@@ -62,13 +62,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// browse directory for file
ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => {
ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
try {
const filePaths = await browseFiles(mainWindow, filters);
return filePaths;
return await browseFiles(mainWindow, filters, properties);
} catch (error) {
return Promise.reject(error);
throw error;
}
});

View File

@@ -28,7 +28,7 @@ const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('./digestauth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
const { chooseFileToSave, writeFile } = require('../../utils/filesystem');
const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
const {
resolveOAuth2AuthorizationCodeAccessToken,
@@ -332,7 +332,30 @@ const configureRequest = async (
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
request.headers['cookie'] = cookieString;
const existingCookieHeaderName = Object.keys(request.headers).find(
name => name.toLowerCase() === 'cookie'
);
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
// Helper function to parse cookies into an object
const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
const [name, ...rest] = cookie.split('=');
if (name && name.trim()) {
cookies[name.trim()] = rest.join('=').trim();
}
return cookies;
}, {});
const mergedCookies = {
...parseCookies(existingCookieString),
...parseCookies(cookieString),
};
const combinedCookieString = Object.entries(mergedCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
}
}
@@ -361,8 +384,8 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
const charsetValue = charsetMatch?.[1];
const dataBuffer = Buffer.from(response.data);
const charsetValue = charsetMatch?.[1] || 'utf-8';
const dataBuffer = Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data);
// Overwrite the original data for backwards compatibility
let data;
if (iconv.encodingExists(charsetValue)) {
@@ -384,6 +407,25 @@ const parseDataFromResponse = (response, disableParsingResponseJson = false) =>
console.log('Failed to parse response data as JSON');
}
// Handle Buffer responses that contain JSON
if (Buffer.isBuffer(response.data)) {
try {
const decodedString = response.data.toString('utf-8');
const parsedData = JSON.parse(decodedString);
if (parsedData && parsedData.type === "Buffer" && Array.isArray(parsedData.data)) {
data = Buffer.from(parsedData.data).toString('utf-8');
if (!disableParsingResponseJson) {
data = JSON.parse(data);
}
} else {
data = parsedData;
}
} catch {
console.error('Failed to parse Buffer data as JSON');
}
}
return { data, dataBuffer };
};
@@ -575,16 +617,16 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});
const request = prepareRequest(item, collection);
const abortController = new AbortController();
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'standalone';
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
try {
const controller = new AbortController();
request.signal = controller.signal;
saveCancelToken(cancelTokenUid, controller);
request.signal = abortController.signal;
saveCancelToken(cancelTokenUid, abortController);
await runPreRequest(
request,
@@ -614,7 +656,7 @@ const registerNetworkIpc = (mainWindow) => {
url: request.url,
method: request.method,
headers: request.headers,
data: safeParseJSON(safeStringifyJSON(request.data)),
data: request.mode == 'file'? "<request body redacted>": safeParseJSON(safeStringifyJSON(request.data)) ,
timestamp: Date.now()
},
collectionUid,
@@ -1036,7 +1078,7 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
const request = prepareRequest(item, collection);
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
const requestUid = uuid();
@@ -1371,7 +1413,7 @@ const registerNetworkIpc = (mainWindow) => {
if (encoding === 'utf-8') {
await writeFile(filePath, data);
} else {
await writeBinaryFile(filePath, data);
await writeFile(filePath, data, true);
}
}
} catch (error) {

View File

@@ -1,8 +1,10 @@
const { get, each, filter } = require('lodash');
const { get, each, filter, find } = require('lodash');
const decomment = require('decomment');
const crypto = require('node:crypto');
const fs = require('node:fs/promises');
const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars } = require('../../utils/collection');
const { buildFormUrlEncodedPayload, createFormData } = require('../../utils/form-data');
const path = require('node:path');
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
@@ -174,10 +176,10 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
return axiosRequest;
};
const prepareRequest = (item, collection) => {
const prepareRequest = async (item, collection = {}, abortController) => {
const request = item.draft ? item.draft.request : item.request;
const collectionRoot = get(collection, 'root', {});
const collectionPath = collection.pathname;
const collectionPath = collection?.pathname;
const headers = {};
let contentTypeDefined = false;
let url = request.url;
@@ -189,7 +191,7 @@ const prepareRequest = (item, collection) => {
}
});
const scriptFlow = collection.brunoConfig?.scripts?.flow ?? 'sandwich';
const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) {
mergeHeaders(collection, request, requestTreePath);
@@ -251,6 +253,31 @@ const prepareRequest = (item, collection) => {
axiosRequest.data = request.body.sparql;
}
if (request.body.mode === 'file') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/octet-stream'; // Default headers for binary file uploads
}
const bodyFile = find(request.body.file, (param) => param.selected);
if (bodyFile) {
let { filePath, contentType } = bodyFile;
axiosRequest.headers['content-type'] = contentType;
if (filePath) {
if (!path.isAbsolute(filePath)) {
filePath = path.join(collectionPath, filePath);
}
try {
const fileContent = await fs.readFile(filePath);
axiosRequest.data = fileContent;
} catch (error) {
console.error('Error reading file:', error);
}
}
}
}
if (request.body.mode === 'formUrlEncoded') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';

View File

@@ -240,6 +240,7 @@ const hydrateRequestWithUuid = (request, pathname) => {
const assertions = get(request, 'request.assertions', []);
const bodyFormUrlEncoded = get(request, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(request, 'request.body.multipartForm', []);
const file = get(request, 'request.body.file', []);
params.forEach((param) => (param.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
@@ -248,6 +249,7 @@ const hydrateRequestWithUuid = (request, pathname) => {
assertions.forEach((assertion) => (assertion.uid = uuid()));
bodyFormUrlEncoded.forEach((param) => (param.uid = uuid()));
bodyMultipartForm.forEach((param) => (param.uid = uuid()));
file.forEach((param) => (param.uid = uuid()));
return request;
};

View File

@@ -68,20 +68,13 @@ function normalizeWslPath(pathname) {
return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\');
}
const writeFile = async (pathname, content) => {
const writeFile = async (pathname, content, isBinary = false) => {
try {
fs.writeFileSync(pathname, content, {
encoding: 'utf8'
await fs.writeFile(pathname, content, {
encoding: !isBinary ? "utf-8" : null
});
} catch (err) {
return Promise.reject(err);
}
};
const writeBinaryFile = async (pathname, content) => {
try {
fs.writeFileSync(pathname, content);
} catch (err) {
console.error(`Error writing file at ${pathname}:`, err);
return Promise.reject(err);
}
};
@@ -121,9 +114,9 @@ const browseDirectory = async (win) => {
return isDirectory(resolvedPath) ? resolvedPath : false;
};
const browseFiles = async (win, filters) => {
const browseFiles = async (win, filters = [], properties = []) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openFile', 'multiSelections'],
properties: ['openFile', ...properties],
filters
});
@@ -265,7 +258,6 @@ module.exports = {
isWSLPath,
normalizeWslPath,
writeFile,
writeBinaryFile,
hasJsonExtension,
hasBruExtension,
createDirectory,

View File

@@ -7,15 +7,17 @@ describe('prepare-request: prepareRequest', () => {
describe('Decomments request body', () => {
it('If request body is valid JSON', async () => {
const body = { mode: 'json', json: '{\n"test": "{{someVar}}" // comment\n}' };
const expected = '{\n"test": "{{someVar}}" \n}';
const result = prepareRequest({ request: { body } }, {});
const expected = `{
\"test\": \"{{someVar}}\"
}`;
const result = await prepareRequest({ request: { body }, collection: { pathname: '' } });
expect(result.data).toEqual(expected);
});
it('If request body is not valid JSON', async () => {
const body = { mode: 'json', json: '{\n"test": {{someVar}} // comment\n}' };
const expected = '{\n"test": {{someVar}} \n}';
const result = prepareRequest({ request: { body } }, {});
const result = await prepareRequest({ request: { body }, collection: { pathname: '' } });
expect(result.data).toEqual(expected);
});

View File

@@ -25,7 +25,7 @@ const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
bodyforms = bodyformurlencoded | bodymultipart | bodyfile
params = paramspath | paramsquery
nl = "\\r"? "\\n"
@@ -102,7 +102,8 @@ const grammar = ohm.grammar(`Bru {
bodyformurlencoded = "body:form-urlencoded" dictionary
bodymultipart = "body:multipart-form" dictionary
bodyfile = "body:file" dictionary
script = scriptreq | scriptres
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
scriptres = "script:post-response" st* "{" nl* textblock tagend
@@ -173,6 +174,19 @@ const multipartExtractContentType = (pair) => {
}
};
const fileExtractContentType = (pair) => {
if (_.isString(pair.value)) {
const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/);
if (match && match.length > 2) {
pair.value = match[1].trim();
pair.contentType = match[2].trim();
} else {
pair.contentType = '';
}
}
};
const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
@@ -190,6 +204,27 @@ const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) =
});
};
const mapPairListToKeyValPairsFile = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
return pairs.map((pair) => {
fileExtractContentType(pair);
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
let filePath = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
pair.filePath = filePath;
pair.selected = pair.enabled
// Remove pair.value as it only contains the file path reference
delete pair.value;
// Remove pair.name as it is auto-generated (e.g., file1, file2, file3, etc.)
delete pair.name;
delete pair.enabled;
}
return pair;
});
};
const concatArrays = (objValue, srcValue) => {
if (_.isArray(objValue) && _.isArray(srcValue)) {
return objValue.concat(srcValue);
@@ -574,6 +609,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
bodyfile(_1, dictionary) {
return {
body: {
file: mapPairListToKeyValPairsFile(dictionary.ast)
}
};
},
body(_1, _2, _3, _4, textblock, _5) {
return {
http: {
@@ -708,3 +750,4 @@ const parser = (input) => {
};
module.exports = parser;

View File

@@ -2,8 +2,8 @@ const _ = require('lodash');
const { indentString } = require('../../v1/src/utils');
const enabled = (items = []) => items.filter((item) => item.enabled);
const disabled = (items = []) => items.filter((item) => !item.enabled);
const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]);
const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]);
// remove the last line if two new lines are found
const stripLastLine = (text) => {
@@ -313,6 +313,30 @@ ${indentString(body.sparql)}
bru += '\n}\n\n';
}
if (body && body.file && body.file.length) {
bru += `body:file {`;
const files = enabled(body.file, "selected").concat(disabled(body.file, "selected"));
if (files.length) {
bru += `\n${indentString(
files
.map((item) => {
const selected = item.selected ? '' : '~';
const contentType =
item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
const filePath = item.filePath || '';
const value = `@file(${filePath})`;
const itemName = "file";
return `${selected}${itemName}: ${value}${contentType}`;
})
.join('\n')
)}`;
}
bru += '\n}\n\n';
}
if (body && body.graphql && body.graphql.query) {
bru += `body:graphql {\n`;
bru += `${indentString(body.graphql.query)}`;

View File

@@ -102,6 +102,12 @@ body:multipart-form {
~message: hello
}
body:file {
file: @file(path/to/file.json) @contentType(application/json)
file: @file(path/to/file.json) @contentType(application/json)
~file: @file(path/to/file2.json) @contentType(application/json)
}
body:graphql {
{
launchesPast {

View File

@@ -137,6 +137,23 @@
"enabled": false,
"type": "text"
}
],
"file" : [
{
"filePath": "path/to/file.json",
"contentType": "application/json",
"selected": true
},
{
"filePath": "path/to/file.json",
"contentType": "application/json",
"selected": true
},
{
"filePath": "path/to/file2.json",
"contentType": "application/json",
"selected": false
}
]
},
"vars": {

View File

@@ -74,9 +74,19 @@ const multipartFormSchema = Yup.object({
.noUnknown(true)
.strict();
const fileSchema = Yup.object({
uid: uidSchema,
filePath: Yup.string().nullable(),
contentType: Yup.string().nullable(),
selected: Yup.boolean()
})
.noUnknown(true)
.strict();
const requestBodySchema = Yup.object({
mode: Yup.string()
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql'])
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'file'])
.required('mode is required'),
json: Yup.string().nullable(),
text: Yup.string().nullable(),
@@ -84,7 +94,8 @@ const requestBodySchema = Yup.object({
sparql: Yup.string().nullable(),
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
graphql: graphqlBodySchema.nullable()
graphql: graphqlBodySchema.nullable(),
file: Yup.array().of(fileSchema).nullable()
})
.noUnknown(true)
.strict();

View File

@@ -0,0 +1,15 @@
meta {
name: echo binary
type: http
seq: 1
}
post {
url: {{echo-host}}
body: file
auth: none
}
body:file {
file: @file(bruno.png) @contentType(image/png)
}

View File

@@ -0,0 +1,3 @@
file.txt
hello, bruno

View File

@@ -19,6 +19,17 @@ router.post('/xml-raw', (req, res) => {
return res.send(req.rawBody);
});
router.post('/bin', (req, res) => {
const rawBody = req.body;
if (!rawBody || rawBody.length === 0) {
return res.status(400).send('No data received');
}
res.set('Content-Type', req.headers['content-type'] || 'application/octet-stream');
res.send(rawBody);
});
router.get('/bom-json-test', (req, res) => {
const jsonData = {
message: 'Hello!',

View File

@@ -10,6 +10,7 @@ const multipartRouter = require('./multipart');
const app = new express();
const port = process.env.PORT || 8080;
app.use(express.raw({type: '*/*', limit: '100mb'}));
app.use(cors());
app.use(xmlParser());
app.use(bodyParser.text());