From 4664fd60b5492098d90f50d01d29b7d552587958 Mon Sep 17 00:00:00 2001 From: Pragadesh-45 Date: Thu, 19 Jun 2025 20:01:36 +0545 Subject: [PATCH 01/22] fix: update qs.stringify to use repeat array format for url serialization --- packages/bruno-electron/src/ipc/network/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index f63926991..e3b9be48a 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -454,7 +454,7 @@ const registerNetworkIpc = (mainWindow) => { // stringify the request url encoded params if (request.headers['content-type'] === 'application/x-www-form-urlencoded') { - request.data = qs.stringify(request.data); + request.data = qs.stringify(request.data, { arrayFormat: "repeat" }); } if (request.headers['content-type'] === 'multipart/form-data') { From 0d13d40cd74d7a01e669b14db89837f2f634d45a Mon Sep 17 00:00:00 2001 From: Pragadesh-45 Date: Thu, 19 Jun 2025 20:02:25 +0545 Subject: [PATCH 02/22] fix(cli): update qs.stringify to use repeat array format for url serialization --- packages/bruno-cli/src/runner/run-single-request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 6fd575f90..e632edf20 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -330,7 +330,7 @@ const runSingleRequest = async function ( // stringify the request url encoded params if (request.headers['content-type'] === 'application/x-www-form-urlencoded') { - request.data = qs.stringify(request.data); + request.data = qs.stringify(request.data, { arrayFormat: "repeat" }); } if (request?.headers?.['content-type'] === 'multipart/form-data') { From f1116c30089398b18542a936e7e7381e3b2f19f6 Mon Sep 17 00:00:00 2001 From: Anoop M D Date: Sun, 22 Jun 2025 19:12:33 +0530 Subject: [PATCH 03/22] feat: implement vertical layout for response pane and enhance drag --- packages/bruno-app/jsconfig.json | 1 + .../Preferences/Display/Font/index.js | 4 + .../components/Preferences/General/index.js | 4 +- .../Preferences/ProxySettings/index.js | 3 + .../RequestPane/GraphQLRequestPane/index.js | 8 +- .../RequestPane/HttpRequestPane/index.js | 7 +- .../RequestPane/QueryParams/index.js | 2 +- .../RequestTabPanel/StyledWrapper.js | 31 +++- .../src/components/RequestTabPanel/index.js | 107 +++++++---- .../ResponsePane/Overlay/StyledWrapper.js | 9 + .../components/ResponsePane/Overlay/index.js | 6 +- .../ResponsePane/Placeholder/StyledWrapper.js | 7 + .../ResponsePane/Placeholder/index.js | 5 +- .../ResponsePane/QueryResult/index.js | 3 +- .../ResponseLayoutToggle/StyledWrapper.js | 15 ++ .../ResponseLayoutToggle/index.js | 84 +++++++++ .../ResponseLayoutToggle/index.spec.js | 173 ++++++++++++++++++ .../src/components/ResponsePane/index.js | 13 +- .../src/providers/ReduxStore/slices/app.js | 8 +- .../bruno-app/src/providers/Theme/index.js | 1 + .../ui/HeightBoundContainer/StyledWrapper.js | 25 +++ .../src/ui/HeightBoundContainer/index.js | 16 ++ .../bruno-electron/src/store/preferences.js | 9 + 23 files changed, 477 insertions(+), 64 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js create mode 100644 packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js create mode 100644 packages/bruno-app/src/ui/HeightBoundContainer/index.js diff --git a/packages/bruno-app/jsconfig.json b/packages/bruno-app/jsconfig.json index 867626852..a71bc3138 100644 --- a/packages/bruno-app/jsconfig.json +++ b/packages/bruno-app/jsconfig.json @@ -6,6 +6,7 @@ "baseUrl": "./", "paths": { "assets/*": ["src/assets/*"], + "ui/*": ["src/ui/*"], "components/*": ["src/components/*"], "hooks/*": ["src/hooks/*"], "themes/*": ["src/themes/*"], diff --git a/packages/bruno-app/src/components/Preferences/Display/Font/index.js b/packages/bruno-app/src/components/Preferences/Display/Font/index.js index 622ea0817..e6bbf9c3f 100644 --- a/packages/bruno-app/src/components/Preferences/Display/Font/index.js +++ b/packages/bruno-app/src/components/Preferences/Display/Font/index.js @@ -3,6 +3,7 @@ import get from 'lodash/get'; import { useSelector, useDispatch } from 'react-redux'; import { savePreferences } from 'providers/ReduxStore/slices/app'; import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; const Font = ({ close }) => { const dispatch = useDispatch(); @@ -31,7 +32,10 @@ const Font = ({ close }) => { } }) ).then(() => { + toast.success('Preferences saved successfully') close(); + }).catch(() => { + toast.error('Failed to save preferences') }); }; diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js index 929eae0ff..554dd0d72 100644 --- a/packages/bruno-app/src/components/Preferences/General/index.js +++ b/packages/bruno-app/src/components/Preferences/General/index.js @@ -80,9 +80,9 @@ const General = ({ close }) => { storeCookies: newPreferences.storeCookies, sendCookies: newPreferences.sendCookies } - }) - ) + })) .then(() => { + toast.success('Preferences saved successfully') close(); }) .catch((err) => console.log(err) && toast.error('Failed to update preferences')); diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js index e7ac735c7..16695f60f 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js @@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => { proxy: validatedProxy }) ).then(() => { + toast.success('Preferences saved successfully') close(); + }).catch(() => { + toast.error('Failed to save preferences') }); }) .catch((error) => { diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index 07dcf1419..34558d928 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -18,8 +18,9 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection import StyledWrapper from './StyledWrapper'; import Documentation from 'components/Documentation/index'; import GraphQLSchemaActions from '../GraphQLSchemaActions/index'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; -const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { +const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -66,7 +67,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog collection={collection} theme={displayedTheme} schema={schema} - width={leftPaneWidth} onSave={onSave} value={query} onRun={onRun} @@ -154,7 +154,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog -
{getTabPanel(focusedTab.requestPaneTab)}
+
+ {getTabPanel(focusedTab.requestPaneTab)} +
); }; diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 4c7e6029b..126613504 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -15,6 +15,7 @@ import Tests from 'components/RequestPane/Tests'; import StyledWrapper from './StyledWrapper'; import { find, get } from 'lodash'; import Documentation from 'components/Documentation/index'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; import { useEffect } from 'react'; const ContentIndicator = () => { @@ -33,7 +34,7 @@ const ErrorIndicator = () => { ); }; -const HttpRequestPane = ({ item, collection, leftPaneWidth }) => { +const HttpRequestPane = ({ item, collection }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -180,7 +181,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => { 'mt-5': !isMultipleContentTab })} > - {getTabPanel(focusedTab.requestPaneTab)} + + {getTabPanel(focusedTab.requestPaneTab)} + ); diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 3f7f7ef01..8fe1cd00b 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -114,7 +114,7 @@ const QueryParams = ({ item, collection }) => { }; return ( - +
Query
props.theme.requestTabPanel.dragbar.border}; } - &:hover div.drag-request-border { + &:hover div.dragbar-handle { border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; } } + &.vertical-layout { + div.dragbar-wrapper { + width: 100%; + height: 10px; + cursor: row-resize; + + div.dragbar-handle { + width: 100%; + height: 1px; + border-left: none; + border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + margin-top: 0.5rem; + } + + &:hover div.dragbar-handle { + border-left: none; + border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; + } + } + } + div.graphql-docs-explorer-container { background: white; outline: none; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 5f53a5e02..19e52ae0f 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -29,7 +29,8 @@ import FolderNotFound from './FolderNotFound'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 350; -const DEFAULT_PADDING = 5; +const MIN_TOP_PANE_HEIGHT = 150; +const MIN_BOTTOM_PANE_HEIGHT = 150; const RequestTabPanel = () => { if (typeof window == 'undefined') { @@ -41,6 +42,8 @@ const RequestTabPanel = () => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const _collections = useSelector((state) => state.collections.collections); + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; // merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object let collections = produce(_collections, (draft) => { @@ -64,13 +67,15 @@ const RequestTabPanel = () => { let asideWidth = useSelector((state) => state.app.leftSidebarWidth); const [leftPaneWidth, setLeftPaneWidth] = useState( focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2 - ); // 2.2 so that request pane is relatively smaller - const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING); + ); // 2.2 is intentional to make both panes appear to be of equal width + const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT); const [dragging, setDragging] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); // Not a recommended pattern here to have the child component // make a callback to set state, but treating this as an exception const docExplorerRef = useRef(null); + const mainSectionRef = useRef(null); const [schema, setSchema] = useState(null); const [showGqlDocs, setShowGqlDocs] = useState(false); const onSchemaLoad = (schema) => setSchema(schema); @@ -85,43 +90,72 @@ const RequestTabPanel = () => { }; useEffect(() => { - const leftPaneWidth = (screenWidth - asideWidth) / 2.2; - setLeftPaneWidth(leftPaneWidth); - }, [screenWidth]); - - useEffect(() => { - setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING); - }, [screenWidth, asideWidth, leftPaneWidth]); + // Initialize vertical heights when switching to vertical layout + if (mainSectionRef.current) { + const mainRect = mainSectionRef.current.getBoundingClientRect(); + if (isVerticalLayout) { + const initialHeight = mainRect.height / 2; + setTopPaneHeight(initialHeight); + // In vertical mode, set leftPaneWidth to full container width + setLeftPaneWidth(mainRect.width); + } else { + // In horizontal mode, set to roughly half width + setLeftPaneWidth((screenWidth - asideWidth) / 2.2); + } + } + }, [isVerticalLayout, screenWidth, asideWidth]); const handleMouseMove = (e) => { - if (dragging) { + if (dragging && mainSectionRef.current) { e.preventDefault(); - let leftPaneXPosition = e.clientX + 2; - if ( - leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH || - leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH - ) { - return; + const mainRect = mainSectionRef.current.getBoundingClientRect(); + + if (isVerticalLayout) { + const newHeight = e.clientY - mainRect.top - dragOffset.current.y; + if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) { + return; + } + + setTopPaneHeight(newHeight); + } else { + const newWidth = e.clientX - mainRect.left - dragOffset.current.x; + if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) { + return; + } + setLeftPaneWidth(newWidth); } - setLeftPaneWidth(leftPaneXPosition - asideWidth); - setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING); } }; + const handleMouseUp = (e) => { - if (dragging) { + if (dragging && mainSectionRef.current) { e.preventDefault(); setDragging(false); - dispatch( - updateRequestPaneTabWidth({ - uid: activeTabUid, - requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING - }) - ); + if (!isVerticalLayout) { + const mainRect = mainSectionRef.current.getBoundingClientRect(); + dispatch( + updateRequestPaneTabWidth({ + uid: activeTabUid, + requestPaneWidth: e.clientX - mainRect.left + }) + ); + } } }; + const handleDragbarMouseDown = (e) => { e.preventDefault(); setDragging(true); + + if (isVerticalLayout) { + const dragBar = e.currentTarget; + const dragBarRect = dragBar.getBoundingClientRect(); + dragOffset.current.y = e.clientY - dragBarRect.top; + } else { + const dragBar = e.currentTarget; + const dragBarRect = dragBar.getBoundingClientRect(); + dragOffset.current.x = e.clientX - dragBarRect.left; + } }; useEffect(() => { @@ -132,7 +166,7 @@ const RequestTabPanel = () => { document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousemove', handleMouseMove); }; - }, [dragging, asideWidth]); + }, [dragging]); if (!activeTabUid) { return ; @@ -197,15 +231,19 @@ const RequestTabPanel = () => { }; return ( - +
-
+
@@ -213,7 +251,6 @@ const RequestTabPanel = () => { { ) : null} {item.type === 'http-request' ? ( - + ) : null}
-
-
+
+
- +
diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js index 045a9dcc3..e60eb7024 100644 --- a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js @@ -22,6 +22,15 @@ const StyledWrapper = styled.div` animation: rotateCounterClockwise 1s linear infinite; } } + + // spinner and request time content looks better centered vertically in vertical layout + // while in horizontal layout, it looks better when the content is aligned to the top + &.vertical-layout { + div.overlay { + justify-content: center; + padding: 1rem; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js index 8ede2d6ec..429c4889a 100644 --- a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js @@ -1,19 +1,21 @@ import React from 'react'; import { IconRefresh } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions'; import StopWatch from '../../StopWatch'; import StyledWrapper from './StyledWrapper'; const ResponseLoadingOverlay = ({ item, collection }) => { const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const handleCancelRequest = () => { dispatch(cancelRequest(item.cancelTokenUid, item, collection)); }; return ( - +
diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js index f6d7a09c5..369637b01 100644 --- a/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js @@ -1,12 +1,19 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + display: flex; + flex-direction: column; padding-top: 20%; width: 100%; .send-icon { color: ${(props) => props.theme.requestTabPanel.responseSendIcon}; } + + &.vertical-layout { + padding: 1rem; + justify-content: center; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js index bca9e138a..4a315ca26 100644 --- a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js @@ -1,5 +1,6 @@ import React from 'react'; import { IconSend } from '@tabler/icons'; +import { useSelector } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import { isMacOS } from 'utils/common/platform'; @@ -8,9 +9,11 @@ const Placeholder = () => { const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter'; const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B'; const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E'; + const preferences = useSelector((state) => state.app.preferences); + const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; return ( - +
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 229a40072..31f1759cb 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -74,7 +74,7 @@ const formatErrorMessage = (error) => { return error; }; -const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => { +const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => { const contentType = getContentType(headers); const mode = getCodeMirrorModeBasedOnContentType(contentType, data); const [filter, setFilter] = useState(null); @@ -164,7 +164,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven return (
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js new file mode 100644 index 000000000..8cb5d4b43 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + button { + display: flex; + align-items: center; + padding: 0.25rem; + background: transparent; + border: none; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + } +`; + +export default Wrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js new file mode 100644 index 000000000..49299422b --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from './StyledWrapper'; + +const IconDockToBottom = () => { + return ( + + + + + + + ); +}; + +const IconDockToRight = () => { + return ( + + + + + + + ); +}; + +const ResponseLayoutToggle = () => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal'; + + const toggleOrientation = () => { + const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal'; + const updatedPreferences = { + ...preferences, + layout: { + ...preferences.layout, + responsePaneOrientation: newOrientation + } + }; + dispatch(savePreferences(updatedPreferences)); + }; + + return ( + + + + ); +}; + +export default ResponseLayoutToggle; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js new file mode 100644 index 000000000..0dd1c7b1a --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js @@ -0,0 +1,173 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { ThemeProvider } from 'providers/Theme'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import ResponseLayoutToggle from './index'; + +const mockSavePreferences = jest.fn((payload) => ({ type: 'app/savePreferences', payload })); + +// Mock the savePreferences action +jest.mock('providers/ReduxStore/slices/app', () => ({ + savePreferences: (payload) => mockSavePreferences(payload) +})); + +// Mock localStorage +const mockLocalStorage = { + getItem: jest.fn(() => 'dark'), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +// Mock matchMedia +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn() + })), + }); + Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage + }); +}); + +beforeEach(() => { + mockSavePreferences.mockClear(); +}); + +const initialState = { + app: { + preferences: { + layout: { + responsePaneOrientation: 'horizontal' + } + } + } +}; + +const createTestStore = (initialState) => { + const appSlice = createSlice({ + name: 'app', + initialState: initialState.app, + reducers: { + savePreferences: (state, action) => { + state.preferences = action.payload; + } + } + }); + + return configureStore({ + reducer: { app: appSlice.reducer } + }); +}; + +const renderWithProviders = (component, customState = initialState) => { + const store = createTestStore(customState); + return { + store, + ...render( + + + {component} + + + ) + }; +}; + +describe('ResponseLayoutToggle', () => { + describe('Initial Render', () => { + it('should render with horizontal orientation by default', () => { + renderWithProviders(); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Switch to vertical layout'); + }); + + it('should render with vertical orientation when specified', () => { + const customState = { + app: { + preferences: { + layout: { + responsePaneOrientation: 'vertical' + } + } + } + }; + renderWithProviders(, customState); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); + }); + }); + + describe('Interaction', () => { + it('should switch to vertical layout when clicked in horizontal mode', () => { + const { store } = renderWithProviders(); + const button = screen.getByRole('button'); + + // Initial state check + expect(button).toHaveAttribute('title', 'Switch to vertical layout'); + + fireEvent.click(button); + + // Check if action was called + expect(mockSavePreferences).toHaveBeenCalledWith({ + layout: { + responsePaneOrientation: 'vertical' + } + }); + + // Manually update store to simulate state change + store.dispatch(mockSavePreferences({ + layout: { + responsePaneOrientation: 'vertical' + } + })); + + // Check if button title was updated + expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); + }); + + it('should switch to horizontal layout when clicked in vertical mode', () => { + const customState = { + app: { + preferences: { + layout: { + responsePaneOrientation: 'vertical' + } + } + } + }; + const { store } = renderWithProviders(, customState); + const button = screen.getByRole('button'); + + // Initial state check + expect(button).toHaveAttribute('title', 'Switch to horizontal layout'); + + fireEvent.click(button); + + // Check if action was called + expect(mockSavePreferences).toHaveBeenCalledWith({ + layout: { + responsePaneOrientation: 'horizontal' + } + }); + + // Manually update store to simulate state change + store.dispatch(mockSavePreferences({ + layout: { + responsePaneOrientation: 'horizontal' + } + })); + + // Check if button title was updated + expect(button).toHaveAttribute('title', 'Switch to vertical layout'); + }); + }); +}); diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index 205a89c56..5d85ce086 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -20,8 +20,10 @@ import ResponseSave from 'src/components/ResponsePane/ResponseSave'; import ResponseClear from 'src/components/ResponsePane/ResponseClear'; import SkippedRequest from './SkippedRequest'; import ClearTimeline from './ClearTimeline/index'; +import ResponseLayoutToggle from './ResponseLayoutToggle'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; -const ResponsePane = ({ rightPaneWidth, item, collection }) => { +const ResponsePane = ({ item, collection }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -57,7 +59,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { { return ; } case 'timeline': { - return ; + return ; } case 'tests': { return { if (!item.response && !requestTimeline?.length) { return ( - + - + ); } @@ -159,6 +160,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { onClick={() => setShowScriptErrorCard(true)} /> )} + {focusedTab?.responsePaneTab === "timeline" ? ( ) : (item?.response && !item?.response?.error) ? ( @@ -193,7 +195,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { ) : null ) : ( diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index f19c51101..0fde3c8b2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -1,6 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; import filter from 'lodash/filter'; -import toast from 'react-hot-toast'; const initialState = { isDragging: false, @@ -103,14 +102,9 @@ export const savePreferences = (preferences) => (dispatch, getState) => { ipcRenderer .invoke('renderer:save-preferences', preferences) - .then(() => toast.success('Preferences saved successfully')) .then(() => dispatch(updatePreferences(preferences))) .then(resolve) - .catch((err) => { - toast.error('An error occurred while saving preferences'); - console.error(err); - reject(err); - }); + .catch(reject); }); }; diff --git a/packages/bruno-app/src/providers/Theme/index.js b/packages/bruno-app/src/providers/Theme/index.js index 44025197a..9b741872b 100644 --- a/packages/bruno-app/src/providers/Theme/index.js +++ b/packages/bruno-app/src/providers/Theme/index.js @@ -1,3 +1,4 @@ +import React from 'react'; import themes from 'themes/index'; import useLocalStorage from 'hooks/useLocalStorage/index'; diff --git a/packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js b/packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js new file mode 100644 index 000000000..8770381e7 --- /dev/null +++ b/packages/bruno-app/src/ui/HeightBoundContainer/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + /* Primary container - establishes flex context */ + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + /* Flex shrink container - allows content to be constrained */ + .height-constraint { + display: flex; + flex: 1 1 0; + min-height: 0; + } + + /* Grid container - enforces boundaries */ + .grid-boundary { + width: 100%; + display: grid; + overflow-y: auto; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/ui/HeightBoundContainer/index.js b/packages/bruno-app/src/ui/HeightBoundContainer/index.js new file mode 100644 index 000000000..be7b2727a --- /dev/null +++ b/packages/bruno-app/src/ui/HeightBoundContainer/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const HeightBoundContainer = ({children}) => { + return ( + +
+
+ {children} +
+
+
+ ); +}; + +export default HeightBoundContainer; diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 33d7a02f8..f5dac56d5 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -37,6 +37,9 @@ const defaultPreferences = { password: '' }, bypassProxy: '' + }, + layout: { + responsePaneOrientation: 'horizontal' } }; @@ -69,6 +72,9 @@ const preferencesSchema = Yup.object().shape({ password: Yup.string().max(1024) }).optional(), bypassProxy: Yup.string().optional().max(1024) + }), + layout: Yup.object({ + responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical']) }) }); @@ -149,6 +155,9 @@ const preferencesUtil = { shouldSendCookies: () => { return get(getPreferences(), 'request.sendCookies', true); }, + getResponsePaneOrientation: () => { + return get(getPreferences(), 'layout.responsePaneOrientation', 'horizontal'); + }, getSystemProxyEnvVariables: () => { const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env; return { From 04d0439c9d6383bc9e73bc95823b191b646087ab Mon Sep 17 00:00:00 2001 From: Pragadesh-45 Date: Thu, 19 Jun 2025 20:34:43 +0545 Subject: [PATCH 04/22] feat(tests): add URL serialization test case for Duplicate Keys --- .../url-serialization/Duplicate Keys.bru | 27 +++++++++++++++++++ .../collection/url-serialization/folder.bru | 8 ++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/bruno-tests/collection/url-serialization/Duplicate Keys.bru create mode 100644 packages/bruno-tests/collection/url-serialization/folder.bru diff --git a/packages/bruno-tests/collection/url-serialization/Duplicate Keys.bru b/packages/bruno-tests/collection/url-serialization/Duplicate Keys.bru new file mode 100644 index 000000000..28f3030f4 --- /dev/null +++ b/packages/bruno-tests/collection/url-serialization/Duplicate Keys.bru @@ -0,0 +1,27 @@ +meta { + name: Duplicate Keys + type: http + seq: 1 +} + +post { + url: https://echo.usebruno.com + body: formUrlEncoded + auth: none +} + +headers { + Content-Type: application/x-www-form-urlencoded +} + +body:form-urlencoded { + tags: frontend + tags: api + user: john +} + +script:post-response { + test('Response body matches expected value', function () { + expect(res.getBody()).to.eql("tags=frontend&tags=api&user=john"); + }); +} diff --git a/packages/bruno-tests/collection/url-serialization/folder.bru b/packages/bruno-tests/collection/url-serialization/folder.bru new file mode 100644 index 000000000..0644be667 --- /dev/null +++ b/packages/bruno-tests/collection/url-serialization/folder.bru @@ -0,0 +1,8 @@ +meta { + name: url-serialization + seq: 13 +} + +auth { + mode: inherit +} From cf5f52b7b9dae983156d91ad5459d67b7307c1ed Mon Sep 17 00:00:00 2001 From: Pragadesh-45 Date: Mon, 23 Jun 2025 18:49:29 +0545 Subject: [PATCH 05/22] feat(cli): refactor formUrlEncoded handling to use buildFormUrlEncodedPayload function --- .../bruno-cli/src/runner/prepare-request.js | 12 +++++------ .../src/runner/run-single-request.js | 7 +++++-- packages/bruno-cli/src/utils/form-data.js | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 9c2493a35..1885ef2b2 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -2,7 +2,7 @@ const { get, each, filter } = require('lodash'); const decomment = require('decomment'); const crypto = require('node:crypto'); const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection'); -const { createFormData } = require('../utils/form-data'); +const { buildFormUrlEncodedPayload } = require('../utils/form-data'); const prepareRequest = (item = {}, collection = {}) => { const request = item?.request; @@ -288,13 +288,13 @@ const prepareRequest = (item = {}, collection = {}) => { } if (request.body.mode === 'formUrlEncoded') { - axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; - const params = {}; + if (!contentTypeDefined) { + axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; + } const enabledParams = filter(request.body.formUrlEncoded, (p) => p.enabled); - each(enabledParams, (p) => (params[p.name] = p.value)); - axiosRequest.data = params; + axiosRequest.data = buildFormUrlEncodedPayload(enabledParams); } - + if (request.body.mode === 'multipartForm') { axiosRequest.headers['content-type'] = 'multipart/form-data'; const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index e632edf20..1019c4072 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -329,11 +329,14 @@ const runSingleRequest = async function ( } // stringify the request url encoded params - if (request.headers['content-type'] === 'application/x-www-form-urlencoded') { + const contentTypeHeader = Object.keys(request.headers).find( + name => name.toLowerCase() === 'content-type' + ); + if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') { request.data = qs.stringify(request.data, { arrayFormat: "repeat" }); } - if (request?.headers?.['content-type'] === 'multipart/form-data') { + if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') { if (!(request?.data instanceof FormData)) { let form = createFormData(request.data, collectionPath); request.data = form; diff --git a/packages/bruno-cli/src/utils/form-data.js b/packages/bruno-cli/src/utils/form-data.js index eab5d5824..7bb00ba81 100644 --- a/packages/bruno-cli/src/utils/form-data.js +++ b/packages/bruno-cli/src/utils/form-data.js @@ -3,6 +3,25 @@ const FormData = require('form-data'); const fs = require('fs'); const path = require('path'); +/** + * @param {Array.} params The request body Array + * @returns {object} Returns an obj with repeating key as an array of values + * {item: 2, item: 3, item1: 4} becomes {item: [2,3], item1: 4} + */ +const buildFormUrlEncodedPayload = (params) => { + return params.reduce((acc, p) => { + if (!acc[p.name]) { + acc[p.name] = p.value; + } else if (Array.isArray(acc[p.name])) { + acc[p.name].push(p.value); + } else { + acc[p.name] = [acc[p.name], p.value]; + } + return acc; + }, {}); +}; + + const createFormData = (data, collectionPath) => { // make axios work in node using form data // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427 @@ -38,5 +57,6 @@ const createFormData = (data, collectionPath) => { }; module.exports = { + buildFormUrlEncodedPayload, createFormData } \ No newline at end of file From 3c65642e92ea0b974f4bf10a9c3ed715e9391e16 Mon Sep 17 00:00:00 2001 From: Maintainer Bruno Date: Tue, 24 Jun 2025 02:31:49 +0530 Subject: [PATCH 06/22] fix(import): curl parser library --- package-lock.json | 14 +- packages/bruno-app/package.json | 6 +- packages/bruno-app/src/utils/common/index.js | 8 +- .../bruno-app/src/utils/curl/curl-to-json.js | 18 +- .../src/utils/curl/curl-to-json.spec.js | 2 +- packages/bruno-app/src/utils/curl/index.js | 8 +- .../bruno-app/src/utils/curl/parse-curl.js | 751 ++++++++++------ .../src/utils/curl/parse-curl.spec.js | 814 +++++++++++++++--- 8 files changed, 1233 insertions(+), 388 deletions(-) diff --git a/package-lock.json b/package-lock.json index 896e23a70..86631f441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28029,12 +28029,12 @@ "react-tooltip": "^5.5.2", "sass": "^1.46.0", "semver": "^7.7.1", + "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.1", "styled-components": "^5.3.3", "system": "^2.0.1", "url": "^0.11.3", "xml-formatter": "^3.5.0", - "yargs-parser": "^21.1.1", "yup": "^0.32.11" }, "devDependencies": { @@ -29667,6 +29667,18 @@ "node": ">=10" } }, + "packages/bruno-app/node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/bruno-app/node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 913e4b400..ab30dfef0 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -73,12 +73,12 @@ "react-tooltip": "^5.5.2", "sass": "^1.46.0", "semver": "^7.7.1", + "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.1", "styled-components": "^5.3.3", "system": "^2.0.1", "url": "^0.11.3", "xml-formatter": "^3.5.0", - "yargs-parser": "^21.1.1", "yup": "^0.32.11" }, "devDependencies": { @@ -91,9 +91,9 @@ "@rsbuild/plugin-react": "^1.0.7", "@rsbuild/plugin-sass": "^1.1.0", "@rsbuild/plugin-styled-components": "1.1.0", + "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", - "@testing-library/dom": "^10.4.0", "autoprefixer": "10.4.20", "babel-jest": "^29.7.0", "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", @@ -111,4 +111,4 @@ "webpack": "^5.64.4", "webpack-cli": "^4.9.1" } -} \ No newline at end of file +} diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index f839ba850..f6621621f 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -1,5 +1,6 @@ import { customAlphabet } from 'nanoid'; import xmlFormat from 'xml-formatter'; +import { format, applyEdits } from 'jsonc-parser'; // a customized version of nanoid without using _ and - export const uuid = () => { @@ -51,9 +52,12 @@ export const safeStringifyJSON = (obj, indent = false) => { } }; -export const convertToCodeMirrorJson = (obj) => { +export const prettifyJSON = (obj, spaces = 2) => { try { - return JSON.stringify(obj, null, 2).slice(1, -1); + const formatted = obj.replace(/\\"/g, '"').replace(/\\'/g, "'"); + const edits = format(formatted, undefined, { tabSize: spaces, insertSpaces: true }); + + return applyEdits(formatted, edits); } catch (e) { return obj; } diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js index a6239519e..72aa22812 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.js @@ -49,15 +49,7 @@ function getDataString(request) { const contentType = getContentType(request.headers); - if (contentType && contentType.includes('application/json')) { - try { - const parsedData = JSON.parse(request.data); - return { data: JSON.stringify(parsedData) }; - } catch (error) { - console.error('Failed to parse JSON data:', error); - return { data: request.data.toString() }; - } - } else if (contentType && (contentType.includes('application/xml') || contentType.includes('text/plain'))) { + if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) { return { data: request.data }; } @@ -182,8 +174,12 @@ const curlToJson = (curlCommand) => { } if (request.query) { - requestJson.queries = getQueries(request); - } else if (request.multipartUploads) { + const queries = getQueries(request); + // append query to requestJson.url + requestJson.url = requestJson.url + '?' + querystring.stringify(queries); + } + + if (request.multipartUploads) { requestJson.data = request.multipartUploads; if (!requestJson.headers) { requestJson.headers = {}; diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js index 991150c57..058064391 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js @@ -62,7 +62,7 @@ describe('curlToJson', () => { it('should accept escaped curl string', () => { const curlCommand = `curl https://www.usebruno.com - -H $'cookie: val_1=\'\'; val_2=\\^373:0\\^373:0; val_3=\u0068\u0065\u006C\u006C\u006F' + -H $'cookie: val_1=\\'\\'; val_2=\\^373:0\\^373:0; val_3=\u0068\u0065\u006C\u006C\u006F' `; const result = curlToJson(curlCommand); diff --git a/packages/bruno-app/src/utils/curl/index.js b/packages/bruno-app/src/utils/curl/index.js index ad4f1edf6..9a986b4de 100644 --- a/packages/bruno-app/src/utils/curl/index.js +++ b/packages/bruno-app/src/utils/curl/index.js @@ -1,5 +1,5 @@ import { forOwn } from 'lodash'; -import { convertToCodeMirrorJson } from 'utils/common'; +import { prettifyJSON } from 'utils/common'; import curlToJson from './curl-to-json'; export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => { @@ -63,7 +63,7 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque body.file = parsedBody; }else if (contentType.includes('application/json')) { body.mode = 'json'; - body.json = convertToCodeMirrorJson(parsedBody); + body.json = prettifyJSON(parsedBody); } else if (contentType.includes('xml')) { body.mode = 'xml'; body.xml = parsedBody; @@ -77,7 +77,11 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque body.mode = 'text'; body.text = parsedBody; } + } else if (parsedBody) { + body.mode = 'formUrlEncoded'; + body.formUrlEncoded = parseFormData(parsedBody); } + return { url: request.url, method: request.method, diff --git a/packages/bruno-app/src/utils/curl/parse-curl.js b/packages/bruno-app/src/utils/curl/parse-curl.js index afdc10395..3a9f82df6 100644 --- a/packages/bruno-app/src/utils/curl/parse-curl.js +++ b/packages/bruno-app/src/utils/curl/parse-curl.js @@ -1,280 +1,499 @@ +import cookie from 'cookie'; +import URL from 'url'; +import querystring from 'query-string'; +import { parse } from 'shell-quote'; +import { isEmpty } from 'lodash'; + /** - * Copyright (c) 2014-2016 Nick Carneiro - * https://github.com/curlconverter/curlconverter - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. + * Flag definitions - maps flag names to their states and actions + * State-returning flags expect a value, immediate action flags don't */ +const FLAG_CATEGORIES = { + // State-returning flags (expect a value after the flag) + 'user-agent': ['-A', '--user-agent'], + 'header': ['-H', '--header'], + 'data': ['-d', '--data', '--data-ascii', '--data-urlencode'], + 'json': ['--json'], + 'user': ['-u', '--user'], + 'method': ['-X', '--request'], + 'cookie': ['-b', '--cookie'], + 'form': ['-F', '--form'], + // Special data flags with properties + 'data-raw': ['--data-raw'], + 'data-binary': ['--data-binary'], -import * as cookie from 'cookie'; -import * as URL from 'url'; -import * as querystring from 'query-string'; -import yargs from 'yargs-parser'; + // Immediate action flags (no value expected) + 'head': ['-I', '--head'], + 'compressed': ['--compressed'], + 'insecure': ['-k', '--insecure'], + /** + * Query flags: mark data for conversion to query parameters. + * While this is an immediate action flag, the actual conversion to a query string occurs later during post-build request processing. + * Due to the unpredictable order of flags, query string construction is deferred to the end. + */ + 'query': ['-G', '--get'] +}; -const parseCurlCommand = (curlCommand) => { - // catch escape sequences (e.g. -H $'cookie: it=\'\'') - curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group); +/** + * Parse a curl command into a request object + * + * @TODO + * - Handle T (file upload) + */ +const parseCurlCommand = (curl) => { + const cleanedCommand = cleanCurlCommand(curl); + const parsedArgs = parse(cleanedCommand); + const request = buildRequest(parsedArgs); - // Remove newlines (and from continuations) - curlCommand = curlCommand.replace(/\\\r|\\\n/g, ''); + return cleanRequest(postBuildProcessRequest(request)); +}; - // Remove extra whitespace - curlCommand = curlCommand.replace(/\s+/g, ' '); +/** + * Build request object by processing parsed arguments + * Uses a state machine pattern to handle flag-value pairs + */ +const buildRequest = (parsedArgs) => { + const request = { headers: {} }; + let currentState = null; - // yargs parses -XPOST as separate arguments. just prescreen for it. - curlCommand = curlCommand.replace(/ -XPOST/, ' -X POST'); - curlCommand = curlCommand.replace(/ -XGET/, ' -X GET'); - curlCommand = curlCommand.replace(/ -XPUT/, ' -X PUT'); - curlCommand = curlCommand.replace(/ -XPATCH/, ' -X PATCH'); - curlCommand = curlCommand.replace(/ -XDELETE/, ' -X DELETE'); - curlCommand = curlCommand.replace(/ -XOPTIONS/, ' -X OPTIONS'); - // Safari adds `-Xnull` if is unable to determine the request type, it can be ignored - curlCommand = curlCommand.replace(/ -Xnull/, ' '); - curlCommand = curlCommand.trim(); - - const parsedArguments = yargs(curlCommand, { - boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'], - alias: { - H: 'header', - A: 'user-agent', - u: 'user', - F: 'form' - } - }); - - let cookieString; - let cookies; - let url = parsedArguments._[1] || ''; - - // remove surrounding quotes if present - if (url && url.length) { - url = url.replace(/^['"]|['"]$/g, ''); - } - - // if url argument wasn't where we expected it, try to find it in the other arguments - if (!url) { - for (const argName in parsedArguments) { - if (typeof parsedArguments[argName] === 'string') { - if (parsedArguments[argName].indexOf('http') === 0 || parsedArguments[argName].indexOf('www.') === 0) { - url = parsedArguments[argName]; - } - } + for (const arg of parsedArgs) { + const newState = processArgument(arg, currentState, request); + // Reset state after handling a value, or update to new state + if (currentState && !newState) { + currentState = null; + } else if (newState) { + currentState = newState; } } - let headers; - - if (parsedArguments.header) { - if (!headers) { - headers = {}; - } - if (!Array.isArray(parsedArguments.header)) { - parsedArguments.header = [parsedArguments.header]; - } - parsedArguments.header.forEach((header) => { - if (header.indexOf('Cookie') !== -1) { - cookieString = header; - } - const components = header.split(/:(.*)/); - if (components[1]) { - headers[components[0]] = components[1].trim(); - } - }); - } - - if (parsedArguments['user-agent']) { - if (!headers) { - headers = {}; - } - headers['User-Agent'] = parsedArguments['user-agent']; - } - - if (parsedArguments.b) { - cookieString = parsedArguments.b; - } - if (parsedArguments.cookie) { - cookieString = parsedArguments.cookie; - } - let multipartUploads; - // Handle multipart form data specified via -F or --form flags - // Example: curl -F 'id=123' -F 'file=@/path/to/file.txt' - if (parsedArguments.F || parsedArguments.form) { - multipartUploads = []; - const formArgs = parsedArguments.F || parsedArguments.form; - const formArray = Array.isArray(formArgs) ? formArgs : [formArgs]; - - formArray.forEach((multipartArgument) => { - // Parse each form field using regex: - // - Group 1: Field name before = - // - Group 2: Value in quotes after = (for text fields) - // - Group 3: Value after @ (for file fields) - const match = multipartArgument.match(/^([^=]+)=(?:@?"([^"]*)"|([^@]*))?$/); - if (match) { - const key = match[1]; - const value = match[2] || match[3] || ''; - const isFile = multipartArgument.includes('@'); - - multipartUploads.push({ - name: key, - value: value, - type: isFile ? 'file' : 'text', - enabled: true - }); - } - }); - } - if (cookieString) { - const cookieParseOptions = { - decode: function (s) { - return s; - } - }; - // separate out cookie headers into separate data structure - // note: cookie is case insensitive - cookies = cookie.parse(cookieString.replace(/^Cookie: /gi, ''), cookieParseOptions); - } - let method; - let parsedMethodArgument = parsedArguments.X || parsedArguments.request || parsedArguments.T; - if (parsedMethodArgument === 'POST') { - method = 'post'; - } else if (parsedMethodArgument === 'PUT') { - method = 'put'; - } else if (parsedMethodArgument === 'PATCH') { - method = 'patch'; - } else if (parsedMethodArgument === 'DELETE') { - method = 'delete'; - } else if (parsedMethodArgument === 'OPTIONS') { - method = 'options'; - } else if ( - (parsedArguments.d || - parsedArguments.data || - parsedArguments['data-ascii'] || - parsedArguments['data-binary'] || - parsedArguments['data-raw'] || - parsedArguments.F || - parsedArguments.form) && - !(parsedArguments.G || parsedArguments.get) - ) { - method = 'post'; - } else if (parsedArguments.I || parsedArguments.head) { - method = 'head'; - } else { - method = 'get'; - } - - const compressed = !!parsedArguments.compressed; - const urlObject = URL.parse(url || ''); - - // if GET request with data, convert data to query string - // NB: the -G flag does not change the http verb. It just moves the data into the url. - if (parsedArguments.G || parsedArguments.get) { - urlObject.query = urlObject.query ? urlObject.query : ''; - let option = null; - if ('d' in parsedArguments) option = 'd'; - if ('data' in parsedArguments) option = 'data'; - if ('data-urlencode' in parsedArguments) option = 'data-urlencode'; - if (option) { - let urlQueryString = ''; - - if (url.indexOf('?') < 0) { - url += '?'; - } else { - urlQueryString += '&'; - } - - if (typeof parsedArguments[option] === 'object') { - urlQueryString += parsedArguments[option].join('&'); - } else { - urlQueryString += parsedArguments[option]; - } - urlObject.query += urlQueryString; - url += urlQueryString; - delete parsedArguments[option]; - } - } - if (urlObject.query && urlObject.query.endsWith('&')) { - urlObject.query = urlObject.query.slice(0, -1); - } - const query = querystring.parse(urlObject.query, { sort: false }); - for (const param in query) { - if (query[param] === null) { - query[param] = ''; - } - } - - urlObject.search = null; // Clean out the search/query portion. - - let urlWithoutQuery = URL.format(urlObject); - let urlHost = urlObject?.host; - if (!url?.includes(`${urlHost}/`)) { - if (urlWithoutQuery && urlHost) { - const [beforeHost, afterHost] = urlWithoutQuery.split(urlHost); - urlWithoutQuery = beforeHost + urlHost + afterHost?.slice(1); - } - } - - const request = { - url, - urlWithoutQuery - }; - - if (compressed) { - request.compressed = true; - } - - if (Object.keys(query).length > 0) { - request.query = query; - } - if (headers) { - request.headers = headers; - } - request.method = method; - - if (cookies) { - request.cookies = cookies; - request.cookieString = cookieString.replace('Cookie: ', ''); - } - if (multipartUploads) { - request.multipartUploads = multipartUploads; - } - if (parsedArguments.data) { - request.data = parsedArguments.data; - } else if (parsedArguments['data-binary']) { - request.data = parsedArguments['data-binary']; - request.isDataBinary = true; - } else if (parsedArguments.d) { - request.data = parsedArguments.d; - } else if (parsedArguments['data-ascii']) { - request.data = parsedArguments['data-ascii']; - } else if (parsedArguments['data-raw']) { - request.data = parsedArguments['data-raw']; - request.isDataRaw = true; - } else if (parsedArguments['data-urlencode']) { - request.data = parsedArguments['data-urlencode']; - } - - if (parsedArguments.user && typeof parsedArguments.user === 'string') { - const basicAuth = parsedArguments.user.split(':') - const username = basicAuth[0] || '' - const password = basicAuth[1] || '' - request.auth = { - mode: 'basic', - basic: { - username, - password - } - } - } - - if (Array.isArray(request.data)) { - request.dataArray = request.data; - request.data = request.data.join('&'); - } - - if (parsedArguments.k || parsedArguments.insecure) { - request.insecure = true; - } return request; }; +/** + * Process a single argument and return new state if needed + * State machine: flags set states, values are processed based on current state + */ +const processArgument = (arg, currentState, request) => { + // Handle flag arguments first (they set states) + const flagState = handleFlag(arg, request); + if (flagState) { + return flagState; + } + + // Handle values based on current state (e.g., -H "value" where currentState is 'header') + if (arg && currentState) { + handleValue(arg, currentState, request); + return null; + } + + // Handle URL detection (only when no current state to avoid conflicts) + if (!currentState && isURLOrFragment(arg)) { + setURL(request, arg); + return null; + } + + return null; +}; + +/** + * Handle flag arguments and return new state + * Determines if flag expects a value or performs immediate action + */ +const handleFlag = (arg, request) => { + // Find which category this flag belongs to + for (const [category, flags] of Object.entries(FLAG_CATEGORIES)) { + if (flags.includes(arg)) { + return handleFlagCategory(category, arg, request); + } + } + + return null; +}; + +/** + * Handle flag based on its category + * Returns state name for flags that expect values, null for immediate actions + */ +const handleFlagCategory = (category, arg, request) => { + switch (category) { + // State-returning flags (return category name to expect value) + case 'user-agent': + case 'header': + case 'data': + case 'json': + case 'user': + case 'method': + case 'cookie': + case 'form': + return category; + + // Special data flags (set properties and return 'data' state) + case 'data-raw': + request.isDataRaw = true; + return 'data'; + + case 'data-binary': + request.isDataBinary = true; + return 'data'; + + // Immediate action flags (perform action and return null) + case 'head': + request.method = 'HEAD'; + return null; + + case 'compressed': + request.headers['Accept-Encoding'] = request.headers['Accept-Encoding'] || 'deflate, gzip'; + return null; + + case 'insecure': + request.insecure = true; + return null; + + case 'query': + // set temporary property isQuery to true to indicate that the data should be converted to query string + // this is processed later at post build request processing + request.isQuery = true; + return null; + + default: + return null; + } +}; + +/** + * Handle values based on the current parsing state + * Maps state names to their value processing functions + */ +const handleValue = (value, state, request) => { + const valueHandlers = { + 'header': () => setHeader(request, value), + 'user-agent': () => setUserAgent(request, value), + 'data': () => setData(request, value), + 'json': () => setJsonData(request, value), + 'form': () => setFormData(request, value), + 'user': () => setAuth(request, value), + 'method': () => setMethod(request, value), + 'cookie': () => setCookie(request, value) + }; + + const handler = valueHandlers[state]; + if (handler) { + handler(); + } +}; + +/** + * Set header from value + */ +const setHeader = (request, value) => { + const [headerName, headerValue] = value.split(/: (.+)/); + request.headers[headerName] = headerValue; +}; + +/** + * Set user agent + */ +const setUserAgent = (request, value) => { + request.headers['User-Agent'] = value; +}; + +/** + * Set authentication + */ +const setAuth = (request, value) => { + if (typeof value !== 'string') { + return; + } + + const [username, password] = value.split(':'); + request.auth = { + mode: 'basic', + basic: { + username: username || '', + password: password || '' + } + }; +}; + +/** + * Set request method + */ +const setMethod = (request, value) => { + request.method = value.toUpperCase(); +}; + +/** + * Set request cookies + */ +const setCookie = (request, value) => { + if (typeof value !== 'string') { + return; + } + + const parsedCookies = cookie.parse(value); + request.cookies = { ...request.cookies, ...parsedCookies }; + request.cookieString = request.cookieString ? request.cookieString + '; ' + value : value; + + request.headers['Cookie'] = request.cookieString; +}; + +/** + * Set data (handles multiple -d flags by concatenating with &) + */ +const setData = (request, value) => { + request.data = request.data ? request.data + '&' + value : value; +}; + +/** + * Set JSON data + * JSON flag automatically sets Content-Type and converts GET/HEAD to POST + */ +const setJsonData = (request, value) => { + if (request.method === 'GET' || request.method === 'HEAD') { + request.method = 'POST'; + } + request.headers['Content-Type'] = 'application/json'; + // JSON data replaces existing data (don't append with &) + request.data = value; +}; + +/** + * Set form data + * Form data always sets method to POST and creates multipart uploads + */ +const setFormData = (request, value) => { + const formArray = Array.isArray(value) ? value : [value]; + const multipartUploads = []; + + formArray.forEach((field) => { + const upload = parseFormField(field); + if (upload) { + multipartUploads.push(upload); + } + }); + + request.multipartUploads = request.multipartUploads || []; + request.multipartUploads.push(...multipartUploads); + request.method = 'POST'; +}; + +/** + * Parse a single form field + * Handles text fields, quoted values, and file uploads (@path) + */ +const parseFormField = (field) => { + const match = field.match(/^([^=]+)=(?:@?"([^"]*)"|@([^@]*)|([^@]*))?$/); + + if (!match) return null; + + const fieldName = match[1]; + const fieldValue = match[2] || match[3] || match[4] || ''; + const isFile = field.includes('@'); + + return { + name: fieldName, + value: fieldValue, + type: isFile ? 'file' : 'text', + enabled: true + }; +}; + +/** + * Check if argument is a URL or URL fragment + */ +const isURLOrFragment = (arg) => { + return isURL(arg) || isURLFragment(arg); +}; + +/** + * Check if argument looks like a URL + */ +const isURL = (arg) => { + if (typeof arg !== 'string') { + return false; + } + return !!URL.parse(arg || '').host; +}; + +/** + * Check if argument looks like a URL fragment + * Handles shell-quote operator objects and query parameter patterns + */ +const isURLFragment = (arg) => { + if (arg && typeof arg === 'object' && arg.op === 'glob') { + return !!URL.parse(arg.pattern || '').host; + } + if (arg && typeof arg === 'object' && arg.op === '&') { + return true; + } + if (typeof arg === 'string') { + // check if arg is a query string containing key=value pair + return /^[^=]+=[^&]*$/.test(arg); + } + return false; +}; + +/** + * Set URL and related properties + * Handles URL concatenation for shell-quote fragments + */ +const setURL = (request, url) => { + const urlString = getUrlString(url); + if (!urlString) return; + + const newUrl = request.url ? request.url + urlString : urlString; + + const { url: formattedUrl, queries, urlWithoutQuery } = parseUrl(newUrl); + + request.url = formattedUrl; + request.urlWithoutQuery = urlWithoutQuery; + request.query = queries; +}; + +/** + * Convert URL fragment to string + * Handles shell-quote operator objects + */ +const getUrlString = (url) => { + if (typeof url === 'string') return url; + if (url?.op === 'glob') return url.pattern; + if (url?.op === '&') return '&'; + return null; +}; + +/** + * Parse URL + * Returns formatted URL, URL without query, and queries + */ +const parseUrl = (url) => { + const parsedUrl = URL.parse(url); + + const queries = querystring.parse(parsedUrl.query, { sort: false }); + + // set empty string for null values + Object.entries(queries).forEach(([key, value]) => { + queries[key] = value ?? ''; + }); + + let formattedUrl = URL.format(parsedUrl); + if (!url.endsWith('/') && formattedUrl.endsWith('/')) { + // Remove trailing slashes if origin url does not have a trailing slash + formattedUrl = formattedUrl.slice(0, -1); + } + + const urlWithoutQuery = formattedUrl.split('?')[0]; + + return { + url: formattedUrl, + urlWithoutQuery, + queries + }; +}; + +/** + * Convert data to query string + * Used when -G or --get flag is present to move data from body to URL + */ +const convertDataToQueryString = (request) => { + let url = request.url; + + if (url.indexOf('?') < 0) { + url += '?'; + } else if (!url.endsWith('&')) { + url += '&'; + } + + // append data to url as query string + url += request.data; + + const { url: formattedUrl, queries } = parseUrl(url); + + request.url = formattedUrl; + request.query = queries; + + return request; +}; + +/** + * Post-build processing of request + * Handles method conversion and query parameter processing + */ +const postBuildProcessRequest = (request) => { + if (request.isQuery && request.data) { + request = convertDataToQueryString(request); + // remove data and isQuery from request as they are no longer needed + delete request.data; + delete request.isQuery; + + } else if (request.data) { + // if data is present, set method to POST unless the method is explicitly set + if (!request.method || request.method === 'HEAD') { + request.method = 'POST'; + } + } + + // if method is not set, set it to GET + if (!request.method) { + request.method = 'GET'; + } + + // bruno requires method to be lowercase + request.method = request.method.toLowerCase(); + + return request; +}; + +/** + * Clean up the final request object + */ +const cleanRequest = (request) => { + if (isEmpty(request.headers)) { + delete request.headers; + } + + if (isEmpty(request.query)) { + delete request.query; + } + + return request; +}; + +/** + * Clean up curl command + * Handles escape sequences, line continuations, and method concatenation + */ +const cleanCurlCommand = (curlCommand) => { + // Handle escape sequences + curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group); + // Convert escaped single quotes to shell quote pattern + curlCommand = curlCommand.replace(/\\'(?!')/g, "'\\''"); + // Fix concatenated HTTP methods + curlCommand = fixConcatenatedMethods(curlCommand); + + return curlCommand.trim(); +}; + +/** + * Fix concatenated HTTP methods + * Eg: Converts -XPOST to -X POST for proper parsing + */ +const fixConcatenatedMethods = (command) => { + const methodFixes = [ + { from: / -XPOST/, to: ' -X POST' }, + { from: / -XGET/, to: ' -X GET' }, + { from: / -XPUT/, to: ' -X PUT' }, + { from: / -XPATCH/, to: ' -X PATCH' }, + { from: / -XDELETE/, to: ' -X DELETE' }, + { from: / -XOPTIONS/, to: ' -X OPTIONS' }, + { from: / -XHEAD/, to: ' -X HEAD' }, + { from: / -Xnull/, to: ' ' } + ]; + + methodFixes.forEach(({ from, to }) => { + command = command.replace(from, to); + }); + + return command; +}; + export default parseCurlCommand; diff --git a/packages/bruno-app/src/utils/curl/parse-curl.spec.js b/packages/bruno-app/src/utils/curl/parse-curl.spec.js index 13b77645c..b136ebb20 100644 --- a/packages/bruno-app/src/utils/curl/parse-curl.spec.js +++ b/packages/bruno-app/src/utils/curl/parse-curl.spec.js @@ -2,144 +2,754 @@ const { describe, it, expect } = require('@jest/globals'); import parseCurlCommand from './parse-curl'; describe('parseCurlCommand', () => { - describe('basic functionality', () => { - it('should handle basic GET request', () => { - const result = parseCurlCommand('curl https://api.example.com/users'); + describe('Basic HTTP Methods', () => { + it('should parse simple GET request', () => { + const result = parseCurlCommand(` + curl https://api.example.com/users + `); + expect(result).toEqual({ + method: 'get', url: 'https://api.example.com/users', - urlWithoutQuery: 'https://api.example.com/users', - method: 'get' + urlWithoutQuery: 'https://api.example.com/users' }); }); it('should parse explicit POST method', () => { - const result = parseCurlCommand('curl -X POST https://api.example.com/users'); + const result = parseCurlCommand(` + curl -X POST https://api.example.com/users + `); + expect(result).toEqual({ + method: 'post', url: 'https://api.example.com/users', - urlWithoutQuery: 'https://api.example.com/users', - method: 'post' + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse PUT method', () => { + const result = parseCurlCommand(` + curl -X PUT https://api.example.com/users/1 + `); + + expect(result).toEqual({ + method: 'put', + url: 'https://api.example.com/users/1', + urlWithoutQuery: 'https://api.example.com/users/1' + }); + }); + + it('should parse DELETE method', () => { + const result = parseCurlCommand(` + curl -X DELETE https://api.example.com/users/1 + `); + + expect(result).toEqual({ + method: 'delete', + url: 'https://api.example.com/users/1', + urlWithoutQuery: 'https://api.example.com/users/1' + }); + }); + + it('should parse HEAD method', () => { + const result = parseCurlCommand(` + curl -I https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'head', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' }); }); }); - describe('headers handling', () => { - it('should parse multiple headers', () => { - const result = parseCurlCommand( - `curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com` - ); + describe('Headers', () => { + it('should parse single header', () => { + const result = parseCurlCommand(` + curl --header "Content-Type: application/json" https://api.example.com + `); + expect(result).toEqual({ + method: 'get', + headers: { + 'Content-Type': 'application/json' + }, url: 'https://api.example.com', - urlWithoutQuery: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should parse multiple headers', () => { + const result = parseCurlCommand(` + curl -H "Content-Type: application/json" \ + -H "Authorization: Bearer token" \ + https://api.example.com + `); + + expect(result).toEqual({ method: 'get', headers: { 'Content-Type': 'application/json', - Authorization: 'Bearer token' - } + 'Authorization': 'Bearer token' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' }); }); - it('should parse user-agent', () => { - const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`); + it('should parse user-agent header', () => { + const result = parseCurlCommand(` + curl -A "Custom User Agent" https://api.example.com + `); + expect(result).toEqual({ - url: 'https://api.example.com', - urlWithoutQuery: 'https://api.example.com', method: 'get', headers: { - 'User-Agent': 'Custom Agent' - } + 'User-Agent': 'Custom User Agent' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' }); }); }); - describe('auth handling', () => { - it('should parse basic auth', () => { - const result = parseCurlCommand(`curl -u user:pass https://api.example.com`); + describe('Data and Request Body', () => { + it('should parse JSON data and change method to POST', () => { + const result = parseCurlCommand(` + curl -d '{"name": "John", "age": 30}' https://api.example.com/users + `); + expect(result).toEqual({ + method: 'post', + data: '{"name": "John", "age": 30}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse post data', () => { + const result = parseCurlCommand(` + curl --data "name=John&age=30" https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: 'name=John&age=30', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle multiple data flags', () => { + const result = parseCurlCommand(` + curl -d "name=John" \ + -d "age=30" \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: 'name=John&age=30', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should keep multiline data', () => { + const result = parseCurlCommand(` + curl -d '{"key": "some long message with line breaks + + + multiline"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: `{"key": "some long message with line breaks + + + multiline"}`, + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should keep multi space data', () => { + const result = parseCurlCommand(` + curl -d '{"key": "some long spaced message"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + data: '{"key": "some long spaced message"}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse binary data flag', () => { + const result = parseCurlCommand(` + curl --data-binary "@/path/to/file" https://api.example.com/upload + `); + + expect(result).toEqual({ + method: 'post', + data: '@/path/to/file', + isDataBinary: true, + url: 'https://api.example.com/upload', + urlWithoutQuery: 'https://api.example.com/upload' + }); + }); + + it('should parse raw data flag', () => { + const result = parseCurlCommand(` + curl --data-raw '{"raw": "data"}' https://api.example.com + `); + + expect(result).toEqual({ + method: 'post', + data: '{"raw": "data"}', + isDataRaw: true, url: 'https://api.example.com', - urlWithoutQuery: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Authentication', () => { + it('should parse basic authentication', () => { + const result = parseCurlCommand(` + curl -u "username:password" https://api.example.com + `); + + expect(result).toEqual({ method: 'get', auth: { mode: 'basic', basic: { - username: 'user', - password: 'pass' + username: 'username', + password: 'password' } + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle username without password', () => { + const result = parseCurlCommand(` + curl --user "username" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + auth: { + mode: 'basic', + basic: { + username: 'username', + password: '' + } + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Form Data', () => { + it('should parse form data with text fields', () => { + const result = parseCurlCommand(` + curl -F "name=John" \ + -F "age=30" \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + multipartUploads: [ + { name: 'name', value: 'John', type: 'text', enabled: true }, + { name: 'age', value: '30', type: 'text', enabled: true } + ], + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should parse form data with file uploads', () => { + const result = parseCurlCommand(` + curl --form "file=@/path/to/file.txt" https://api.example.com/upload + `); + + expect(result).toEqual({ + method: 'post', + multipartUploads: [ + { name: 'file', value: '/path/to/file.txt', type: 'file', enabled: true } + ], + url: 'https://api.example.com/upload', + urlWithoutQuery: 'https://api.example.com/upload' + }); + }); + }); + + describe('Cookie', () => { + it('should handle cookie flag', () => { + const result = parseCurlCommand(` + curl -b "session=abc123" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123' + }, + cookieString: "session=abc123", + cookies: { + session: 'abc123' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle cookie flag with multiple cookies', () => { + const result = parseCurlCommand(` + curl -b "session=abc123; user=john" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123; user=john' + }, + cookieString: "session=abc123; user=john", + cookies: { + session: 'abc123', + user: 'john' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle multiple cookie flags', () => { + const result = parseCurlCommand(` + curl -b "session=abc123" -b "user=john" https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123; user=john' + }, + cookieString: "session=abc123; user=john", + cookies: { + session: 'abc123', + user: 'john' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle complex cookie string', () => { + const result = parseCurlCommand(` + curl -b "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly" \ + https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Cookie': 'session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly' + }, + cookieString: "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly", + cookies: { + session: 'abc123', + user: 'john', + path: '/', + domain: 'example.com', + expires: 'Thu, 01 Jan 1970 00:00:00 GMT', + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Shell Quote Handling', () => { + it(`should handle shell quote patterns ('\'' => \')`, () => { + const result = parseCurlCommand(` + curl -d '{"name": "John\'\\'\'s data"}' https://api.example.com + `); + + expect(result).toEqual({ + method: 'post', + data: '{"name": "John\'s data"}', + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle complex escaped quotes', () => { + const result = parseCurlCommand(` + curl -d '{"message": "Don\\'t stop believing"}' https://api.example.com + `); + + expect(result).toEqual({ + method: 'post', + data: '{"message": "Don\'t stop believing"}', + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('URL Handling', () => { + it('should parse URLs with query parameters', () => { + const result = parseCurlCommand(` + curl https://api.example.com/users?page=1&limit=10&sort=asc + `); + + expect(result).toEqual({ + method: 'get', + query: { + page: '1', + limit: '10', + sort: 'asc' + }, + url: 'https://api.example.com/users?page=1&limit=10&sort=asc', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle URLs with paths', () => { + const result = parseCurlCommand(` + curl https://api.example.com/v1/users/123 + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/v1/users/123', + urlWithoutQuery: 'https://api.example.com/v1/users/123' + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle compressed flag', () => { + const result = parseCurlCommand(` + curl --compressed https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + headers: { + 'Accept-Encoding': 'deflate, gzip' + }, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle concatenated HTTP methods', () => { + const result = parseCurlCommand(` + curl -XPOST https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle newlines and continuations', () => { + const result = parseCurlCommand(` + curl -H "Content-Type: application/json" \ + -d '{"name": "John"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"name": "John"}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + }); + + describe('Complex Examples', () => { + it('should parse a complex curl command with multiple features', () => { + const result = parseCurlCommand(` + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer token123" \ + -H "X-Custom-Header: custom header" \ + -d '{"name": "John\\'s data", "email": "john@example.com", "message": "Don\\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}' \ + -u "api_user:api_pass" \ + --compressed \ + https://api.example.com/v1/users?param1=value1¶m2=custom+param + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123', + 'X-Custom-Header': 'custom header', + 'Accept-Encoding': 'deflate, gzip' + }, + data: '{"name": "John\'s data", "email": "john@example.com", "message": "Don\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}', + auth: { + mode: 'basic', + basic: { + username: 'api_user', + password: 'api_pass' + } + }, + query: { + param1: 'value1', + param2: 'custom param' + }, + url: 'https://api.example.com/v1/users?param1=value1¶m2=custom+param', + urlWithoutQuery: 'https://api.example.com/v1/users' + }); + }); + }); + + describe('curl command with complex escape characters', () => { + it('should parse a curl command with complex escape characters', () => { + const result = parseCurlCommand(` + curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer token123" \ + -d '{"name": "John\\'s data", "email": "john@example.com"}' \ + -u "api_user:api_pass" \ + --compressed \ + https://api.example.com/v1/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123', + 'Accept-Encoding': 'deflate, gzip' + }, + data: '{"name": "John\'s data", "email": "john@example.com"}', + auth: { + mode: 'basic', + basic: { + username: 'api_user', + password: 'api_pass' + } + }, + url: 'https://api.example.com/v1/users', + urlWithoutQuery: 'https://api.example.com/v1/users' + }); + }); + }); + + describe('JSON Flag', () => { + it('should handle basic JSON request', () => { + const result = parseCurlCommand(` + curl --json '{"name": "John Doe", "email": "john@example.com"}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"name": "John Doe", "email": "john@example.com"}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle JSON with authentication headers', () => { + const result = parseCurlCommand(` + curl --json '{"title": "New Post", "content": "Post content"}' \ + -H "Authorization: Bearer token123" \ + https://api.example.com/posts + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123' + }, + data: '{"title": "New Post", "content": "Post content"}', + url: 'https://api.example.com/posts', + urlWithoutQuery: 'https://api.example.com/posts' + }); + }); + + it('should handle complex JSON data', () => { + const result = parseCurlCommand(` + curl --json '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}' \ + https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}', + url: 'https://api.example.com/users', + urlWithoutQuery: 'https://api.example.com/users' + }); + }); + + it('should handle JSON with escaped quotes', () => { + const result = parseCurlCommand(` + curl --json '{"message": "Don\\'t stop believing!", "user": "John\\'s account"}' \ + https://api.example.com/messages + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"message": "Don\'t stop believing!", "user": "John\'s account"}', + url: 'https://api.example.com/messages', + urlWithoutQuery: 'https://api.example.com/messages' + }); + }); + + it('should handle JSON with arrays and nested objects', () => { + const result = parseCurlCommand(` + curl --json '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}' \ + https://api.example.com/orders + `); + + expect(result).toEqual({ + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}', + url: 'https://api.example.com/orders', + urlWithoutQuery: 'https://api.example.com/orders' + }); + }); + + it('should handle JSON with custom method', () => { + const result = parseCurlCommand(` + curl -X PUT \ + --json '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}' \ + https://api.example.com/tasks/123 + `); + + expect(result).toEqual({ + method: 'put', + headers: { + 'Content-Type': 'application/json' + }, + data: '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}', + url: 'https://api.example.com/tasks/123', + urlWithoutQuery: 'https://api.example.com/tasks/123' + }); + }); + }); + + describe('Insecure Flag', () => { + it('should handle -k flag', () => { + const result = parseCurlCommand(` + curl -k https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + insecure: true, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + + it('should handle --insecure flag', () => { + const result = parseCurlCommand(` + curl --insecure https://api.example.com + `); + + expect(result).toEqual({ + method: 'get', + insecure: true, + url: 'https://api.example.com', + urlWithoutQuery: 'https://api.example.com' + }); + }); + }); + + describe('Query Flag', () => { + it('should handle -G flag to convert POST data to GET query parameters', () => { + const result = parseCurlCommand(` + curl -G -d "name=John" -d "age=30" https://api.example.com/users + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/users?name=John&age=30', + urlWithoutQuery: 'https://api.example.com/users', + query: { + name: 'John', + age: '30' + } + }); + }); + + it('should handle -G flag with --data-urlencode', () => { + const result = parseCurlCommand(` + curl -G --data-urlencode "name=John Doe" \ + --data-urlencode "email=john@example.com" \ + --data-urlencode "hello" \ + https://api.example.com/users?test=urlquery&hello + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/users?test=urlquery&name=John%20Doe&email=john@example.com&hello', + urlWithoutQuery: 'https://api.example.com/users', + query: { + email: 'john@example.com', + hello: '', + name: 'John Doe', + test: 'urlquery' + } + }); + }); + + it('should handle -G flag with complex data', () => { + const result = parseCurlCommand(` + curl -G -d "search=test+query" \ + -d "filter=active" \ + -d "sort=name" \ + -d "page=1" \ + https://api.example.com/search + `); + + expect(result).toEqual({ + method: 'get', + url: 'https://api.example.com/search?search=test+query&filter=active&sort=name&page=1', + urlWithoutQuery: 'https://api.example.com/search', + query: { + search: 'test query', + filter: 'active', + sort: 'name', + page: '1' } }); }); }); - - describe('data handling', () => { - it('should parse POST data', () => { - const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`); - expect(result).toEqual({ - url: 'https://api.example.com', - urlWithoutQuery: 'https://api.example.com', - method: 'post', - data: 'foo=bar&baz=qux' - }); - }); - - it('should handle data-binary', () => { - const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`); - expect(result).toEqual({ - url: 'https://api.example.com', - urlWithoutQuery: 'https://api.example.com', - method: 'post', - data: '@file.json', - isDataBinary: true - }); - }); - }); - - describe('form data handling', () => { - it('should parse complex form data with multiple fields and file upload', () => { - const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \ - --form 'id="1"' \ - --form 'documentid="ADMINN_ID"' \ - --form 'appoinID="12376"' \ - --form 'autoclose="false"' \ - --form 'fileData=@"/path/to/file"'`; - - const result = parseCurlCommand(curlCommand); - - expect(result).toEqual({ - url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d', - urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d', - method: 'post', - multipartUploads: [ - { - name: 'id', - value: '1', - type: 'text', - enabled: true - }, - { - name: 'documentid', - value: 'ADMINN_ID', - type: 'text', - enabled: true - }, - { - name: 'appoinID', - value: '12376', - type: 'text', - enabled: true - }, - { - name: 'autoclose', - value: 'false', - type: 'text', - enabled: true - }, - { - name: 'fileData', - value: '/path/to/file', - type: 'file', - enabled: true - } - ] - }); - }); - }); }); From 2bbfb28090594cb14c050e65b0329d2bbae4e159 Mon Sep 17 00:00:00 2001 From: sanish chirayath Date: Tue, 24 Jun 2025 15:58:29 +0530 Subject: [PATCH 07/22] fix: handle falsy values in Postman environment and collection variables (#4924) * fix: handle falsy values in Postman environment and collection variables * Updated the `postman-env-to-bruno-env` and `postman-to-bruno` converters to handle cases where variable keys or values are falsy, ensuring they default to empty strings. * Added unit tests to verify the correct handling of falsy values in both environment and collection variables. * fix: filter out null/undefined keys and values in Postman variable imports * Updated the `postman-env-to-bruno-env` and `postman-to-bruno` converters to filter out variables with null keys and values during import. * Removed redundant test cases for empty variables in the corresponding unit tests. --- .../src/postman/postman-env-to-bruno-env.js | 8 +- .../src/postman/postman-to-bruno.js | 6 +- .../postman/postman-env-to-bruno-env.spec.js | 79 +++++++++++++++++++ .../postman-to-bruno/postman-to-bruno.spec.js | 67 ++++++++++++++++ 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js b/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js index 52d60b08b..d9183cbfb 100644 --- a/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js +++ b/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js @@ -6,14 +6,14 @@ const isSecret = (type) => { return type === 'secret'; }; -const importPostmanEnvironmentVariables = (brunoEnvironment, values) => { +const importPostmanEnvironmentVariables = (brunoEnvironment, values = []) => { brunoEnvironment.variables = brunoEnvironment.variables || []; - each(values, (i) => { + each(values.filter(i => !(i.key == null && i.value == null)), (i) => { const brunoEnvironmentVariable = { uid: uuid(), - name: i.key.replace(invalidVariableCharacterRegex, '_'), - value: i.value, + name: (i.key ?? '').replace(invalidVariableCharacterRegex, '_'), + value: i.value ?? '', enabled: i.enabled, secret: isSecret(i.type) }; diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index bef21b46e..0b1873792 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -119,10 +119,10 @@ const importScriptsFromEvents = (events, requestObject) => { }; const importCollectionLevelVariables = (variables, requestObject) => { - const vars = variables.map((v) => ({ + const vars = variables.filter(v => !(v.key == null && v.value == null)).map((v) => ({ uid: uuid(), - name: v.key.replace(invalidVariableCharacterRegex, '_'), - value: v.value, + name: (v.key ?? '').replace(invalidVariableCharacterRegex, '_'), + value: v.value ?? '', enabled: true })); diff --git a/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js b/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js index 6101548bd..9421d3768 100644 --- a/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js +++ b/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js @@ -47,6 +47,66 @@ describe('postmanToBrunoEnvironment Function', () => { expect(brunoEnvironment).toEqual(expectedEnvironment); }); + it('should handle falsy values in environment variables', async () => { + const postmanEnvironment = { + "id": "some-id", + "name": "My Environment", + "values": [ + { + "enabled": true, + "type": "text" + }, + { + "value": "", + "enabled": true, + "type": "text" + }, + { + "key": "", + "enabled": true, + "type": "text" + }, + { + "key": "", + "value": "", + "enabled": true, + "type": "text" + } + ] + }; + + const brunoEnvironment = await postmanToBrunoEnvironment(postmanEnvironment); + + const expectedEnvironment = { + name: 'My Environment', + variables: [ + { + name: '', + value: '', + enabled: true, + secret: false, + uid: "mockeduuidvalue123456", + }, + { + name: '', + value: '', + enabled: true, + secret: false, + uid: "mockeduuidvalue123456", + }, + { + name: '', + value: '', + enabled: true, + secret: false, + uid: "mockeduuidvalue123456", + } + ], + }; + + expect(brunoEnvironment).toEqual(expectedEnvironment); + }); + it.skip('should throw Error when JSON parsing fails', async () => { const invalidBrunoEnvironment = { "id": "some-id", @@ -66,4 +126,23 @@ describe('postmanToBrunoEnvironment Function', () => { 'Unable to parse the postman environment json file' ); }); + + it("should handle empty variables", async () => { + const collectionWithEmptyVars = { + "name": "My Environment", + "values": [] + }; + + const brunoCollection = await postmanToBrunoEnvironment(collectionWithEmptyVars); + expect(brunoCollection.variables).toEqual([]); + }); + + it("should handle undefined variables", async () => { + const collectionWithUndefinedVars = { + "name": "My Environment", + }; + + const brunoCollection = await postmanToBrunoEnvironment(collectionWithUndefinedVars); + expect(brunoCollection.variables).toEqual([]); + }); }); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js index 3ac79476c..e303ab3d3 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js @@ -6,6 +6,73 @@ describe('postman-collection', () => { const brunoCollection = await postmanToBruno(postmanCollection); expect(brunoCollection).toMatchObject(expectedOutput); }); + + it('should handle falsy values in collection variables', async () => { + const collectionWithFalsyVars = { + "info": { + "_postman_id": "7f91bbd8-cb97-41ac-8d0b-e1fcd8bb4ce9", + "name": "collection with falsy vars", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "type": "string" + }, + { + "key": "", + "type": "string" + }, + { + "value": "", + "type": "string" + }, + { + "key": "", + "value": "", + "type": "string" + } + ], + "item": [] + }; + + const brunoCollection = await postmanToBruno(collectionWithFalsyVars); + + expect(brunoCollection.root.request.vars.req).toEqual([ + { + uid: "mockeduuidvalue123456", + name: '', + value: '', + enabled: true + }, + { + uid: "mockeduuidvalue123456", + name: '', + value: '', + enabled: true + }, + { + uid: "mockeduuidvalue123456", + name: '', + value: '', + enabled: true + } + ]); + }); + + it("should handle empty variables", async () => { + const collectionWithEmptyVars = { + "info": { + "_postman_id": "7f91bbd8-cb97-41ac-8d0b-e1fcd8bb4ce9", + "name": "collection with falsy vars", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [], + "item": [] + }; + + const brunoCollection = await postmanToBruno(collectionWithEmptyVars); + expect(brunoCollection.root.request.vars.req).toEqual([]); + }); }); // Simple Collection (postman) From 9fe13f18688beef215b738bcc692fdd9babe9607 Mon Sep 17 00:00:00 2001 From: sanish chirayath Date: Tue, 24 Jun 2025 16:32:32 +0530 Subject: [PATCH 08/22] Fix: postman collection fails when auth object missing auth values (#4794) * refactor: streamline authentication handling in postman-to-bruno.js by using a switch statement and introducing AUTH_TYPES constant for better readability and maintainability * feat: enhance authentication handling in postman-to-bruno.js to manage missing auth values across collection, folder, and request levels, ensuring a default mode of 'none' * fix: update authentication handling in postman-to-bruno.js to correctly set auth mode based on provided auth type * fix: update authentication tests to ensure default values are set for various auth types in postman-to-bruno --- .../src/postman/postman-to-bruno.js | 211 +++++---- .../postman-to-bruno/collection-auth.spec.js | 93 ++++ .../postman-to-bruno/folder-auth.spec.js | 52 +++ .../postman-to-bruno/process-auth.spec.js | 428 ++++++++++++++++++ .../postman-to-bruno/request-auth.spec.js | 37 +- 5 files changed, 727 insertions(+), 94 deletions(-) create mode 100644 packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 0b1873792..62317ee0e 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -4,6 +4,17 @@ import each from 'lodash/each'; import postmanTranslation from './postman-translations'; import { invalidVariableCharacterRegex } from '../constants/index'; +const AUTH_TYPES = Object.freeze({ + BASIC: 'basic', + BEARER: 'bearer', + AWSV4: 'awsv4', + APIKEY: 'apikey', + DIGEST: 'digest', + OAUTH2: 'oauth2', + NOAUTH: 'noauth', + NONE: 'none' +}); + const parseGraphQLRequest = (graphqlSource) => { try { let queryResultObject = { @@ -129,107 +140,122 @@ const importCollectionLevelVariables = (variables, requestObject) => { requestObject.vars.req = vars; }; -const processAuth = (auth, requestObject) => { - if (!auth || !auth.type || auth.type === 'noauth') { +export const processAuth = (auth, requestObject) => { + if (!auth || !auth.type || auth.type === AUTH_TYPES.NOAUTH) { return; } let authValues = auth[auth.type]; + + if(!authValues) { + console.warn('Unexpected auth.type, auth object doesn\'t have the key', auth.type); + requestObject.auth.mode = auth.type; + authValues = {}; + } + if (Array.isArray(authValues)) { authValues = convertV21Auth(authValues); } - if (auth.type === 'basic') { - requestObject.auth.mode = 'basic'; - requestObject.auth.basic = { - username: authValues.username || '', - password: authValues.password || '' - }; - } else if (auth.type === 'bearer') { - requestObject.auth.mode = 'bearer'; - requestObject.auth.bearer = { - token: authValues.token || '' - }; - } else if (auth.type === 'awsv4') { - requestObject.auth.mode = 'awsv4'; - requestObject.auth.awsv4 = { - accessKeyId: authValues.accessKey || '', - secretAccessKey: authValues.secretKey || '', - sessionToken: authValues.sessionToken || '', - service: authValues.service || '', - region: authValues.region || '', - profileName: '' - }; - } else if (auth.type === 'apikey') { - requestObject.auth.mode = 'apikey'; - requestObject.auth.apikey = { - key: authValues.key || '', - value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it, - placement: 'header' //By default we are placing the apikey values in headers! - }; - } else if (auth.type === 'digest') { - requestObject.auth.mode = 'digest'; - requestObject.auth.digest = { - username: authValues.username || '', - password: authValues.password || '' - }; - } else if (auth.type === 'oauth2') { - const findValueUsingKey = (key) => { - return authValues[key] || ''; - }; - const oauth2GrantTypeMaps = { - authorization_code_with_pkce: 'authorization_code', - authorization_code: 'authorization_code', - client_credentials: 'client_credentials', - password_credentials: 'password_credentials' - }; - const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code'; + switch (auth.type) { + case AUTH_TYPES.BASIC: + requestObject.auth.mode = AUTH_TYPES.BASIC; + requestObject.auth.basic = { + username: authValues.username || '', + password: authValues.password || '' + }; + break; + case AUTH_TYPES.BEARER: + requestObject.auth.mode = AUTH_TYPES.BEARER; + requestObject.auth.bearer = { + token: authValues.token || '' + }; + break; + case AUTH_TYPES.AWSV4: + requestObject.auth.mode = AUTH_TYPES.AWSV4; + requestObject.auth.awsv4 = { + accessKeyId: authValues.accessKey || '', + secretAccessKey: authValues.secretKey || '', + sessionToken: authValues.sessionToken || '', + service: authValues.service || '', + region: authValues.region || '', + profileName: '' + }; + break; + case AUTH_TYPES.APIKEY: + requestObject.auth.mode = AUTH_TYPES.APIKEY; + requestObject.auth.apikey = { + key: authValues.key || '', + value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it, + placement: 'header' //By default we are placing the apikey values in headers! + }; + break; + case AUTH_TYPES.DIGEST: + requestObject.auth.mode = AUTH_TYPES.DIGEST; + requestObject.auth.digest = { + username: authValues.username || '', + password: authValues.password || '' + }; + break; + case AUTH_TYPES.OAUTH2: + const findValueUsingKey = (key) => { + return authValues[key] || ''; + }; + const oauth2GrantTypeMaps = { + authorization_code_with_pkce: 'authorization_code', + authorization_code: 'authorization_code', + client_credentials: 'client_credentials', + password_credentials: 'password_credentials' + }; + const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code'; - requestObject.auth.mode = 'oauth2'; - if (grantType === 'authorization_code') { - requestObject.auth.oauth2 = { - grantType: 'authorization_code', - authorizationUrl: findValueUsingKey('authUrl'), - callbackUrl: findValueUsingKey('redirect_uri'), - accessTokenUrl: findValueUsingKey('accessTokenUrl'), - refreshTokenUrl: findValueUsingKey('refreshTokenUrl'), - clientId: findValueUsingKey('clientId'), - clientSecret: findValueUsingKey('clientSecret'), - scope: findValueUsingKey('scope'), - state: findValueUsingKey('state'), - pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'), - tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url', - credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header' - }; - } else if (grantType === 'password_credentials') { - requestObject.auth.oauth2 = { - grantType: 'password', - accessTokenUrl: findValueUsingKey('accessTokenUrl'), - refreshTokenUrl: findValueUsingKey('refreshTokenUrl'), - username: findValueUsingKey('username'), - password: findValueUsingKey('password'), - clientId: findValueUsingKey('clientId'), - clientSecret: findValueUsingKey('clientSecret'), - scope: findValueUsingKey('scope'), - state: findValueUsingKey('state'), - tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url', - credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header' - }; - } else if (grantType === 'client_credentials') { - requestObject.auth.oauth2 = { - grantType: 'client_credentials', - accessTokenUrl: findValueUsingKey('accessTokenUrl'), - refreshTokenUrl: findValueUsingKey('refreshTokenUrl'), - clientId: findValueUsingKey('clientId'), - clientSecret: findValueUsingKey('clientSecret'), - scope: findValueUsingKey('scope'), - state: findValueUsingKey('state'), - tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url', - credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header' - }; - } - } else { - console.warn('Unexpected auth.type', auth.type); + requestObject.auth.mode = AUTH_TYPES.OAUTH2; + if (grantType === 'authorization_code') { + requestObject.auth.oauth2 = { + grantType: 'authorization_code', + authorizationUrl: findValueUsingKey('authUrl'), + callbackUrl: findValueUsingKey('redirect_uri'), + accessTokenUrl: findValueUsingKey('accessTokenUrl'), + refreshTokenUrl: findValueUsingKey('refreshTokenUrl'), + clientId: findValueUsingKey('clientId'), + clientSecret: findValueUsingKey('clientSecret'), + scope: findValueUsingKey('scope'), + state: findValueUsingKey('state'), + pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'), + tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url', + credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header' + }; + } else if (grantType === 'password_credentials') { + requestObject.auth.oauth2 = { + grantType: 'password', + accessTokenUrl: findValueUsingKey('accessTokenUrl'), + refreshTokenUrl: findValueUsingKey('refreshTokenUrl'), + username: findValueUsingKey('username'), + password: findValueUsingKey('password'), + clientId: findValueUsingKey('clientId'), + clientSecret: findValueUsingKey('clientSecret'), + scope: findValueUsingKey('scope'), + state: findValueUsingKey('state'), + tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url', + credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header' + }; + } else if (grantType === 'client_credentials') { + requestObject.auth.oauth2 = { + grantType: 'client_credentials', + accessTokenUrl: findValueUsingKey('accessTokenUrl'), + refreshTokenUrl: findValueUsingKey('refreshTokenUrl'), + clientId: findValueUsingKey('clientId'), + clientSecret: findValueUsingKey('clientSecret'), + scope: findValueUsingKey('scope'), + state: findValueUsingKey('state'), + tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url', + credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header' + }; + } + break; + default: + requestObject.auth.mode = AUTH_TYPES.NONE; + console.warn('Unexpected auth.type', auth.type); } }; @@ -663,5 +689,4 @@ const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) => } }; - export default postmanToBruno; \ No newline at end of file diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js index d1a5caa7a..77b2ea7d4 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js @@ -235,4 +235,97 @@ describe('Collection Authentication', () => { } }); }); + + it('should handle missing auth values when auth.type exists', async() => { + const postmanCollection = { + info: { + name: 'Collection with missing auth values', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + item: [], + auth: { + type: 'basic' + // Missing basic auth values + }, + event: [ + { + listen: 'prerequest', + script: { + type: 'text/javascript', + packages: {}, + exec: [''] + } + }, + { + listen: 'test', + script: { + type: 'text/javascript', + packages: {}, + exec: [''] + } + } + ] + }; + + const result = await postmanToBruno(postmanCollection); + + expect(result.root.request.auth).toEqual({ + mode: 'basic', + basic: { + username: '', + password: '' + }, + bearer: null, + awsv4: null, + apikey: null, + oauth2: null, + digest: null + }); + }); + + it('should handle missing auth values for different auth types', async() => { + const postmanCollection = { + info: { + name: 'Collection with missing auth values for different types', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + item: [], + auth: { + type: 'bearer' + // Missing bearer token + }, + event: [ + { + listen: 'prerequest', + script: { + type: 'text/javascript', + packages: {}, + exec: [''] + } + }, + { + listen: 'test', + script: { + type: 'text/javascript', + packages: {}, + exec: [''] + } + } + ] + }; + + const result = await postmanToBruno(postmanCollection); + + expect(result.root.request.auth).toEqual({ + mode: 'bearer', + basic: null, + bearer: { + token: '' + }, + awsv4: null, + apikey: null, + oauth2: null, + digest: null + }); + }); }); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js index ba6f86596..7e273826e 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js @@ -244,4 +244,56 @@ describe('Folder Authentication', () => { digest: { username: 'digest user', password: 'digest pass' } }); }); + + it('should handle missing auth values in folder level auth', async() => { + const postmanCollection = { + info: { + name: 'Folder with missing auth values', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + item: [ + { + name: 'folder', + item: [], + auth: { + type: 'basic' + // Missing basic values + }, + event: [ + { + listen: 'prerequest', + script: { + type: 'text/javascript', + packages: {}, + exec: [''] + } + }, + { + listen: 'test', + script: { + type: 'text/javascript', + packages: {}, + exec: [''] + } + } + ] + } + ] + }; + + const result = await postmanToBruno(postmanCollection); + + expect(result.items[0].root.request.auth).toEqual({ + mode: 'basic', + basic: { + username: '', + password: '' + }, + bearer: null, + awsv4: null, + apikey: null, + oauth2: null, + digest: null + }); + }); }); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js new file mode 100644 index 000000000..8bbf697a5 --- /dev/null +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/process-auth.spec.js @@ -0,0 +1,428 @@ +const { processAuth } = require("../../../src/postman/postman-to-bruno"); + + +describe('processAuth', () => { + let requestObject; + + beforeEach(() => { + requestObject = { + auth: { + mode: 'none', + basic: null, + bearer: null, + awsv4: null, + apikey: null, + oauth2: null, + digest: null + } + }; + }); + + it('should handle no auth', () => { + processAuth(null, requestObject); + expect(requestObject.auth.mode).toBe('none'); + }); + + it('should handle noauth type', () => { + processAuth({ type: 'noauth' }, requestObject); + expect(requestObject.auth.mode).toBe('none'); + }); + + it('should handle basic auth', () => { + const auth = { + type: 'basic', + basic: [ + { key: 'username', value: 'testuser', type: 'string' }, + { key: 'password', value: 'testpass', type: 'string' } + ] + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('basic'); + expect(requestObject.auth.basic).toEqual({ + username: 'testuser', + password: 'testpass' + }); + }); + + it('should handle basic auth with missing values', () => { + const auth = { + type: 'basic', + basic: {} + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('basic'); + expect(requestObject.auth.basic).toEqual({ + username: '', + password: '' + }); + }); + + it('should handle basic auth with missing basic key', () => { + const auth = { + type: 'basic' + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('basic'); + expect(requestObject.auth.basic).toEqual({ + username: '', + password: '' + }); + }); + + it('should handle bearer auth', () => { + const auth = { + type: 'bearer', + bearer: { + token: 'test-token' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('bearer'); + expect(requestObject.auth.bearer).toEqual({ + token: 'test-token' + }); + }); + + it('should handle bearer auth with missing values', () => { + const auth = { + type: 'bearer', + bearer: {} + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('bearer'); + expect(requestObject.auth.bearer).toEqual({ + token: '' + }); + }); + + it('should handle bearer auth with missing bearer key', () => { + const auth = { + type: 'bearer' + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('bearer'); + expect(requestObject.auth.bearer).toEqual({ + token: '' + }); + }); + + it('should handle awsv4 auth', () => { + const auth = { + type: 'awsv4', + awsv4: { + accessKey: 'test-access-key', + secretKey: 'test-secret-key', + sessionToken: 'test-session-token', + service: 'test-service', + region: 'test-region' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('awsv4'); + expect(requestObject.auth.awsv4).toEqual({ + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + sessionToken: 'test-session-token', + service: 'test-service', + region: 'test-region', + profileName: '' + }); + }); + + it('should handle awsv4 auth with missing values', () => { + const auth = { + type: 'awsv4', + awsv4: {} + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('awsv4'); + expect(requestObject.auth.awsv4).toEqual({ + accessKeyId: '', + secretAccessKey: '', + sessionToken: '', + service: '', + region: '', + profileName: '' + }); + }); + + it('should handle awsv4 auth with missing awsv4 key', () => { + const auth = { + type: 'awsv4' + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('awsv4'); + expect(requestObject.auth.awsv4).toEqual({ + accessKeyId: '', + secretAccessKey: '', + sessionToken: '', + service: '', + region: '', + profileName: '' + }); + }); + + it('should handle apikey auth', () => { + const auth = { + type: 'apikey', + apikey: { + key: 'test-key', + value: 'test-value' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('apikey'); + expect(requestObject.auth.apikey).toEqual({ + key: 'test-key', + value: 'test-value', + placement: 'header' + }); + }); + + it('should handle apikey auth with missing values', () => { + const auth = { + type: 'apikey', + apikey: {} + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('apikey'); + expect(requestObject.auth.apikey).toEqual({ + key: '', + value: '', + placement: 'header' + }); + }); + + it('should handle apikey auth with missing apikey key', () => { + const auth = { + type: 'apikey' + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('apikey'); + expect(requestObject.auth.apikey).toEqual({ + key: '', + value: '', + placement: 'header' + }); + }); + + it('should handle digest auth', () => { + const auth = { + type: 'digest', + digest: { + username: 'testuser', + password: 'testpass' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('digest'); + expect(requestObject.auth.digest).toEqual({ + username: 'testuser', + password: 'testpass' + }); + }); + + it('should handle digest auth with missing values', () => { + const auth = { + type: 'digest', + digest: {} + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('digest'); + expect(requestObject.auth.digest).toEqual({ + username: '', + password: '' + }); + }); + + it('should handle digest auth with missing digest key', () => { + const auth = { + type: 'digest' + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('digest'); + expect(requestObject.auth.digest).toEqual({ + username: '', + password: '' + }); + }); + + it('should handle oauth2 auth with authorization_code grant type', () => { + const auth = { + type: 'oauth2', + oauth2: { + grant_type: 'authorization_code', + authUrl: 'https://auth.example.com', + redirect_uri: 'https://callback.example.com', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + addTokenTo: 'header', + client_authentication: 'body' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('oauth2'); + expect(requestObject.auth.oauth2).toEqual({ + grantType: 'authorization_code', + authorizationUrl: 'https://auth.example.com', + callbackUrl: 'https://callback.example.com', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + pkce: false, + tokenPlacement: 'header', + credentialsPlacement: 'body' + }); + }); + + it('should handle oauth2 auth with password_credentials grant type', () => { + const auth = { + type: 'oauth2', + oauth2: { + grant_type: 'password_credentials', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + username: 'testuser', + password: 'testpass', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + addTokenTo: 'header', + client_authentication: 'body' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('oauth2'); + expect(requestObject.auth.oauth2).toEqual({ + grantType: 'password', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + username: 'testuser', + password: 'testpass', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + tokenPlacement: 'header', + credentialsPlacement: 'body' + }); + }); + + it('should handle oauth2 auth with client_credentials grant type', () => { + const auth = { + type: 'oauth2', + oauth2: { + grant_type: 'client_credentials', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + addTokenTo: 'header', + client_authentication: 'body' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('oauth2'); + expect(requestObject.auth.oauth2).toEqual({ + grantType: 'client_credentials', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + tokenPlacement: 'header', + credentialsPlacement: 'body' + }); + }); + + it('should handle oauth2 auth with missing values', () => { + const auth = { + type: 'oauth2', + oauth2: {} + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('oauth2'); + expect(requestObject.auth.oauth2).toEqual({ + grantType: 'authorization_code', + authorizationUrl: '', + callbackUrl: '', + accessTokenUrl: '', + refreshTokenUrl: '', + clientId: '', + clientSecret: '', + scope: '', + state: '', + pkce: false, + tokenPlacement: 'url', + credentialsPlacement: 'basic_auth_header' + }); + }); + + it('should handle oauth2 auth with missing oauth2 key', () => { + const auth = { + type: 'oauth2' + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('oauth2'); + expect(requestObject.auth.oauth2).toEqual({ + grantType: 'authorization_code', + authorizationUrl: '', + callbackUrl: '', + accessTokenUrl: '', + refreshTokenUrl: '', + clientId: '', + clientSecret: '', + scope: '', + state: '', + pkce: false, + tokenPlacement: 'url', + credentialsPlacement: 'basic_auth_header' + }); + }); + + it('should handle oauth2 auth with authorization_code_with_pkce grant type', () => { + const auth = { + type: 'oauth2', + oauth2: { + grant_type: 'authorization_code_with_pkce', + authUrl: 'https://auth.example.com', + redirect_uri: 'https://callback.example.com', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + addTokenTo: 'header', + client_authentication: 'body' + } + }; + processAuth(auth, requestObject); + expect(requestObject.auth.mode).toBe('oauth2'); + expect(requestObject.auth.oauth2).toEqual({ + grantType: 'authorization_code', + authorizationUrl: 'https://auth.example.com', + callbackUrl: 'https://callback.example.com', + accessTokenUrl: 'https://token.example.com', + refreshTokenUrl: 'https://refresh.example.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + scope: 'test-scope', + state: 'test-state', + pkce: true, + tokenPlacement: 'header', + credentialsPlacement: 'body' + }); + }); +}); diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js index 2a71301d3..8a1c5fa27 100644 --- a/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js +++ b/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js @@ -130,5 +130,40 @@ describe('Request Authentication', () => { digest: null }); }); - + + it('should handle missing basic auth values in request level', async() => { + const postmanCollection = { + info: { + name: 'Missing Auth Request Collection', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' + }, + item: [ + { + name: 'Missing Auth Request', + request: { + method: 'GET', + url: 'https://api.example.com/test', + auth: { + type: 'basic' + } + } + } + ] + }; + + const result = await postmanToBruno(postmanCollection); + + expect(result.items[0].request.auth).toEqual({ + mode: 'basic', + basic: { + username: '', + password: '' + }, + bearer: null, + awsv4: null, + apikey: null, + oauth2: null, + digest: null + }); + }); }); From 879c124aecb27c132ebf86c7392dcfb2595b9681 Mon Sep 17 00:00:00 2001 From: lohit Date: Tue, 24 Jun 2025 17:12:17 +0530 Subject: [PATCH 09/22] add explicit HTTP agents with keepAlive to bru.sendRequest axios instance --- packages/bruno-requests/src/network/axios-instance.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bruno-requests/src/network/axios-instance.ts b/packages/bruno-requests/src/network/axios-instance.ts index 529045fe4..066168901 100644 --- a/packages/bruno-requests/src/network/axios-instance.ts +++ b/packages/bruno-requests/src/network/axios-instance.ts @@ -1,4 +1,6 @@ import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import http from 'node:http'; +import https from 'node:https'; /** * @@ -25,6 +27,8 @@ type ModifiedAxiosResponse = AxiosResponse & { } const baseRequestConfig: Partial = { + httpAgent: new http.Agent({ keepAlive: true }), + httpsAgent: new https.Agent({ keepAlive: true }), transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) { const contentType = headers.getContentType() || ''; const hasJSONContentType = contentType.includes('json'); From f138b126f323e71226b2f9c400b6f079f5c76cad Mon Sep 17 00:00:00 2001 From: ganesh-bruno Date: Tue, 24 Jun 2025 19:24:15 +0530 Subject: [PATCH 10/22] removed text fron runtime var --- packages/bruno-app/src/components/VariablesEditor/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bruno-app/src/components/VariablesEditor/index.js b/packages/bruno-app/src/components/VariablesEditor/index.js index a06b6a1ff..5d9de8b59 100644 --- a/packages/bruno-app/src/components/VariablesEditor/index.js +++ b/packages/bruno-app/src/components/VariablesEditor/index.js @@ -96,7 +96,6 @@ const VariablesEditor = ({ collection }) => {
Note: As of today, runtime variables can only be set via the API - getVar(){' '} and setVar().
- In the next release, we will add a UI to set and modify runtime variables.
); From 6244679d5b7f0b7637515ef0e6d9792d37cf4e1e Mon Sep 17 00:00:00 2001 From: Bacteria <32611672+bacteriostat@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:30:22 +0000 Subject: [PATCH 11/22] Merge pull request #4956 from bacteriostat/feature/single-line-editor-placeholder feat: Add placeholder for SingleLineEditor --- .../src/components/SingleLineEditor/StyledWrapper.js | 5 +++++ packages/bruno-app/src/components/SingleLineEditor/index.js | 1 + packages/bruno-app/src/themes/dark.js | 4 ++++ packages/bruno-app/src/themes/light.js | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js b/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js index 592a75b28..3398cb5ff 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js @@ -26,6 +26,11 @@ const StyledWrapper = styled.div` .CodeMirror-lines { padding: 0; + + .CodeMirror-placeholder { + color: ${(props) => props.theme.codemirror.placeholder.color} !important; + opacity: ${(props) => props.theme.codemirror.placeholder.opacity} !important + } } .CodeMirror-cursor { diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index a82bfe94a..435a2a5c7 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -46,6 +46,7 @@ class SingleLineEditor extends Component { const noopHandler = () => {}; this.editor = CodeMirror(this.editorRef.current, { + placeholder: this.props.placeholder ?? '', lineWrapping: false, lineNumbers: false, theme: this.props.theme === 'dark' ? 'monokai' : 'default', diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 04ee6134e..ec2e8d212 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -248,6 +248,10 @@ const darkTheme = { codemirror: { bg: '#1e1e1e', border: '#373737', + placeholder: { + color: '#a2a2a2', + opacity: 0.50 + }, gutter: { bg: '#262626' }, diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 55b1d0eaf..cdcb8de26 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -249,6 +249,10 @@ const lightTheme = { codemirror: { bg: 'white', border: '#efefef', + placeholder: { + color: '#a2a2a2', + opacity: 0.75 + }, gutter: { bg: '#f3f3f3' }, From 3a92cb4eda479916b7b2e07391452b274577b728 Mon Sep 17 00:00:00 2001 From: ganesh Date: Wed, 25 Jun 2025 16:08:42 +0530 Subject: [PATCH 12/22] Fix: Made `reporter-skip-headers` option case-insensitive in bruno-cli (#4799) --- packages/bruno-cli/src/commands/run.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 3d4bfb6c7..ccbfa8581 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -529,9 +529,11 @@ const handler = async function (argv) { } const deleteHeaderIfExists = (headers, header) => { - if (headers && headers[header]) { - delete headers[header]; - } + Object.keys(headers).forEach((key) => { + if (key.toLowerCase() === header.toLowerCase()) { + delete headers[key]; + } + }); }; if (reporterSkipHeaders?.length) { From 4d7c044ebabac12850f1c0c1b44c7c643d2b6df5 Mon Sep 17 00:00:00 2001 From: Pooja Date: Wed, 25 Jun 2025 20:25:53 +0530 Subject: [PATCH 13/22] Fix: undefined auth fields in folder-level authentication (#4907) --- .../RequestPane/Auth/AwsV4Auth/index.js | 70 +++++++++---------- .../RequestPane/Auth/BasicAuth/index.js | 8 +-- .../RequestPane/Auth/DigestAuth/index.js | 8 +-- .../RequestPane/Auth/NTLMAuth/index.js | 18 ++--- .../RequestPane/Auth/WsseAuth/index.js | 8 +-- 5 files changed, 56 insertions(+), 56 deletions(-) diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js index 75469d784..35ae35d7a 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js @@ -28,11 +28,11 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { itemUid: item.uid, content: { accessKeyId: accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -45,12 +45,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -63,12 +63,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -81,12 +81,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: service, - region: awsv4Auth.region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: service || '', + region: awsv4Auth.region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -99,12 +99,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: region, - profileName: awsv4Auth.profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: region || '', + profileName: awsv4Auth.profileName || '' } }) ); @@ -117,12 +117,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - accessKeyId: awsv4Auth.accessKeyId, - secretAccessKey: awsv4Auth.secretAccessKey, - sessionToken: awsv4Auth.sessionToken, - service: awsv4Auth.service, - region: awsv4Auth.region, - profileName: profileName + accessKeyId: awsv4Auth.accessKeyId || '', + secretAccessKey: awsv4Auth.secretAccessKey || '', + sessionToken: awsv4Auth.sessionToken || '', + service: awsv4Auth.service || '', + region: awsv4Auth.region || '', + profileName: profileName || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js index ef714f528..752d7ce33 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js @@ -26,8 +26,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: username, - password: basicAuth.password + username: username || '', + password: basicAuth.password || '' } }) ); @@ -40,8 +40,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: basicAuth.username, - password: password + username: basicAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js index 50b92f669..a4ff3012e 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js @@ -25,8 +25,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: username, - password: digestAuth.password + username: username || '', + password: digestAuth.password || '' } }) ); @@ -39,8 +39,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: digestAuth.username, - password: password + username: digestAuth.username || '', + password: password || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js index 1164fb903..44f87656e 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js @@ -26,9 +26,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: username, - password: ntlmAuth.password, - domain: ntlmAuth.domain + username: username || '', + password: ntlmAuth.password || '', + domain: ntlmAuth.domain || '' } }) ); @@ -41,9 +41,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: ntlmAuth.username, - password: password, - domain: ntlmAuth.domain + username: ntlmAuth.username || '', + password: password || '', + domain: ntlmAuth.domain || '' } }) ); @@ -56,9 +56,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: ntlmAuth.username, - password: ntlmAuth.password, - domain: domain + username: ntlmAuth.username || '', + password: ntlmAuth.password || '', + domain: domain || '' } }) ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js index ae201370e..05e9daaf1 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js @@ -26,8 +26,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username, - password: wsseAuth.password + username: username || '', + password: wsseAuth.password || '' } }) ); @@ -40,8 +40,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => { collectionUid: collection.uid, itemUid: item.uid, content: { - username: wsseAuth.username, - password + username: wsseAuth.username || '', + password: password || '' } }) ); From ff0ceb2879c77f47deba5f5150414913cd2c3434 Mon Sep 17 00:00:00 2001 From: Pooja Date: Wed, 25 Jun 2025 20:26:42 +0530 Subject: [PATCH 14/22] feat: add dropdown to select language and add lib selector in code gen (#4345) * feat: add dropdown to select language and add lib selector in code gen * add: checkbox for interpolation * rm: url should interpolate from url * add: search in dropdown * fixes * add: autofocus for search * add: arrow navigation in select * fix code improvements fix rm: editor wrapper rm: font-size improvement rm: custom select rm comments and add sparql mode rm: styles * add: tests and fixes * fixes: file naming * rm: comments * fix * fix: unit tests * improvements * fixes * fix: indentation * fix * fixes: CodeViewToolbar * trim: extra spaces --- .../CodeView/StyledWrapper.js | 46 +- .../GenerateCodeItem/CodeView/index.js | 73 ++- .../CodeViewToolbar/StyledWrapper.js | 117 +++++ .../GenerateCodeItem/CodeViewToolbar/index.js | 106 +++++ .../GenerateCodeItem/StyledWrapper.js | 74 ++- .../CollectionItem/GenerateCodeItem/index.js | 126 ++---- .../GenerateCodeItem/utils/auth-utils.js | 49 ++ .../GenerateCodeItem/utils/auth-utils.spec.js | 68 +++ .../GenerateCodeItem/utils/interpolation.js | 88 ++++ .../utils/interpolation.spec.js | 48 ++ .../utils/snippet-generator.js | 63 +++ .../utils/snippet-generator.spec.js | 421 ++++++++++++++++++ .../src/providers/ReduxStore/slices/app.js | 14 +- 13 files changed, 1103 insertions(+), 190 deletions(-) create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js index ff06f4f31..181a258ae 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js @@ -1,19 +1,59 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - position: relative; height: 100%; + position: relative; + + .editor-content { + height: 100%; + + .CodeMirror { + height: 100%; + font-size: 12px; + line-height: 1.5; + padding: 0; + + .CodeMirror-gutters { + background: ${props => props.theme.codemirror.gutter.bg}; + border-right: 1px solid ${props => props.theme.codemirror.border}; + } + + .CodeMirror-linenumber { + color: ${props => props.theme.colors.text.muted}; + font-size: 11px; + padding: 0 3px 0 5px; + } + + .CodeMirror-lines { + padding: 0; + } + + .CodeMirror-line { + padding: 0 4px; + } + } + } .copy-to-clipboard { position: absolute; - cursor: pointer; top: 10px; right: 10px; z-index: 10; - opacity: 0.5; + background: transparent; + border: none; + color: ${props => props.theme.colors.text.muted}; + cursor: pointer; + padding: 6px; + opacity: 0.7; + transition: all 0.2s ease; &:hover { opacity: 1; + color: ${props => props.theme.text}; + } + + &:active { + transform: translateY(1px); } } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js index ea3ed43a7..307cab01a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js @@ -1,64 +1,52 @@ import CodeEditor from 'components/CodeEditor/index'; import get from 'lodash/get'; -import { HTTPSnippet } from 'httpsnippet'; import { useTheme } from 'providers/Theme/index'; import StyledWrapper from './StyledWrapper'; -import { buildHarRequest } from 'utils/codegenerator/har'; import { useSelector } from 'react-redux'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import toast from 'react-hot-toast'; import { IconCopy } from '@tabler/icons'; -import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index'; -import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth'; +import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/collections/index'; import { cloneDeep } from 'lodash'; +import { useMemo } from 'react'; +import { generateSnippet } from '../utils/snippet-generator'; const CodeView = ({ language, item }) => { const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - const { target, client, language: lang } = language; - const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); - let _collection = findCollectionByItemUid( + const generateCodePrefs = useSelector((state) => state.app.generateCode); + + let collectionOriginal = findCollectionByItemUid( useSelector((state) => state.collections.collections), item.uid ); - let collection = cloneDeep(_collection); + const collection = useMemo(() => { + const c = cloneDeep(collectionOriginal); + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ + globalEnvironments, + activeGlobalEnvironmentUid + }); + c.globalEnvironmentVariables = globalEnvironmentVariables; + return c; + }, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]); - // add selected global env variables to the collection object - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - collection.globalEnvironmentVariables = globalEnvironmentVariables; - - const collectionRootAuth = collection?.root?.request?.auth; - const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth'); - - const headers = [ - ...getAuthHeaders(collectionRootAuth, requestAuth), - ...(collection?.root?.request?.headers || []), - ...(requestHeaders || []) - ]; - - let snippet = ''; - try { - snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert( - target, - client - ); - } catch (e) { - console.error(e); - snippet = 'Error generating code snippet'; - } + const snippet = useMemo(() => { + return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate }); + }, [language, item, collection, generateCodePrefs.shouldInterpolate]); return ( - <> - - toast.success('Copied to clipboard!')} - > + + toast.success('Copied to clipboard!')} + > + + +
{ font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} theme={displayedTheme} - mode={lang} + mode={language.language} + enableVariableHighlighting={true} /> - - +
+
); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js new file mode 100644 index 000000000..c73d2ae39 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js @@ -0,0 +1,117 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: ${props => props.theme.requestTabPanel.card.bg}; + border-bottom: 1px solid ${props => props.theme.requestTabPanel.card.border}; + gap: 12px; + flex-shrink: 0; + } + + .left-controls { + display: flex; + align-items: center; + gap: 12px; + } + + .select-wrapper { + position: relative; + display: flex; + align-items: center; + } + + .select-arrow { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: ${props => props.theme.colors.text.muted}; + } + + .native-select { + background: ${props => props.theme.requestTabPanel.url.bg}; + border: 1px solid ${props => props.theme.input.border}; + border-radius: 3px; + color: ${props => props.theme.text}; + font-size: 12px; + padding: 6px 28px 6px 10px; + min-width: 140px; + height: 32px; + cursor: pointer; + transition: all 0.2s ease; + appearance: none; + + &:hover { + border-color: ${props => props.theme.input.focusBorder}; + } + + &:focus { + outline: none; + border-color: ${props => props.theme.input.focusBorder}; + box-shadow: 0 0 0 2px ${props => props.theme.input.focusBoxShadow}; + } + + option { + background: ${props => props.theme.bg}; + color: ${props => props.theme.text}; + padding: 8px 12px; + } + } + + .library-options { + display: flex; + gap: 6px; + } + + .lib-btn { + height: 32px; + padding: 0 12px; + background: ${props => props.theme.requestTabPanel.url.bg}; + border: 1px solid ${props => props.theme.input.border}; + border-radius: 3px; + color: ${props => props.theme.text}; + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + + &:hover { + background: ${props => props.theme.dropdown.hoverBg}; + border-color: ${props => props.theme.input.focusBorder}; + } + + &.active { + background: ${props => props.theme.button.secondary.bg}; + border-color: ${props => props.theme.button.secondary.border}; + color: ${props => props.theme.button.secondary.color}; + } + } + + .right-controls { + .interpolate-checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: ${props => props.theme.text}; + + input[type="checkbox"] { + cursor: pointer; + margin: 0; + } + + &:hover { + opacity: 0.8; + } + } + } +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js new file mode 100644 index 000000000..2e63ce384 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js @@ -0,0 +1,106 @@ +import { IconChevronDown } from '@tabler/icons'; +import { useSelector, useDispatch } from 'react-redux'; +import { useMemo } from 'react'; +import { getLanguages } from 'utils/codegenerator/targets'; +import { updateGenerateCode } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from './StyledWrapper'; + +const CodeViewToolbar = () => { + const dispatch = useDispatch(); + const languages = getLanguages(); + const generateCodePrefs = useSelector((state) => state.app.generateCode); + + // Group languages by their main language type + const languageGroups = useMemo(() => { + return languages.reduce((acc, lang) => { + const mainLang = lang.name.split('-')[0]; + if (!acc[mainLang]) { + acc[mainLang] = []; + } + acc[mainLang].push({ + ...lang, + libraryName: lang.name.split('-')[1] || 'default' + }); + return acc; + }, {}); + }, [languages]); + + const mainLanguages = useMemo(() => Object.keys(languageGroups), [languageGroups]); + + const availableLibraries = useMemo(() => { + return languageGroups[generateCodePrefs.mainLanguage] || []; + }, [generateCodePrefs.mainLanguage, languageGroups]); + + // Event handlers + const handleMainLanguageChange = (e) => { + const newMainLang = e.target.value; + const defaultLibrary = languageGroups[newMainLang][0].libraryName; + + dispatch(updateGenerateCode({ + mainLanguage: newMainLang, + library: defaultLibrary + })); + }; + + const handleLibraryChange = (libraryName) => { + dispatch(updateGenerateCode({ + library: libraryName + })); + }; + + const handleInterpolateChange = (e) => { + dispatch(updateGenerateCode({ + shouldInterpolate: e.target.checked + })); + }; + + return ( + +
+
+
+ + +
+ + {availableLibraries.length > 1 && ( +
+ {availableLibraries.map((lib) => ( + + ))} +
+ )} +
+ +
+ +
+
+
+ ); +}; + +export default CodeViewToolbar; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js index 3d8ea1229..324e9ec3c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js @@ -1,60 +1,44 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - margin-inline: -1rem; - margin-block: -1.5rem; + margin: -1.5rem -1rem; + height: 50vh; + display: flex; + flex-direction: column; background-color: ${(props) => props.theme.collection.environment.settings.bg}; - .generate-code-sidebar { - background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg}; - border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight}; - max-height: 80vh; + .code-generator { + display: flex; + flex-direction: column; height: 100%; - overflow-y: auto; } - .generate-code-item { - min-width: 150px; - display: block; + .editor-container { + flex: 1; + overflow: hidden; position: relative; - cursor: pointer; - padding: 8px 10px; - border-left: solid 2px transparent; - text-decoration: none; + background: ${props => props.theme.bg}; + } - &:hover { - text-decoration: none; - background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg}; + .error-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: ${props => props.theme.colors.text.muted}; + text-align: center; + padding: 20px; + + h1 { + font-size: 14px; + margin-bottom: 8px; + color: ${props => props.theme.text}; } - } - .active { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important; - border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border}; - &:hover { - background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important; - } - } - - .flexible-container { - width: 100%; - } - - @media (max-width: 600px) { - .flexible-container { - width: 500px; - } - } - - @media (min-width: 601px) and (max-width: 1200px) { - .flexible-container { - width: 800px; - } - } - - @media (min-width: 1201px) { - .flexible-container { - width: 900px; + p { + font-size: 12px; + opacity: 0.8; } } `; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index f31caf9ab..aabaafcba 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -1,72 +1,30 @@ import Modal from 'components/Modal/index'; -import { useState } from 'react'; +import { useMemo } from 'react'; import CodeView from './CodeView'; +import CodeViewToolbar from './CodeViewToolbar'; import StyledWrapper from './StyledWrapper'; import { isValidUrl } from 'utils/url'; import { get } from 'lodash'; -import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections'; +import { + findEnvironmentInCollection +} from 'utils/collections'; import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index'; import { getLanguages } from 'utils/codegenerator/targets'; import { useSelector } from 'react-redux'; import { getGlobalEnvironmentVariables } from 'utils/collections/index'; - -const getTreePathFromCollectionToItem = (collection, _itemUid) => { - let path = []; - let item = findItemInCollection(collection, _itemUid); - while (item) { - path.unshift(item); - item = findParentItemInCollection(collection, item?.uid); - } - return path; -}; - -// Function to resolve inherited auth -const resolveInheritedAuth = (item, collection) => { - const request = item.draft?.request || item.request; - const authMode = request?.auth?.mode; - - // If auth is not inherit or no auth defined, return the request as is - if (!authMode || authMode !== 'inherit') { - return { - ...request - }; - } - - // Get the tree path from collection to item - const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid); - - // Default to collection auth - const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' }); - let effectiveAuth = collectionAuth; - let source = 'collection'; - - // Check folders in reverse to find the closest auth configuration - for (let i of [...requestTreePath].reverse()) { - if (i.type === 'folder') { - const folderAuth = get(i, 'root.request.auth'); - if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { - effectiveAuth = folderAuth; - source = 'folder'; - break; - } - } - } - - return { - ...request, - auth: effectiveAuth - }; -}; +import { resolveInheritedAuth } from './utils/auth-utils'; const GenerateCodeItem = ({ collectionUid, item, onClose }) => { const languages = getLanguages(); - const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); - const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - + const generateCodePrefs = useSelector((state) => state.app.generateCode); + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ + globalEnvironments, + activeGlobalEnvironmentUid + }); const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid); + let envVars = {}; if (environment) { const vars = get(environment, 'variables', []); @@ -79,7 +37,6 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => { const requestUrl = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url'); - // interpolate the url const interpolatedUrl = interpolateUrl({ url: requestUrl, globalEnvironmentVariables, @@ -94,54 +51,27 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => { get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params') ); + // Get the full language object based on current preferences + const selectedLanguage = useMemo(() => { + const fullName = generateCodePrefs.library === 'default' + ? generateCodePrefs.mainLanguage + : `${generateCodePrefs.mainLanguage}-${generateCodePrefs.library}`; + + return languages.find(lang => lang.name === fullName) || languages[0]; + }, [generateCodePrefs.mainLanguage, generateCodePrefs.library, languages]); + // Resolve auth inheritance const resolvedRequest = resolveInheritedAuth(item, collection); - const [selectedLanguage, setSelectedLanguage] = useState(languages[0]); return ( -
-
-
- {languages && - languages.length && - languages.map((language) => ( -
setSelectedLanguage(language)} - onKeyDown={(e) => { - if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) { - e.preventDefault(); - const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name); - const nextIndex = e.shiftKey - ? (currentIndex - 1 + languages.length) % languages.length - : (currentIndex + 1) % languages.length; - setSelectedLanguage(languages[nextIndex]); +
+ - // Explicitly focus on the new active element - const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`); - nextElement?.focus(); - } - - }} - data-language={language.name} - aria-pressed={language.name === selectedLanguage.name} - > - {language.name} -
- ))} -
-
-
+
{isValidUrl(finalUrl) ? ( { }} /> ) : ( -
-
-

Invalid URL: {finalUrl}

-

Please check the URL and try again

-
+
+

Invalid URL: {finalUrl}

+

Please check the URL and try again

)}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js new file mode 100644 index 000000000..25a392e8c --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js @@ -0,0 +1,49 @@ +import { get } from 'lodash'; +import { + findItemInCollection, + findParentItemInCollection +} from 'utils/collections'; + +export const getTreePathFromCollectionToItem = (collection, _itemUid) => { + let path = []; + let item = findItemInCollection(collection, _itemUid); + while (item) { + path.unshift(item); + item = findParentItemInCollection(collection, item?.uid); + } + return path; +}; + +// Resolve inherited auth by traversing up the folder hierarchy +export const resolveInheritedAuth = (item, collection) => { + const request = item.draft?.request || item.request; + const authMode = request?.auth?.mode; + + // If auth is not inherit or no auth defined, return the request as is + if (!authMode || authMode !== 'inherit') { + return request; + } + + // Get the tree path from collection to item + const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid); + + // Default to collection auth + const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' }); + let effectiveAuth = collectionAuth; + + // Check folders in reverse to find the closest auth configuration + for (let i of [...requestTreePath].reverse()) { + if (i.type === 'folder') { + const folderAuth = get(i, 'root.request.auth'); + if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') { + effectiveAuth = folderAuth; + break; + } + } + } + + return { + ...request, + auth: effectiveAuth + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js new file mode 100644 index 000000000..407f2af87 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js @@ -0,0 +1,68 @@ +import { resolveInheritedAuth } from './auth-utils'; + +// Helper to build mock collection structure +const buildCollection = () => { + return { + uid: 'c1', + root: { + request: { + auth: { mode: 'bearer', bearer: { token: 'COLLECTION' } } + } + }, + items: [ + { + uid: 'f1', + type: 'folder', + name: 'Folder', + root: { + request: { + auth: { mode: 'basic', basic: { username: 'user', password: 'pass' } } + } + }, + items: [ + { + uid: 'r1', + type: 'request', + name: 'Request', + request: { + auth: { mode: 'inherit' }, + url: 'http://example.com', + method: 'GET' + } + } + ] + } + ] + }; +}; + +describe('auth-utils.resolveInheritedAuth', () => { + it('should resolve to nearest folder auth when request mode is inherit', () => { + const collection = buildCollection(); + const item = collection.items[0].items[0]; // r1 + + const resolved = resolveInheritedAuth(item, collection); + expect(resolved.auth.mode).toBe('basic'); + expect(resolved.auth.basic.username).toBe('user'); + }); + + it('should resolve to collection auth if no folder auth', () => { + const collection = buildCollection(); + collection.items[0].root.request.auth = { mode: 'inherit' }; + const item = collection.items[0].items[0]; + + const resolved = resolveInheritedAuth(item, collection); + expect(resolved.auth.mode).toBe('bearer'); + expect(resolved.auth.bearer.token).toBe('COLLECTION'); + }); + + it('should return original request when mode is not inherit', () => { + const collection = buildCollection(); + const item = collection.items[0].items[0]; + item.request.auth = { mode: 'basic', basic: { username: 'override', password: 'pwd' } }; + + const resolved = resolveInheritedAuth(item, collection); + expect(resolved.auth.mode).toBe('basic'); + expect(resolved.auth.basic.username).toBe('override'); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js new file mode 100644 index 000000000..b9aa5ba2e --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js @@ -0,0 +1,88 @@ +import { interpolate } from '@usebruno/common'; +import { cloneDeep } from 'lodash'; + +export const interpolateHeaders = (headers = [], variables = {}) => { + return headers.map((header) => ({ + ...header, + name: interpolate(header.name, variables), + value: interpolate(header.value, variables) + })); +}; + +export const interpolateBody = (body, variables = {}) => { + if (!body) return null; + + const interpolatedBody = cloneDeep(body); + + switch (body.mode) { + case 'json': + let parsed = body.json; + // If it's already a string, use it directly; if it's an object, stringify it first + if (typeof parsed === 'object') { + parsed = JSON.stringify(parsed); + } + parsed = interpolate(parsed, variables, { escapeJSONStrings: true }); + try { + const jsonObj = JSON.parse(parsed); + interpolatedBody.json = JSON.stringify(jsonObj, null, 2); + } catch { + interpolatedBody.json = parsed; + } + break; + + case 'text': + interpolatedBody.text = interpolate(body.text, variables); + break; + + case 'xml': + interpolatedBody.xml = interpolate(body.xml, variables); + break; + + case 'sparql': + interpolatedBody.sparql = interpolate(body.sparql, variables); + break; + + case 'formUrlEncoded': + interpolatedBody.formUrlEncoded = body.formUrlEncoded.map((param) => ({ + ...param, + value: param.enabled ? interpolate(param.value, variables) : param.value + })); + break; + + case 'multipartForm': + interpolatedBody.multipartForm = body.multipartForm.map((param) => ({ + ...param, + value: + param.type === 'text' && param.enabled + ? interpolate(param.value, variables) + : param.value + })); + break; + + default: + break; + } + + return interpolatedBody; +}; + +export const createVariablesObject = ({ + globalEnvironmentVariables = {}, + collectionVars = {}, + allVariables = {}, + collection = {}, + runtimeVariables = {}, + processEnvVars = {} +}) => { + return { + ...globalEnvironmentVariables, + ...allVariables, + ...collectionVars, + ...runtimeVariables, + process: { + env: { + ...processEnvVars + } + } + }; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js new file mode 100644 index 000000000..8c5920b76 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js @@ -0,0 +1,48 @@ +import { interpolateHeaders, interpolateBody } from './interpolation'; + +describe('interpolation utils', () => { + describe('interpolateHeaders', () => { + it('should interpolate variables in header name and value while preserving other props', () => { + const headers = [ + { uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true } + ]; + const variables = { var: 'test' }; + + const result = interpolateHeaders(headers, variables); + expect(result).toEqual([ + { + uid: '1', + name: 'X-test', + value: 'value-test', + enabled: true + } + ]); + }); + }); + + describe('interpolateBody', () => { + it('should interpolate JSON body strings and keep formatting', () => { + const body = { + mode: 'json', + json: '{"name": "{{username}}"}' + }; + const variables = { username: 'bruno' }; + + const result = interpolateBody(body, variables); + expect(result.json).toBe('{\n "name": "bruno"\n}'); + }); + + it('should interpolate text body', () => { + const body = { + mode: 'text', + text: 'Hello {{name}}' + }; + const result = interpolateBody(body, { name: 'World' }); + expect(result.text).toBe('Hello World'); + }); + + it('should return null when body is null', () => { + expect(interpolateBody(null, { a: 1 })).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js new file mode 100644 index 000000000..6be76f170 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -0,0 +1,63 @@ +import { buildHarRequest } from 'utils/codegenerator/har'; +import { getAuthHeaders } from 'utils/codegenerator/auth'; +import { getAllVariables } from 'utils/collections/index'; +import { interpolateHeaders, interpolateBody, createVariablesObject } from './interpolation'; +import { resolveInheritedAuth } from './auth-utils'; + +const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => { + try { + // Get HTTPSnippet dynamically so mocks can be applied in tests + const { HTTPSnippet } = require('httpsnippet'); + + const allVariables = getAllVariables(collection, item); + + // Create variables object for interpolation + const variables = createVariablesObject({ + globalEnvironmentVariables: collection.globalEnvironmentVariables || {}, + collectionVars: collection.collectionVars || {}, + allVariables, + collection, + runtimeVariables: collection.runtimeVariables || {}, + processEnvVars: collection.processEnvVariables || {} + }); + + // Get the request with resolved auth + const request = resolveInheritedAuth(item, collection); + + // Prepare headers + let headers = [...(request.headers || [])]; + + // Add auth headers if needed + if (request.auth && request.auth.mode !== 'none') { + const authHeaders = getAuthHeaders(request.auth, variables); + headers = [...headers, ...authHeaders]; + } + + // Interpolate headers and body if needed + if (shouldInterpolate) { + headers = interpolateHeaders(headers, variables); + if (request.body) { + request.body = interpolateBody(request.body, variables); + } + } + + // Build HAR request + const harRequest = buildHarRequest({ + request, + headers + }); + + // Generate snippet using HTTPSnippet + const snippet = new HTTPSnippet(harRequest); + const result = snippet.convert(language.target, language.client); + + return result; + } catch (error) { + console.error('Error generating code snippet:', error); + return 'Error generating code snippet'; + } +}; + +export { + generateSnippet +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js new file mode 100644 index 000000000..b765f3026 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -0,0 +1,421 @@ +jest.mock('httpsnippet', () => { + return { + HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({ + convert: jest.fn(() => { + const method = harRequest?.method || 'GET'; + const url = harRequest?.url || 'http://example.com'; + const hasBody = harRequest?.postData?.text; + + if (method === 'POST' && hasBody) { + return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`; + } + return `curl -X ${method} ${url}`; + }) + })) + }; +}); + +jest.mock('utils/codegenerator/har', () => ({ + buildHarRequest: jest.fn((data) => { + const request = data.request || {}; + const method = request.method || 'GET'; + const url = request.url || 'http://example.com'; + const body = request.body || {}; + + const harRequest = { + method: method, + url: url, + headers: data.headers || [], + httpVersion: 'HTTP/1.1' + }; + + // Add body data for POST requests + if (method === 'POST' && body.mode === 'json' && body.json) { + harRequest.postData = { + mimeType: 'application/json', + text: body.json + }; + } + + return harRequest; + }) +})); + +jest.mock('utils/codegenerator/auth', () => ({ + getAuthHeaders: jest.fn(() => []) +})); + +jest.mock('utils/collections/index', () => ({ + getAllVariables: jest.fn(() => ({ + baseUrl: 'https://api.example.com', + apiKey: 'secret-key-123', + userId: '12345' + })) +})); + +import { generateSnippet } from './snippet-generator'; + +describe('Snippet Generator - Simple Tests', () => { + + // Simple test request - easy to understand + const testRequest = { + uid: 'test-request-123', + name: 'test api call', + type: 'http-request', + request: { + method: 'POST', + url: 'https://api.example.com/{{endpoint}}', + headers: [ + { uid: 'h1', name: 'Authorization', value: 'Bearer {{apiToken}}', enabled: true }, + { uid: 'h2', name: 'Content-Type', value: 'application/json', enabled: true }, + { uid: 'h3', name: 'X-Custom', value: '{{customValue}}', enabled: true } + ], + body: { + mode: 'json', + json: '{"message": "{{greeting}}", "count": {{number}}}' + }, + auth: { mode: 'none' }, + assertions: [], + tests: '', + docs: '', + params: [], + vars: { req: [] } + } + }; + + const testCollection = { + root: { + request: { + auth: { mode: 'none' }, + headers: [] + } + }, + globalEnvironmentVariables: { + endpoint: 'data', + apiToken: 'token123', + customValue: 'test-value', + greeting: 'Hello World', + number: 42 + }, + runtimeVariables: {}, + processEnvVariables: {} + }; + + const curlLanguage = { target: 'shell', client: 'curl' }; + + beforeEach(() => { + jest.clearAllMocks(); + require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({ + convert: jest.fn(() => { + const method = harRequest?.method || 'GET'; + const url = harRequest?.url || 'http://example.com'; + const hasBody = harRequest?.postData?.text; + + if (method === 'POST' && hasBody) { + return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`; + } + return `curl -X ${method} ${url}`; + }) + })); + }); + + it('should generate curl for POST request with JSON body', () => { + const result = generateSnippet({ + language: curlLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe('curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"message": "{{greeting}}", "count": {{number}}}\''); + }); + + it('should interpolate variables when enabled', () => { + const result = generateSnippet({ + language: curlLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: true + }); + + const expectedBody = `{ + "message": "Hello World", + "count": 42 +}`; + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`); + }); + + it('should handle GET requests', () => { + const getRequest = { + ...testRequest, + request: { + ...testRequest.request, + method: 'GET', + body: { mode: 'none' } + } + }; + + const result = generateSnippet({ + language: curlLanguage, + item: getRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe('curl -X GET https://api.example.com/{{endpoint}}'); + }); + + it('should handle requests with different headers', () => { + const requestWithDifferentHeaders = { + ...testRequest, + request: { + ...testRequest.request, + headers: [ + { uid: 'h1', name: 'X-API-Key', value: '{{apiKey}}', enabled: true }, + { uid: 'h2', name: 'Accept', value: 'application/json', enabled: true }, + { uid: 'h3', name: 'User-Agent', value: 'TestApp/{{version}}', enabled: true } + ] + } + }; + + const collectionWithDifferentVars = { + ...testCollection, + globalEnvironmentVariables: { + ...testCollection.globalEnvironmentVariables, + apiKey: 'secret-key-456', + version: '1.0.0' + } + }; + + const result = generateSnippet({ + language: curlLanguage, + item: requestWithDifferentHeaders, + collection: collectionWithDifferentVars, + shouldInterpolate: true + }); + + // Body should have interpolated variables with proper formatting + const expectedBody = `{ + "message": "Hello World", + "count": 42 +}`; + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`); + }); + + it('should handle complex nested JSON body', () => { + const complexBody = { + user: { + name: '{{userName}}', + settings: { + theme: '{{userTheme}}', + active: true + } + }, + data: { + items: ['{{item1}}', '{{item2}}'], + total: '{{totalCount}}' + } + }; + + const requestWithComplexBody = { + ...testRequest, + request: { + ...testRequest.request, + body: { + mode: 'json', + json: JSON.stringify(complexBody, null, 2) + } + } + }; + + const collectionWithComplexVars = { + ...testCollection, + globalEnvironmentVariables: { + ...testCollection.globalEnvironmentVariables, + userName: 'Alice', + userTheme: 'dark', + item1: 'first', + item2: 'second', + totalCount: 100 + } + }; + + const result = generateSnippet({ + language: curlLanguage, + item: requestWithComplexBody, + collection: collectionWithComplexVars, + shouldInterpolate: true + }); + + const expectedComplexBody = JSON.stringify({ + user: { + name: 'Alice', + settings: { + theme: 'dark', + active: true + } + }, + data: { + items: ['first', 'second'], + total: '100' + } + }, null, 2); + + expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`); + }); + + it('should handle errors gracefully', () => { + // Set up the error mock after beforeEach has run + const originalHTTPSnippet = require('httpsnippet').HTTPSnippet; + require('httpsnippet').HTTPSnippet = jest.fn(() => { + throw new Error('Mock error!'); + }); + + const originalConsoleError = console.error; + console.error = jest.fn(); + + const result = generateSnippet({ + language: curlLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe('Error generating code snippet'); + + require('httpsnippet').HTTPSnippet = originalHTTPSnippet; + console.error = originalConsoleError; + }); + + it('should work with JavaScript language', () => { + const javascriptLanguage = { target: 'javascript', client: 'fetch' }; + + const expectedJavaScriptCode = `fetch("https://api.example.com/data", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ "message": "Hello World", "count": 42 }) +})`; + + const originalHTTPSnippet = require('httpsnippet').HTTPSnippet; + require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation(() => ({ + convert: jest.fn(() => expectedJavaScriptCode) + })); + + const result = generateSnippet({ + language: javascriptLanguage, + item: testRequest, + collection: testCollection, + shouldInterpolate: false + }); + + expect(result).toBe(expectedJavaScriptCode); + + // Restore the original mock + require('httpsnippet').HTTPSnippet = originalHTTPSnippet; + }); + + it('should interpolate simple headers and body variables', () => { + const simpleTestRequest = { + uid: 'test-123', + name: 'simple test', + type: 'http-request', + request: { + method: 'POST', + url: 'https://api.test.com/{{endpoint}}', + headers: [ + { uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true }, + { uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true }, + { uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true } + ], + body: { + mode: 'json', + json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}' + } + } + }; + + // Simple collection with clear variable values + const simpleTestCollection = { + root: { + request: { + auth: { mode: 'none' }, + headers: [] + } + }, + globalEnvironmentVariables: { + endpoint: 'users', + token: 'abc123token', + userId: 'user456', + userName: 'John Smith', + userEmail: 'john@test.com', + userAge: 30 + }, + runtimeVariables: {}, + processEnvVariables: {} + }; + + const result = generateSnippet({ + language: curlLanguage, + item: simpleTestRequest, + collection: simpleTestCollection, + shouldInterpolate: true + }); + + const expectedInterpolatedBody = `{ + "name": "John Smith", + "email": "john@test.com", + "age": 30 +}`; + + expect(result).toBe(`curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`); + }); + + it('should NOT interpolate when shouldInterpolate is false', () => { + const simpleTestRequest = { + uid: 'test-123', + name: 'simple test', + type: 'http-request', + request: { + method: 'POST', + url: 'https://api.test.com/{{endpoint}}', + headers: [ + { uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true }, + { uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true }, + { uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true } + ], + body: { + mode: 'json', + json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}' + } + } + }; + + const simpleTestCollection = { + root: { + request: { + auth: { mode: 'none' }, + headers: [] + } + }, + globalEnvironmentVariables: { + endpoint: 'users', + token: 'abc123token', + userId: 'user456', + userName: 'John Smith', + userEmail: 'john@test.com', + userAge: 30 + }, + runtimeVariables: {}, + processEnvVariables: {} + }; + + const result = generateSnippet({ + language: curlLanguage, + item: simpleTestRequest, + collection: simpleTestCollection, + shouldInterpolate: false + }); + + expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\''); + }); +}); \ No newline at end of file diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 0fde3c8b2..900cf24b6 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -25,6 +25,11 @@ const initialState = { codeFont: 'default' } }, + generateCode: { + mainLanguage: 'Shell', + library: 'curl', + shouldInterpolate: true + }, cookies: [], taskQueue: [], systemProxyEnvVariables: {} @@ -75,6 +80,12 @@ export const appSlice = createSlice({ }, updateSystemProxyEnvVariables: (state, action) => { state.systemProxyEnvVariables = action.payload; + }, + updateGenerateCode: (state, action) => { + state.generateCode = { + ...state.generateCode, + ...action.payload + }; } } }); @@ -93,7 +104,8 @@ export const { insertTaskIntoQueue, removeTaskFromQueue, removeAllTasksFromQueue, - updateSystemProxyEnvVariables + updateSystemProxyEnvVariables, + updateGenerateCode } = appSlice.actions; export const savePreferences = (preferences) => (dispatch, getState) => { From 5d51a528d7d90d693d02c4ea2c625286a2bae102 Mon Sep 17 00:00:00 2001 From: Pragadesh-45 Date: Thu, 26 Jun 2025 12:04:34 +0545 Subject: [PATCH 15/22] fix(cli): standardize quotes in qs.stringify for form-urlencoded data --- packages/bruno-cli/src/runner/run-single-request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 1019c4072..27fdfe010 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -333,7 +333,7 @@ const runSingleRequest = async function ( name => name.toLowerCase() === 'content-type' ); if (contentTypeHeader && request.headers[contentTypeHeader] === 'application/x-www-form-urlencoded') { - request.data = qs.stringify(request.data, { arrayFormat: "repeat" }); + request.data = qs.stringify(request.data, { arrayFormat: 'repeat' }); } if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') { From ef188050086c0429fb84d7b9e806e7f92b9e5722 Mon Sep 17 00:00:00 2001 From: Pragadesh-45 Date: Thu, 26 Jun 2025 12:05:02 +0545 Subject: [PATCH 16/22] fix(electron): standardize quotes in qs.stringify for form-urlencoded data --- packages/bruno-electron/src/ipc/network/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index e3b9be48a..10f6d129a 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -454,7 +454,7 @@ const registerNetworkIpc = (mainWindow) => { // stringify the request url encoded params if (request.headers['content-type'] === 'application/x-www-form-urlencoded') { - request.data = qs.stringify(request.data, { arrayFormat: "repeat" }); + request.data = qs.stringify(request.data, { arrayFormat: 'repeat' }); } if (request.headers['content-type'] === 'multipart/form-data') { From 6349e9b81644f9475a8162e8ea7f9ebfdd96383b Mon Sep 17 00:00:00 2001 From: lohit Date: Thu, 26 Jun 2025 15:53:14 +0530 Subject: [PATCH 17/22] fix: oauth2 tokenHeaderPrefix can be set to an empty string value (#4928) * ~ only prefill `Bearer` as token prefix only when the oauth2 is selected as the auth type for the first time ~ check if tokenPrefix is present before adding a space before the access_token value in the header * review comment fixes --------- Co-authored-by: lohit --- packages/bruno-app/src/utils/collections/index.js | 6 +++--- packages/bruno-cli/src/runner/run-single-request.js | 6 +++--- packages/bruno-electron/src/ipc/network/index.js | 12 ++++++------ packages/bruno-lang/v2/src/bruToJson.js | 6 +++--- packages/bruno-lang/v2/src/collectionBruToJson.js | 6 +++--- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 61ce02f50..5b0d28026 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -314,7 +314,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'), credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), - tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), + tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), @@ -334,7 +334,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} pkce: get(si.request, 'auth.oauth2.pkce', false), credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), - tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), + tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), @@ -351,7 +351,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'), credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'), tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'), - tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'), + tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''), tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''), autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true), autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true), diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 27fdfe010..50aaf823b 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -357,10 +357,10 @@ const runSingleRequest = async function ( try { const token = await getOAuth2Token(request.oauth2); if (token) { - const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2; + const { tokenPlacement = 'header', tokenHeaderPrefix = '', tokenQueryKey = 'access_token' } = request.oauth2; - if (tokenPlacement === 'header') { - request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`; + if (tokenPlacement === 'header' && token) { + request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`.trim(); } else if (tokenPlacement === 'url') { try { const url = new URL(request.url); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 10f6d129a..4da75f9ec 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -206,8 +206,8 @@ const configureRequest = async ( interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; - if (tokenPlacement == 'header') { - request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; + if (tokenPlacement == 'header' && credentials?.access_token) { + request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); } else { try { @@ -222,8 +222,8 @@ const configureRequest = async ( interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; - if (tokenPlacement == 'header') { - request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; + if (tokenPlacement == 'header' && credentials?.access_token) { + request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); } else { try { @@ -238,8 +238,8 @@ const configureRequest = async ( interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; - if (tokenPlacement == 'header') { - request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`; + if (tokenPlacement == 'header' && credentials?.access_token) { + request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); } else { try { diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 819272240..e99b690c2 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -543,7 +543,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body', credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials', tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', - tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', + tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false @@ -563,7 +563,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body', credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials', tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', - tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', + tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false @@ -579,7 +579,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body', credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials', tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', - tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', + tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index e92dcaa88..73f5af1a8 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -303,7 +303,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body', credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials', tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', - tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', + tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false @@ -323,7 +323,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body', credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials', tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', - tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', + tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false @@ -339,7 +339,7 @@ const sem = grammar.createSemantics().addAttribute('ast', { credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body', credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials', tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header', - tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer', + tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '', tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token', autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true, autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false From 5065b2ac379a7bf52614d53225a4f5af02dac48d Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Thu, 26 Jun 2025 17:18:29 +0530 Subject: [PATCH 18/22] fix: oauth2 scope --- packages/bruno-electron/src/utils/oauth2.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index beaee1c21..dd8594728 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -141,7 +141,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo if (pkce) { data['code_verifier'] = codeVerifier; } - if (scope) { + if (scope && scope.trim() !== '') { data.scope = scope; } requestCopy.data = qs.stringify(data); @@ -344,7 +344,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo if (clientSecret && credentialsPlacement !== "basic_auth_header") { data.client_secret = clientSecret; } - if (scope) { + if (scope && scope.trim() !== '') { data.scope = scope; } requestCopy.data = qs.stringify(data); @@ -515,7 +515,7 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, if (clientSecret && credentialsPlacement !== "basic_auth_header") { data.client_secret = clientSecret; } - if (scope) { + if (scope && scope.trim() !== '') { data.scope = scope; } requestCopy.data = qs.stringify(data); From 535865fdebc4d029e9a183429b4f8599ac51897f Mon Sep 17 00:00:00 2001 From: Maintainer Bruno Date: Fri, 27 Jun 2025 00:08:10 +0530 Subject: [PATCH 19/22] fix(import): handle repeated query keys and improve error handling in curl import --- packages/bruno-app/src/utils/curl/curl-to-json.js | 8 ++++++-- packages/bruno-app/src/utils/curl/index.js | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js index 72aa22812..4897f5a2d 100644 --- a/packages/bruno-app/src/utils/curl/curl-to-json.js +++ b/packages/bruno-app/src/utils/curl/curl-to-json.js @@ -26,7 +26,7 @@ function getQueries(request) { const rawValue = request.query[paramName]; let paramValue; if (Array.isArray(rawValue)) { - paramValue = rawValue.map(repr); + paramValue = rawValue.map(value => repr(value, false)); } else { paramValue = repr(rawValue); } @@ -139,6 +139,10 @@ function getFilesString(request) { const curlToJson = (curlCommand) => { const request = parseCurlCommand(curlCommand); + if (!request?.url) { + return null; + } + const requestJson = {}; // curl automatically prepends 'http' if the scheme is missing, but python fails and returns an error @@ -207,7 +211,7 @@ const curlToJson = (curlCommand) => { } } - return Object.keys(requestJson).length ? requestJson : {}; + return Object.keys(requestJson).length ? requestJson : null; }; export default curlToJson; diff --git a/packages/bruno-app/src/utils/curl/index.js b/packages/bruno-app/src/utils/curl/index.js index 9a986b4de..0b4d894cd 100644 --- a/packages/bruno-app/src/utils/curl/index.js +++ b/packages/bruno-app/src/utils/curl/index.js @@ -34,6 +34,10 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque } const request = curlToJson(curlCommand); + if (!request || !request.url) { + return null; + } + const parsedHeaders = request?.headers; const headers = parsedHeaders && From 47e420dec17bd5936aaed0a8bff3c0178e637d4e Mon Sep 17 00:00:00 2001 From: Maintainer Bruno Date: Fri, 27 Jun 2025 13:00:52 +0530 Subject: [PATCH 20/22] fix(layout): minor layout css fixes --- .../RequestPane/RequestBody/RequestBodyMode/index.js | 4 ++-- .../src/components/RequestTabPanel/StyledWrapper.js | 10 +++++++++- .../bruno-app/src/components/ResponsePane/index.js | 4 ++-- .../bruno-app/src/components/Table/StyledWrapper.js | 3 --- packages/bruno-app/src/components/Table/index.js | 2 +- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js index db73597df..caeb555ff 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js @@ -21,7 +21,7 @@ const RequestBodyMode = ({ item, collection }) => { const Icon = forwardRef((props, ref) => { return (
- {humanizeRequestBodyMode(bodyMode)} + {humanizeRequestBodyMode(bodyMode)}
); }); @@ -149,7 +149,7 @@ const RequestBodyMode = ({ item, collection }) => {
{(bodyMode === 'json' || bodyMode === 'xml') && ( - )} diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js index 8339af6dc..3bee80c40 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js @@ -32,17 +32,25 @@ const StyledWrapper = styled.div` } &.vertical-layout { + .request-pane { + padding-bottom: 0.5rem; + } + + .response-pane { + padding-top: 0.5rem; + } + div.dragbar-wrapper { width: 100%; height: 10px; cursor: row-resize; + padding: 0 1rem; div.dragbar-handle { width: 100%; height: 1px; border-left: none; border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; - margin-top: 0.5rem; } &:hover div.dragbar-handle { diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index 5d85ce086..447eab43b 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -133,7 +133,7 @@ const ResponsePane = ({ item, collection }) => { return ( -
+
selectTab('response')}> Response
@@ -176,7 +176,7 @@ const ResponsePane = ({ item, collection }) => { ) : null}
columns?.[0]?.width diff --git a/packages/bruno-app/src/components/Table/index.js b/packages/bruno-app/src/components/Table/index.js index 7c9b48d7d..4944276c6 100644 --- a/packages/bruno-app/src/components/Table/index.js +++ b/packages/bruno-app/src/components/Table/index.js @@ -86,7 +86,7 @@ const Table = ({ minColumnWidth = 1, headers = [], children }) => { return (
-
+
{columns.map(({ ref, name }, i) => ( From e8eab46f482f74addf5295a150ba8cd0cd64bde4 Mon Sep 17 00:00:00 2001 From: Chris Casola Date: Fri, 20 Oct 2023 13:43:11 -0400 Subject: [PATCH 21/22] feat: add bulk edit mode for request headers Closes #185 --- .../RequestHeaders/StyledWrapper.js | 12 ++++++++++- .../RequestPane/RequestHeaders/index.js | 4 ++-- .../ReduxStore/slices/collections/index.js | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js index 5b787e8bb..c76dcb57e 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js @@ -22,10 +22,20 @@ const Wrapper = styled.div` } } - .btn-add-header { + .top-controls { + display: flex; + justify-content: right; font-size: 0.8125rem; } + .bottom-controls { + font-size: 0.8125rem; + } + + div.CodeMirror { + height: 100%; + } + input[type='text'] { width: 100%; border: solid 1px transparent; diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index d88318017..6c75e2e93 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import get from 'lodash/get'; import cloneDeep from 'lodash/cloneDeep'; import { IconTrash } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index df1fc63bc..ffb915cb2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -785,6 +785,26 @@ export const collectionsSlice = createSlice({ } } }, + setRequestHeaders: (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.headers = map(action.payload.headers, (header) => ({ + uid: uuid(), + name: header.name, + value: header.value, + description: '', + enabled: true + })); + } + } + }, addFormUrlEncodedParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -2281,6 +2301,7 @@ export const { updateRequestHeader, deleteRequestHeader, moveRequestHeader, + setRequestHeaders, addFormUrlEncodedParam, updateFormUrlEncodedParam, deleteFormUrlEncodedParam, From f2b5b6f7835cf5293d3af13740543592a019325c Mon Sep 17 00:00:00 2001 From: sanjai0py Date: Sun, 22 Jun 2025 22:24:19 +0530 Subject: [PATCH 22/22] refactor: implementation of bulk edit functionality for query parameters and request headers refactor: integrate BulkEditCodeEditor for bulk editing of query parameters and request headers refactor: refactor BulkEditCodeEditor component folder structure nad fix Bulk Edit button styles refactor: now the queryparams are updated in both the ways style: fix indentation reverting the style changes which fixes the alignment of the bulkedit button refactor: add onSave prop to BulkEditCodeEditor and update value handling feat: add onSave prop to BulkEditCodeEditor for improved header management added onRun prop to BulkEditCodeEditor, QueryParams, and RequestHeaders refactor: renamed BulkEditCodeEditor to BulkEditor and update the references, and updated names for bulkEdit states --- .../src/components/BulkEditor/index.js | 40 ++++++++++ .../RequestPane/QueryParams/StyledWrapper.js | 2 +- .../RequestPane/QueryParams/index.js | 44 +++++++++-- .../RequestHeaders/StyledWrapper.js | 15 +--- .../RequestPane/RequestHeaders/index.js | 39 +++++++++- .../ReduxStore/slices/collections/index.js | 78 +++++++++++++++---- .../src/utils/common/bulkKeyValueUtils.js | 20 +++++ 7 files changed, 200 insertions(+), 38 deletions(-) create mode 100644 packages/bruno-app/src/components/BulkEditor/index.js create mode 100644 packages/bruno-app/src/utils/common/bulkKeyValueUtils.js diff --git a/packages/bruno-app/src/components/BulkEditor/index.js b/packages/bruno-app/src/components/BulkEditor/index.js new file mode 100644 index 000000000..1739c963f --- /dev/null +++ b/packages/bruno-app/src/components/BulkEditor/index.js @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import CodeEditor from 'components/CodeEditor'; +import { useTheme } from 'providers/Theme'; +import { useSelector } from 'react-redux'; +import { parseBulkKeyValue, serializeBulkKeyValue } from 'utils/common/bulkKeyValueUtils'; + +const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => { + const preferences = useSelector((state) => state.app.preferences); + const { displayedTheme } = useTheme(); + + const parsedParams = useMemo(() => serializeBulkKeyValue(params), [params]); + + const handleEdit = (value) => { + const parsed = parseBulkKeyValue(value); + onChange(parsed); + }; + + return ( + <> +
+ +
+
+ +
+ + ); +}; + +export default BulkEditor; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js index b460c1b4f..9a23f2f9c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js @@ -31,7 +31,7 @@ const Wrapper = styled.div` } } - .btn-add-param { + .btn-action { font-size: 0.8125rem; &:hover span { text-decoration: underline; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 8fe1cd00b..0b1b9df9c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -1,16 +1,17 @@ -import React from 'react'; +import React, { useState } from 'react'; import get from 'lodash/get'; import cloneDeep from 'lodash/cloneDeep'; import InfoTip from 'components/InfoTip'; import { IconTrash } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { addQueryParam, updateQueryParam, deleteQueryParam, moveQueryParam, - updatePathParam + updatePathParam, + setQueryParams } from 'providers/ReduxStore/slices/collections'; import SingleLineEditor from 'components/SingleLineEditor'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection import StyledWrapper from './StyledWrapper'; import Table from 'components/Table/index'; import ReorderTable from 'components/ReorderTable'; +import BulkEditor from '../../BulkEditor'; const QueryParams = ({ item, collection }) => { const dispatch = useDispatch(); @@ -25,6 +27,8 @@ const QueryParams = ({ item, collection }) => { 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 [isBulkEditMode, setIsBulkEditMode] = useState(false); const handleAddQueryParam = () => { dispatch( @@ -113,6 +117,29 @@ const QueryParams = ({ item, collection }) => { ); }; + const toggleBulkEditMode = () => { + setIsBulkEditMode(!isBulkEditMode); + }; + + const handleBulkParamsChange = (newParams) => { + const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' })); + dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType })); + }; + + if (isBulkEditMode) { + return ( + + + + ); + } + return (
@@ -171,9 +198,14 @@ const QueryParams = ({ item, collection }) => {
- +
+ + +
Path diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js index c76dcb57e..86cb4e365 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js @@ -22,18 +22,11 @@ const Wrapper = styled.div` } } - .top-controls { - display: flex; - justify-content: right; + .btn-action { font-size: 0.8125rem; - } - - .bottom-controls { - font-size: 0.8125rem; - } - - div.CodeMirror { - height: 100%; + &:hover span { + text-decoration: underline; + } } input[type='text'] { diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index 6c75e2e93..ddcc62af2 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -4,7 +4,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { IconTrash } from '@tabler/icons'; import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; -import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections'; +import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import SingleLineEditor from 'components/SingleLineEditor'; import StyledWrapper from './StyledWrapper'; @@ -12,12 +12,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import Table from 'components/Table/index'; import ReorderTable from 'components/ReorderTable/index'; +import BulkEditor from '../../BulkEditor'; + const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const RequestHeaders = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); + + const [isBulkEditMode, setIsBulkEditMode] = useState(false); const addHeader = () => { dispatch( @@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => { ); }; + const toggleBulkEditMode = () => { + setIsBulkEditMode(!isBulkEditMode); + }; + + const handleBulkHeadersChange = (newHeaders) => { + dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders })); + }; + + if (isBulkEditMode) { + return ( + + + + ); + } + return ( { : null}
- +
+ + +
); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index ffb915cb2..9139ec599 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -579,7 +579,48 @@ export const collectionsSlice = createSlice({ } } }, + setQueryParams: (state, action) => { + const { collectionUid, itemUid, params } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) { + return; + } + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) { + return; + } + + if (!item.draft) { + item.draft = cloneDeep(item); + } + const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || []; + const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({ + uid: uuid(), + name, + value, + description: '', + type: 'query', + enabled + })); + + item.draft.request.params = [...newQueryParams, ...existingOtherParams]; + + // Update the request URL to reflect the new query params + const parts = splitOnFirst(item.draft.request.url, '?'); + const query = stringifyQueryParams( + filter(item.draft.request.params, (p) => p.enabled && p.type === 'query') + ); + + // If there are enabled query params, append them to the URL + if (query && query.length) { + item.draft.request.url = parts[0] + '?' + query; + } else { + // If no enabled query params, remove the query part from URL + item.draft.request.url = parts[0]; + } + }, moveQueryParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -786,24 +827,28 @@ export const collectionsSlice = createSlice({ } }, setRequestHeaders: (state, action) => { - const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + const { collectionUid, itemUid, headers } = action.payload; - if (collection) { - const item = findItemInCollection(collection, action.payload.itemUid); - - if (item && isItemARequest(item)) { - if (!item.draft) { - item.draft = cloneDeep(item); - } - item.draft.request.headers = map(action.payload.headers, (header) => ({ - uid: uuid(), - name: header.name, - value: header.value, - description: '', - enabled: true - })); - } + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) { + return; } + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) { + return; + } + + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({ + uid: uuid(), + name: name, + value: value, + description: '', + enabled: enabled + })); }, addFormUrlEncodedParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -2293,6 +2338,7 @@ export const { requestUrlChanged, updateAuth, addQueryParam, + setQueryParams, moveQueryParam, updateQueryParam, deleteQueryParam, diff --git a/packages/bruno-app/src/utils/common/bulkKeyValueUtils.js b/packages/bruno-app/src/utils/common/bulkKeyValueUtils.js new file mode 100644 index 000000000..b165c2f3f --- /dev/null +++ b/packages/bruno-app/src/utils/common/bulkKeyValueUtils.js @@ -0,0 +1,20 @@ +export function parseBulkKeyValue(value) { + return value + .split(/\r?\n/) + .map((pair) => { + const isEnabled = !pair.trim().startsWith('//'); + const cleanPair = pair.replace(/^\/\/\s*/, ''); + const sep = cleanPair.indexOf(':'); + if (sep < 0) return null; + return { + name: cleanPair.slice(0, sep).trim(), + value: cleanPair.slice(sep + 1).trim(), + enabled: isEnabled + }; + }) + .filter(Boolean); +} + +export function serializeBulkKeyValue(items) { + return items.map((item) => `${item.enabled ? '' : '//'}${item.name}:${item.value}`).join('\n'); +}