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..447eab43b 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 (
-
+
-
+
);
}
@@ -132,7 +133,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return (
-
+
selectTab('response')}>
Response
@@ -159,6 +160,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
onClick={() => setShowScriptErrorCard(true)}
/>
)}
+
{focusedTab?.responsePaneTab === "timeline" ? (
) : (item?.response && !item?.response?.error) ? (
@@ -174,7 +176,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
) : null}
{
) : null
) : (
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 5729da88b..34b779370 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}
showHintsFor={['variables']}
/>
-
- >
+
+
);
};
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/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 483ba2443..5b15b973b 100644
--- a/packages/bruno-app/src/components/SingleLineEditor/index.js
+++ b/packages/bruno-app/src/components/SingleLineEditor/index.js
@@ -41,6 +41,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/components/Table/StyledWrapper.js b/packages/bruno-app/src/components/Table/StyledWrapper.js
index eeead4ed2..53cddc0b2 100644
--- a/packages/bruno-app/src/components/Table/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Table/StyledWrapper.js
@@ -9,9 +9,6 @@ const StyledWrapper = styled.div`
// for icon hover
position: inherit;
- left: -4px;
- padding-left: 4px;
- padding-right: 4px;
grid-template-columns: ${({ columns }) =>
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) => (
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.
);
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index f19c51101..900cf24b6 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,
@@ -26,6 +25,11 @@ const initialState = {
codeFont: 'default'
}
},
+ generateCode: {
+ mainLanguage: 'Shell',
+ library: 'curl',
+ shouldInterpolate: true
+ },
cookies: [],
taskQueue: [],
systemProxyEnvVariables: {}
@@ -76,6 +80,12 @@ export const appSlice = createSlice({
},
updateSystemProxyEnvVariables: (state, action) => {
state.systemProxyEnvVariables = action.payload;
+ },
+ updateGenerateCode: (state, action) => {
+ state.generateCode = {
+ ...state.generateCode,
+ ...action.payload
+ };
}
}
});
@@ -94,7 +104,8 @@ export const {
insertTaskIntoQueue,
removeTaskFromQueue,
removeAllTasksFromQueue,
- updateSystemProxyEnvVariables
+ updateSystemProxyEnvVariables,
+ updateGenerateCode
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {
@@ -103,14 +114,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/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index df1fc63bc..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);
@@ -785,6 +826,30 @@ export const collectionsSlice = createSlice({
}
}
},
+ setRequestHeaders: (state, action) => {
+ const { collectionUid, itemUid, headers } = 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);
+ }
+ 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);
@@ -2273,6 +2338,7 @@ export const {
requestUrlChanged,
updateAuth,
addQueryParam,
+ setQueryParams,
moveQueryParam,
updateQueryParam,
deleteQueryParam,
@@ -2281,6 +2347,7 @@ export const {
updateRequestHeader,
deleteRequestHeader,
moveRequestHeader,
+ setRequestHeaders,
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam,
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/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'
},
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 (
+
+
+
+ );
+};
+
+export default HeightBoundContainer;
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-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');
+}
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..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);
}
@@ -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 };
}
@@ -147,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
@@ -182,8 +178,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 = {};
@@ -211,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/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..0b4d894cd 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') => {
@@ -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 &&
@@ -63,7 +67,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 +81,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
- }
- ]
- });
- });
- });
});
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) {
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 6fd575f90..50aaf823b 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') {
- request.data = qs.stringify(request.data);
+ 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;
@@ -354,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-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.