Merge branch 'main' into codemirror_autocomplete_logic_refactor

This commit is contained in:
lohit
2025-06-27 17:17:15 +05:30
committed by GitHub
82 changed files with 4091 additions and 858 deletions

14
package-lock.json generated
View File

@@ -28029,12 +28029,12 @@
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -29667,6 +29667,18 @@
"node": ">=10"
}
},
"packages/bruno-app/node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -6,6 +6,7 @@
"baseUrl": "./",
"paths": {
"assets/*": ["src/assets/*"],
"ui/*": ["src/ui/*"],
"components/*": ["src/components/*"],
"hooks/*": ["src/hooks/*"],
"themes/*": ["src/themes/*"],

View File

@@ -73,12 +73,12 @@
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
@@ -91,9 +91,9 @@
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/dom": "^10.4.0",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
@@ -111,4 +111,4 @@
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
}
}
}

View File

@@ -0,0 +1,40 @@
import React, { useMemo } from 'react';
import CodeEditor from 'components/CodeEditor';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { parseBulkKeyValue, serializeBulkKeyValue } from 'utils/common/bulkKeyValueUtils';
const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const { displayedTheme } = useTheme();
const parsedParams = useMemo(() => serializeBulkKeyValue(params), [params]);
const handleEdit = (value) => {
const parsed = parseBulkKeyValue(value);
onChange(parsed);
};
return (
<>
<div className="h-[200px]">
<CodeEditor
mode="text/plain"
theme={displayedTheme}
font={preferences.codeFont || 'default'}
value={parsedParams}
onEdit={handleEdit}
onSave={onSave}
onRun={onRun}
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" onClick={onToggle}>
Key/Value Edit
</button>
</div>
</>
);
};
export default BulkEditor;

View File

@@ -3,6 +3,7 @@ import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
const Font = ({ close }) => {
const dispatch = useDispatch();
@@ -31,7 +32,10 @@ const Font = ({ close }) => {
}
})
).then(() => {
toast.success('Preferences saved successfully')
close();
}).catch(() => {
toast.error('Failed to save preferences')
});
};

View File

@@ -80,9 +80,9 @@ const General = ({ close }) => {
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
}
})
)
}))
.then(() => {
toast.success('Preferences saved successfully')
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));

View File

@@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => {
proxy: validatedProxy
})
).then(() => {
toast.success('Preferences saved successfully')
close();
}).catch(() => {
toast.error('Failed to save preferences')
});
})
.catch((error) => {

View File

@@ -28,11 +28,11 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
itemUid: item.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -45,12 +45,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -63,12 +63,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -81,12 +81,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -99,12 +99,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -117,12 +117,12 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: profileName || ''
}
})
);

View File

@@ -26,8 +26,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: basicAuth.password
username: username || '',
password: basicAuth.password || ''
}
})
);
@@ -40,8 +40,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: basicAuth.username,
password: password
username: basicAuth.username || '',
password: password || ''
}
})
);

View File

@@ -25,8 +25,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: digestAuth.password
username: username || '',
password: digestAuth.password || ''
}
})
);
@@ -39,8 +39,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: digestAuth.username,
password: password
username: digestAuth.username || '',
password: password || ''
}
})
);

View File

@@ -26,9 +26,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
username: username || '',
password: ntlmAuth.password || '',
domain: ntlmAuth.domain || ''
}
})
);
@@ -41,9 +41,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: ntlmAuth.username,
password: password,
domain: ntlmAuth.domain
username: ntlmAuth.username || '',
password: password || '',
domain: ntlmAuth.domain || ''
}
})
);
@@ -56,9 +56,9 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: ntlmAuth.username,
password: ntlmAuth.password,
domain: domain
username: ntlmAuth.username || '',
password: ntlmAuth.password || '',
domain: domain || ''
}
})
);

View File

@@ -26,8 +26,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username,
password: wsseAuth.password
username: username || '',
password: wsseAuth.password || ''
}
})
);
@@ -40,8 +40,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: wsseAuth.username,
password
username: wsseAuth.username || '',
password: password || ''
}
})
);

View File

@@ -18,8 +18,9 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -66,7 +67,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
collection={collection}
theme={displayedTheme}
schema={schema}
width={leftPaneWidth}
onSave={onSave}
value={query}
onRun={onRun}
@@ -154,7 +154,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1 relative">{getTabPanel(focusedTab.requestPaneTab)}</section>
<section className="flex w-full mt-5 flex-1 relative">
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
</section>
</StyledWrapper>
);
};

View File

@@ -15,6 +15,7 @@ import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import { useEffect } from 'react';
const ContentIndicator = () => {
@@ -33,7 +34,7 @@ const ErrorIndicator = () => {
);
};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -180,7 +181,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
'mt-5': !isMultipleContentTab
})}
>
{getTabPanel(focusedTab.requestPaneTab)}
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
</section>
</StyledWrapper>
);

View File

@@ -31,7 +31,7 @@ const Wrapper = styled.div`
}
}
.btn-add-param {
.btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;

View File

@@ -1,16 +1,17 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import InfoTip from 'components/InfoTip';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addQueryParam,
updateQueryParam,
deleteQueryParam,
moveQueryParam,
updatePathParam
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable';
import BulkEditor from '../../BulkEditor';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -25,6 +27,8 @@ const QueryParams = ({ item, collection }) => {
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const handleAddQueryParam = () => {
dispatch(
@@ -113,8 +117,31 @@ const QueryParams = ({ item, collection }) => {
);
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkParamsChange = (newParams) => {
const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={queryParams}
onChange={handleBulkParamsChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full flex flex-col absolute">
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<Table
@@ -171,9 +198,14 @@ const QueryParams = ({ item, collection }) => {
</ReorderTable>
</Table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mb-2 title text-xs flex items-stretch">
<span>Path</span>
<InfoTip infotipId="path-param-InfoTip">

View File

@@ -21,7 +21,7 @@ const RequestBodyMode = ({ item, collection }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
@@ -149,7 +149,7 @@ const RequestBodyMode = ({ item, collection }) => {
</Dropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (
<button className="ml-1" onClick={onPrettify}>
<button className="ml-2" onClick={onPrettify}>
Prettify
</button>
)}

View File

@@ -22,8 +22,11 @@ const Wrapper = styled.div`
}
}
.btn-add-header {
.btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;
}
}
input[type='text'] {

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -12,12 +12,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
import BulkEditor from '../../BulkEditor';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const addHeader = () => {
dispatch(
@@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => {
);
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full">
<Table
@@ -153,9 +179,14 @@ const RequestHeaders = ({ item, collection }) => {
: null}
</ReorderTable>
</Table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
</StyledWrapper>
);
};

View File

@@ -3,9 +3,13 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
&.dragging {
cursor: col-resize;
&.vertical-layout {
cursor: row-resize;
}
}
div.drag-request {
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
@@ -15,18 +19,47 @@ const StyledWrapper = styled.div`
cursor: col-resize;
background: transparent;
div.drag-request-border {
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.drag-request-border {
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
&.vertical-layout {
.request-pane {
padding-bottom: 0.5rem;
}
.response-pane {
padding-top: 0.5rem;
}
div.dragbar-wrapper {
width: 100%;
height: 10px;
cursor: row-resize;
padding: 0 1rem;
div.dragbar-handle {
width: 100%;
height: 1px;
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
}
div.graphql-docs-explorer-container {
background: white;
outline: none;

View File

@@ -29,7 +29,8 @@ import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
const DEFAULT_PADDING = 5;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
@@ -41,6 +42,8 @@ const RequestTabPanel = () => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
let collections = produce(_collections, (draft) => {
@@ -64,13 +67,15 @@ const RequestTabPanel = () => {
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 so that request pane is relatively smaller
const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
); // 2.2 is intentional to make both panes appear to be of equal width
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
const docExplorerRef = useRef(null);
const mainSectionRef = useRef(null);
const [schema, setSchema] = useState(null);
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = (schema) => setSchema(schema);
@@ -85,43 +90,72 @@ const RequestTabPanel = () => {
};
useEffect(() => {
const leftPaneWidth = (screenWidth - asideWidth) / 2.2;
setLeftPaneWidth(leftPaneWidth);
}, [screenWidth]);
useEffect(() => {
setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
}, [screenWidth, asideWidth, leftPaneWidth]);
// Initialize vertical heights when switching to vertical layout
if (mainSectionRef.current) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const initialHeight = mainRect.height / 2;
setTopPaneHeight(initialHeight);
// In vertical mode, set leftPaneWidth to full container width
setLeftPaneWidth(mainRect.width);
} else {
// In horizontal mode, set to roughly half width
setLeftPaneWidth((screenWidth - asideWidth) / 2.2);
}
}
}, [isVerticalLayout, screenWidth, asideWidth]);
const handleMouseMove = (e) => {
if (dragging) {
if (dragging && mainSectionRef.current) {
e.preventDefault();
let leftPaneXPosition = e.clientX + 2;
if (
leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH ||
leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH
) {
return;
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
return;
}
setTopPaneHeight(newHeight);
} else {
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
return;
}
setLeftPaneWidth(newWidth);
}
setLeftPaneWidth(leftPaneXPosition - asideWidth);
setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING);
}
};
const handleMouseUp = (e) => {
if (dragging) {
if (dragging && mainSectionRef.current) {
e.preventDefault();
setDragging(false);
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING
})
);
if (!isVerticalLayout) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
})
);
}
}
};
const handleDragbarMouseDown = (e) => {
e.preventDefault();
setDragging(true);
if (isVerticalLayout) {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.y = e.clientY - dragBarRect.top;
} else {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.x = e.clientX - dragBarRect.left;
}
};
useEffect(() => {
@@ -132,7 +166,7 @@ const RequestTabPanel = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [dragging, asideWidth]);
}, [dragging]);
if (!activeTabUid) {
return <Welcome />;
@@ -197,15 +231,19 @@ const RequestTabPanel = () => {
};
return (
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''}`}>
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="pt-4 pb-3 px-4">
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
</div>
<section className="main flex flex-grow pb-4 relative">
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative`}>
<section className="request-pane">
<div
className="px-4 h-full"
style={{
style={isVerticalLayout ? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
>
@@ -213,7 +251,6 @@ const RequestTabPanel = () => {
<GraphQLRequestPane
item={item}
collection={collection}
leftPaneWidth={leftPaneWidth}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
@@ -221,17 +258,17 @@ const RequestTabPanel = () => {
) : null}
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} leftPaneWidth={leftPaneWidth} />
<HttpRequestPane item={item} collection={collection} />
) : null}
</div>
</section>
<div className="drag-request" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" />
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow">
<ResponsePane item={item} collection={collection} rightPaneWidth={rightPaneWidth} response={item.response} />
<ResponsePane item={item} collection={collection} response={item.response} />
</section>
</section>

View File

@@ -22,6 +22,15 @@ const StyledWrapper = styled.div`
animation: rotateCounterClockwise 1s linear infinite;
}
}
// spinner and request time content looks better centered vertically in vertical layout
// while in horizontal layout, it looks better when the content is aligned to the top
&.vertical-layout {
div.overlay {
justify-content: center;
padding: 1rem;
}
}
`;
export default StyledWrapper;

View File

@@ -1,19 +1,21 @@
import React from 'react';
import { IconRefresh } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import StopWatch from '../../StopWatch';
import StyledWrapper from './StyledWrapper';
const ResponseLoadingOverlay = ({ item, collection }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const handleCancelRequest = () => {
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className={`w-full ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>

View File

@@ -1,12 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
padding-top: 20%;
width: 100%;
.send-icon {
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
}
&.vertical-layout {
padding: 1rem;
justify-content: center;
}
`;
export default StyledWrapper;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { IconSend } from '@tabler/icons';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { isMacOS } from 'utils/common/platform';
@@ -8,9 +9,11 @@ const Placeholder = () => {
const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter';
const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B';
const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E';
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
return (
<StyledWrapper>
<StyledWrapper className={`${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="send-icon flex justify-center" style={{ fontSize: 200 }}>
<IconSend size={150} strokeWidth={1} />
</div>

View File

@@ -74,7 +74,7 @@ const formatErrorMessage = (error) => {
return error;
};
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
@@ -164,7 +164,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
return (
<StyledWrapper
className="w-full h-full relative flex"
style={{ maxWidth: width }}
queryFilterEnabled={queryFilterEnabled}
>
<div className="flex justify-end gap-2 text-xs" role="tablist">

View File

@@ -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;

View File

@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
<path stroke="none" fill="none" d="M0 0h24v24H0z" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M4 15l16 0" />
<path
fill="currentColor"
d="M 5.5135136,19.111502 C 5.2542477,18.995986 5.0221761,18.756859 4.8928709,18.47199 4.7922381,18.250288 4.7788524,18.078909 4.7777079,16.997543 l -0.0013,-1.223586 H 12 19.223587 v 1.22675 c 0,1.194609 -0.0039,1.234605 -0.149369,1.526503 -0.09333,0.187285 -0.240773,0.363095 -0.392978,0.46858 l -0.243606,0.168829 -6.373606,0.0129 c -5.2129418,0.0105 -6.4058225,-0.0015 -6.5505114,-0.06597 z"
/>
</svg>
);
};
const IconDockToRight = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
fill="none"
>
<path fill="none" stroke="none" d="M 0,24 V 0 h 24 v 24 z" />
<path d="m 4,20 m 2,0 A 2,2 0 0 1 4,18 V 6 A 2,2 0 0 1 6,4 h 12 a 2,2 0 0 1 2,2 v 12 a 2,2 0 0 1 -2,2 z" />
<path d="M 15,20 V 4" />
<path
fill="currentColor"
stroke="currentColor"
d="m 19.111502,18.486486 c -0.115516,0.259266 -0.354643,0.491338 -0.639512,0.620643 -0.221702,0.100633 -0.393081,0.114019 -1.474447,0.115163 l -1.223586,0.0013 V 12 4.7764125 h 1.22675 c 1.194609,0 1.234605,0.0039 1.526503,0.14937 0.187285,0.09333 0.363095,0.2407725 0.46858,0.3929775 l 0.168829,0.243606 0.0129,6.373606 c 0.0105,5.212942 -0.0015,6.405822 -0.06597,6.550511 z"
/>
</svg>
);
};
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 (
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={toggleOrientation}
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
>
{orientation === 'horizontal' ? (
<IconDockToBottom />
) : (
<IconDockToRight />
)}
</button>
</StyledWrapper>
);
};
export default ResponseLayoutToggle;

View File

@@ -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(
<Provider store={store}>
<ThemeProvider>
{component}
</ThemeProvider>
</Provider>
)
};
};
describe('ResponseLayoutToggle', () => {
describe('Initial Render', () => {
it('should render with horizontal orientation by default', () => {
renderWithProviders(<ResponseLayoutToggle />);
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(<ResponseLayoutToggle />, 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(<ResponseLayoutToggle />);
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(<ResponseLayoutToggle />, 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');
});
});
});

View File

@@ -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 }) => {
<QueryResult
item={item}
collection={collection}
width={rightPaneWidth}
data={response.data}
dataBuffer={response.dataBuffer}
headers={response.headers}
@@ -70,7 +71,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <ResponseHeaders headers={response.headers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
return <Timeline collection={collection} item={item} />;
}
case 'tests': {
return <TestResults
@@ -105,9 +106,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<HeightBoundContainer>
<Placeholder />
</StyledWrapper>
</HeightBoundContainer>
);
}
@@ -132,7 +133,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
<div className="flex flex-wrap items-center px-4 tabs" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
</div>
@@ -159,6 +160,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
onClick={() => setShowScriptErrorCard(true)}
/>
)}
<ResponseLayoutToggle />
{focusedTab?.responsePaneTab === "timeline" ? (
<ClearTimeline item={item} collection={collection} />
) : (item?.response && !item?.response?.error) ? (
@@ -174,7 +176,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
) : null}
</div>
<section
className={`flex flex-col min-h-0 relative pl-3 pr-4 auto`}
className={`flex flex-col min-h-0 relative px-4 auto`}
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
@@ -193,7 +195,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<Timeline
collection={collection}
item={item}
width={rightPaneWidth}
/>
) : null
) : (

View File

@@ -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);
}
}
`;

View File

@@ -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 (
<>
<StyledWrapper>
<CopyToClipboard
className="copy-to-clipboard"
text={snippet}
onCopy={() => toast.success('Copied to clipboard!')}
>
<StyledWrapper>
<CopyToClipboard
text={snippet}
onCopy={() => toast.success('Copied to clipboard!')}
>
<button className="copy-to-clipboard">
<IconCopy size={25} strokeWidth={1.5} />
</CopyToClipboard>
</button>
</CopyToClipboard>
<div className="editor-content">
<CodeEditor
readOnly
collection={collection}
@@ -67,11 +55,12 @@ const CodeView = ({ language, item }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
mode={lang}
mode={language.language}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</StyledWrapper>
</>
</div>
</StyledWrapper>
);
};

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
<div className="toolbar">
<div className="left-controls">
<div className="select-wrapper">
<select
className="native-select"
value={generateCodePrefs.mainLanguage}
onChange={handleMainLanguageChange}
>
{mainLanguages.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
<IconChevronDown size={16} className="select-arrow" />
</div>
{availableLibraries.length > 1 && (
<div className="library-options">
{availableLibraries.map((lib) => (
<button
key={lib.libraryName}
className={`lib-btn ${generateCodePrefs.library === lib.libraryName ? 'active' : ''}`}
onClick={() => handleLibraryChange(lib.libraryName)}
>
{lib.libraryName}
</button>
))}
</div>
)}
</div>
<div className="right-controls">
<label className="interpolate-checkbox">
<input
type="checkbox"
checked={generateCodePrefs.shouldInterpolate}
onChange={handleInterpolateChange}
/>
<span>Interpolate Variables</span>
</label>
</div>
</div>
</StyledWrapper>
);
};
export default CodeViewToolbar;

View File

@@ -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;
}
}
`;

View File

@@ -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 (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full flexible-container">
<div>
<div className="generate-code-sidebar">
{languages &&
languages.length &&
languages.map((language) => (
<div
key={language.name}
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
role="button"
tabIndex={0}
onClick={() => 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]);
<div className="code-generator">
<CodeViewToolbar />
// 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}
>
<span className="capitalize">{language.name}</span>
</div>
))}
</div>
</div>
<div className="flex-grow p-4">
<div className="editor-container">
{isValidUrl(finalUrl) ? (
<CodeView
tabIndex={-1}
language={selectedLanguage}
item={{
...item,
@@ -152,11 +82,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
}}
/>
) : (
<div className="flex flex-col justify-center items-center w-full">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid URL: {finalUrl}</h1>
<p className="text-gray-500">Please check the URL and try again</p>
</div>
<div className="error-message">
<h1>Invalid URL: {finalUrl}</h1>
<p>Please check the URL and try again</p>
</div>
)}
</div>

View File

@@ -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
};
};

View File

@@ -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');
});
});

View File

@@ -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
}
}
};
};

View File

@@ -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();
});
});
});

View File

@@ -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
};

View File

@@ -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}}}\'');
});
});

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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

View File

@@ -86,7 +86,7 @@ const Table = ({ minColumnWidth = 1, headers = [], children }) => {
return (
<StyledWrapper columns={columns}>
<div className="relative">
<table ref={tableRef} className="px-4 inherit left-[4px]">
<table ref={tableRef} className="inherit">
<thead>
<tr>
{columns.map(({ ref, name }, i) => (

View File

@@ -96,7 +96,6 @@ const VariablesEditor = ({ collection }) => {
<div className="mt-8 muted text-xs">
Note: As of today, runtime variables can only be set via the API - <span className="font-medium">getVar()</span>{' '}
and <span className="font-medium">setVar()</span>. <br />
In the next release, we will add a UI to set and modify runtime variables.
</div>
</StyledWrapper>
);

View File

@@ -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);
});
};

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
import React from 'react';
import themes from 'themes/index';
import useLocalStorage from 'hooks/useLocalStorage/index';

View File

@@ -248,6 +248,10 @@ const darkTheme = {
codemirror: {
bg: '#1e1e1e',
border: '#373737',
placeholder: {
color: '#a2a2a2',
opacity: 0.50
},
gutter: {
bg: '#262626'
},

View File

@@ -249,6 +249,10 @@ const lightTheme = {
codemirror: {
bg: 'white',
border: '#efefef',
placeholder: {
color: '#a2a2a2',
opacity: 0.75
},
gutter: {
bg: '#f3f3f3'
},

View File

@@ -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;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const HeightBoundContainer = ({children}) => {
return (
<StyledWrapper>
<div className="height-constraint">
<div className="grid-boundary">
{children}
</div>
</div>
</StyledWrapper>
);
};
export default HeightBoundContainer;

View File

@@ -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),

View File

@@ -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');
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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&param2=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&param2=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
}
]
});
});
});
});

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);

View File

@@ -3,6 +3,25 @@ const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
/**
* @param {Array.<object>} params The request body Array
* @returns {object} Returns an obj with repeating key as an array of values
* {item: 2, item: 3, item1: 4} becomes {item: [2,3], item1: 4}
*/
const buildFormUrlEncodedPayload = (params) => {
return params.reduce((acc, p) => {
if (!acc[p.name]) {
acc[p.name] = p.value;
} else if (Array.isArray(acc[p.name])) {
acc[p.name].push(p.value);
} else {
acc[p.name] = [acc[p.name], p.value];
}
return acc;
}, {});
};
const createFormData = (data, collectionPath) => {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
@@ -38,5 +57,6 @@ const createFormData = (data, collectionPath) => {
};
module.exports = {
buildFormUrlEncodedPayload,
createFormData
}

View File

@@ -6,14 +6,14 @@ const isSecret = (type) => {
return type === 'secret';
};
const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
const importPostmanEnvironmentVariables = (brunoEnvironment, values = []) => {
brunoEnvironment.variables = brunoEnvironment.variables || [];
each(values, (i) => {
each(values.filter(i => !(i.key == null && i.value == null)), (i) => {
const brunoEnvironmentVariable = {
uid: uuid(),
name: i.key.replace(invalidVariableCharacterRegex, '_'),
value: i.value,
name: (i.key ?? '').replace(invalidVariableCharacterRegex, '_'),
value: i.value ?? '',
enabled: i.enabled,
secret: isSecret(i.type)
};

View File

@@ -4,6 +4,17 @@ import each from 'lodash/each';
import postmanTranslation from './postman-translations';
import { invalidVariableCharacterRegex } from '../constants/index';
const AUTH_TYPES = Object.freeze({
BASIC: 'basic',
BEARER: 'bearer',
AWSV4: 'awsv4',
APIKEY: 'apikey',
DIGEST: 'digest',
OAUTH2: 'oauth2',
NOAUTH: 'noauth',
NONE: 'none'
});
const parseGraphQLRequest = (graphqlSource) => {
try {
let queryResultObject = {
@@ -119,117 +130,132 @@ const importScriptsFromEvents = (events, requestObject) => {
};
const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.map((v) => ({
const vars = variables.filter(v => !(v.key == null && v.value == null)).map((v) => ({
uid: uuid(),
name: v.key.replace(invalidVariableCharacterRegex, '_'),
value: v.value,
name: (v.key ?? '').replace(invalidVariableCharacterRegex, '_'),
value: v.value ?? '',
enabled: true
}));
requestObject.vars.req = vars;
};
const processAuth = (auth, requestObject) => {
if (!auth || !auth.type || auth.type === 'noauth') {
export const processAuth = (auth, requestObject) => {
if (!auth || !auth.type || auth.type === AUTH_TYPES.NOAUTH) {
return;
}
let authValues = auth[auth.type];
if(!authValues) {
console.warn('Unexpected auth.type, auth object doesn\'t have the key', auth.type);
requestObject.auth.mode = auth.type;
authValues = {};
}
if (Array.isArray(authValues)) {
authValues = convertV21Auth(authValues);
}
if (auth.type === 'basic') {
requestObject.auth.mode = 'basic';
requestObject.auth.basic = {
username: authValues.username || '',
password: authValues.password || ''
};
} else if (auth.type === 'bearer') {
requestObject.auth.mode = 'bearer';
requestObject.auth.bearer = {
token: authValues.token || ''
};
} else if (auth.type === 'awsv4') {
requestObject.auth.mode = 'awsv4';
requestObject.auth.awsv4 = {
accessKeyId: authValues.accessKey || '',
secretAccessKey: authValues.secretKey || '',
sessionToken: authValues.sessionToken || '',
service: authValues.service || '',
region: authValues.region || '',
profileName: ''
};
} else if (auth.type === 'apikey') {
requestObject.auth.mode = 'apikey';
requestObject.auth.apikey = {
key: authValues.key || '',
value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: 'header' //By default we are placing the apikey values in headers!
};
} else if (auth.type === 'digest') {
requestObject.auth.mode = 'digest';
requestObject.auth.digest = {
username: authValues.username || '',
password: authValues.password || ''
};
} else if (auth.type === 'oauth2') {
const findValueUsingKey = (key) => {
return authValues[key] || '';
};
const oauth2GrantTypeMaps = {
authorization_code_with_pkce: 'authorization_code',
authorization_code: 'authorization_code',
client_credentials: 'client_credentials',
password_credentials: 'password_credentials'
};
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
switch (auth.type) {
case AUTH_TYPES.BASIC:
requestObject.auth.mode = AUTH_TYPES.BASIC;
requestObject.auth.basic = {
username: authValues.username || '',
password: authValues.password || ''
};
break;
case AUTH_TYPES.BEARER:
requestObject.auth.mode = AUTH_TYPES.BEARER;
requestObject.auth.bearer = {
token: authValues.token || ''
};
break;
case AUTH_TYPES.AWSV4:
requestObject.auth.mode = AUTH_TYPES.AWSV4;
requestObject.auth.awsv4 = {
accessKeyId: authValues.accessKey || '',
secretAccessKey: authValues.secretKey || '',
sessionToken: authValues.sessionToken || '',
service: authValues.service || '',
region: authValues.region || '',
profileName: ''
};
break;
case AUTH_TYPES.APIKEY:
requestObject.auth.mode = AUTH_TYPES.APIKEY;
requestObject.auth.apikey = {
key: authValues.key || '',
value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it,
placement: 'header' //By default we are placing the apikey values in headers!
};
break;
case AUTH_TYPES.DIGEST:
requestObject.auth.mode = AUTH_TYPES.DIGEST;
requestObject.auth.digest = {
username: authValues.username || '',
password: authValues.password || ''
};
break;
case AUTH_TYPES.OAUTH2:
const findValueUsingKey = (key) => {
return authValues[key] || '';
};
const oauth2GrantTypeMaps = {
authorization_code_with_pkce: 'authorization_code',
authorization_code: 'authorization_code',
client_credentials: 'client_credentials',
password_credentials: 'password_credentials'
};
const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
requestObject.auth.mode = 'oauth2';
if (grantType === 'authorization_code') {
requestObject.auth.oauth2 = {
grantType: 'authorization_code',
authorizationUrl: findValueUsingKey('authUrl'),
callbackUrl: findValueUsingKey('redirect_uri'),
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
} else if (grantType === 'password_credentials') {
requestObject.auth.oauth2 = {
grantType: 'password',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
username: findValueUsingKey('username'),
password: findValueUsingKey('password'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
} else if (grantType === 'client_credentials') {
requestObject.auth.oauth2 = {
grantType: 'client_credentials',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
}
} else {
console.warn('Unexpected auth.type', auth.type);
requestObject.auth.mode = AUTH_TYPES.OAUTH2;
if (grantType === 'authorization_code') {
requestObject.auth.oauth2 = {
grantType: 'authorization_code',
authorizationUrl: findValueUsingKey('authUrl'),
callbackUrl: findValueUsingKey('redirect_uri'),
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
} else if (grantType === 'password_credentials') {
requestObject.auth.oauth2 = {
grantType: 'password',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
username: findValueUsingKey('username'),
password: findValueUsingKey('password'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
} else if (grantType === 'client_credentials') {
requestObject.auth.oauth2 = {
grantType: 'client_credentials',
accessTokenUrl: findValueUsingKey('accessTokenUrl'),
refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
clientId: findValueUsingKey('clientId'),
clientSecret: findValueUsingKey('clientSecret'),
scope: findValueUsingKey('scope'),
state: findValueUsingKey('state'),
tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
};
}
break;
default:
requestObject.auth.mode = AUTH_TYPES.NONE;
console.warn('Unexpected auth.type', auth.type);
}
};
@@ -663,5 +689,4 @@ const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) =>
}
};
export default postmanToBruno;

View File

@@ -47,6 +47,66 @@ describe('postmanToBrunoEnvironment Function', () => {
expect(brunoEnvironment).toEqual(expectedEnvironment);
});
it('should handle falsy values in environment variables', async () => {
const postmanEnvironment = {
"id": "some-id",
"name": "My Environment",
"values": [
{
"enabled": true,
"type": "text"
},
{
"value": "",
"enabled": true,
"type": "text"
},
{
"key": "",
"enabled": true,
"type": "text"
},
{
"key": "",
"value": "",
"enabled": true,
"type": "text"
}
]
};
const brunoEnvironment = await postmanToBrunoEnvironment(postmanEnvironment);
const expectedEnvironment = {
name: 'My Environment',
variables: [
{
name: '',
value: '',
enabled: true,
secret: false,
uid: "mockeduuidvalue123456",
},
{
name: '',
value: '',
enabled: true,
secret: false,
uid: "mockeduuidvalue123456",
},
{
name: '',
value: '',
enabled: true,
secret: false,
uid: "mockeduuidvalue123456",
}
],
};
expect(brunoEnvironment).toEqual(expectedEnvironment);
});
it.skip('should throw Error when JSON parsing fails', async () => {
const invalidBrunoEnvironment = {
"id": "some-id",
@@ -66,4 +126,23 @@ describe('postmanToBrunoEnvironment Function', () => {
'Unable to parse the postman environment json file'
);
});
it("should handle empty variables", async () => {
const collectionWithEmptyVars = {
"name": "My Environment",
"values": []
};
const brunoCollection = await postmanToBrunoEnvironment(collectionWithEmptyVars);
expect(brunoCollection.variables).toEqual([]);
});
it("should handle undefined variables", async () => {
const collectionWithUndefinedVars = {
"name": "My Environment",
};
const brunoCollection = await postmanToBrunoEnvironment(collectionWithUndefinedVars);
expect(brunoCollection.variables).toEqual([]);
});
});

View File

@@ -235,4 +235,97 @@ describe('Collection Authentication', () => {
}
});
});
it('should handle missing auth values when auth.type exists', async() => {
const postmanCollection = {
info: {
name: 'Collection with missing auth values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [],
auth: {
type: 'basic'
// Missing basic auth values
},
event: [
{
listen: 'prerequest',
script: {
type: 'text/javascript',
packages: {},
exec: ['']
}
},
{
listen: 'test',
script: {
type: 'text/javascript',
packages: {},
exec: ['']
}
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.root.request.auth).toEqual({
mode: 'basic',
basic: {
username: '',
password: ''
},
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null
});
});
it('should handle missing auth values for different auth types', async() => {
const postmanCollection = {
info: {
name: 'Collection with missing auth values for different types',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [],
auth: {
type: 'bearer'
// Missing bearer token
},
event: [
{
listen: 'prerequest',
script: {
type: 'text/javascript',
packages: {},
exec: ['']
}
},
{
listen: 'test',
script: {
type: 'text/javascript',
packages: {},
exec: ['']
}
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.root.request.auth).toEqual({
mode: 'bearer',
basic: null,
bearer: {
token: ''
},
awsv4: null,
apikey: null,
oauth2: null,
digest: null
});
});
});

View File

@@ -244,4 +244,56 @@ describe('Folder Authentication', () => {
digest: { username: 'digest user', password: 'digest pass' }
});
});
it('should handle missing auth values in folder level auth', async() => {
const postmanCollection = {
info: {
name: 'Folder with missing auth values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'folder',
item: [],
auth: {
type: 'basic'
// Missing basic values
},
event: [
{
listen: 'prerequest',
script: {
type: 'text/javascript',
packages: {},
exec: ['']
}
},
{
listen: 'test',
script: {
type: 'text/javascript',
packages: {},
exec: ['']
}
}
]
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].root.request.auth).toEqual({
mode: 'basic',
basic: {
username: '',
password: ''
},
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null
});
});
});

View File

@@ -6,6 +6,73 @@ describe('postman-collection', () => {
const brunoCollection = await postmanToBruno(postmanCollection);
expect(brunoCollection).toMatchObject(expectedOutput);
});
it('should handle falsy values in collection variables', async () => {
const collectionWithFalsyVars = {
"info": {
"_postman_id": "7f91bbd8-cb97-41ac-8d0b-e1fcd8bb4ce9",
"name": "collection with falsy vars",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"type": "string"
},
{
"key": "",
"type": "string"
},
{
"value": "",
"type": "string"
},
{
"key": "",
"value": "",
"type": "string"
}
],
"item": []
};
const brunoCollection = await postmanToBruno(collectionWithFalsyVars);
expect(brunoCollection.root.request.vars.req).toEqual([
{
uid: "mockeduuidvalue123456",
name: '',
value: '',
enabled: true
},
{
uid: "mockeduuidvalue123456",
name: '',
value: '',
enabled: true
},
{
uid: "mockeduuidvalue123456",
name: '',
value: '',
enabled: true
}
]);
});
it("should handle empty variables", async () => {
const collectionWithEmptyVars = {
"info": {
"_postman_id": "7f91bbd8-cb97-41ac-8d0b-e1fcd8bb4ce9",
"name": "collection with falsy vars",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [],
"item": []
};
const brunoCollection = await postmanToBruno(collectionWithEmptyVars);
expect(brunoCollection.root.request.vars.req).toEqual([]);
});
});
// Simple Collection (postman)

View File

@@ -0,0 +1,428 @@
const { processAuth } = require("../../../src/postman/postman-to-bruno");
describe('processAuth', () => {
let requestObject;
beforeEach(() => {
requestObject = {
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null
}
};
});
it('should handle no auth', () => {
processAuth(null, requestObject);
expect(requestObject.auth.mode).toBe('none');
});
it('should handle noauth type', () => {
processAuth({ type: 'noauth' }, requestObject);
expect(requestObject.auth.mode).toBe('none');
});
it('should handle basic auth', () => {
const auth = {
type: 'basic',
basic: [
{ key: 'username', value: 'testuser', type: 'string' },
{ key: 'password', value: 'testpass', type: 'string' }
]
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('basic');
expect(requestObject.auth.basic).toEqual({
username: 'testuser',
password: 'testpass'
});
});
it('should handle basic auth with missing values', () => {
const auth = {
type: 'basic',
basic: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('basic');
expect(requestObject.auth.basic).toEqual({
username: '',
password: ''
});
});
it('should handle basic auth with missing basic key', () => {
const auth = {
type: 'basic'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('basic');
expect(requestObject.auth.basic).toEqual({
username: '',
password: ''
});
});
it('should handle bearer auth', () => {
const auth = {
type: 'bearer',
bearer: {
token: 'test-token'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('bearer');
expect(requestObject.auth.bearer).toEqual({
token: 'test-token'
});
});
it('should handle bearer auth with missing values', () => {
const auth = {
type: 'bearer',
bearer: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('bearer');
expect(requestObject.auth.bearer).toEqual({
token: ''
});
});
it('should handle bearer auth with missing bearer key', () => {
const auth = {
type: 'bearer'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('bearer');
expect(requestObject.auth.bearer).toEqual({
token: ''
});
});
it('should handle awsv4 auth', () => {
const auth = {
type: 'awsv4',
awsv4: {
accessKey: 'test-access-key',
secretKey: 'test-secret-key',
sessionToken: 'test-session-token',
service: 'test-service',
region: 'test-region'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('awsv4');
expect(requestObject.auth.awsv4).toEqual({
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
sessionToken: 'test-session-token',
service: 'test-service',
region: 'test-region',
profileName: ''
});
});
it('should handle awsv4 auth with missing values', () => {
const auth = {
type: 'awsv4',
awsv4: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('awsv4');
expect(requestObject.auth.awsv4).toEqual({
accessKeyId: '',
secretAccessKey: '',
sessionToken: '',
service: '',
region: '',
profileName: ''
});
});
it('should handle awsv4 auth with missing awsv4 key', () => {
const auth = {
type: 'awsv4'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('awsv4');
expect(requestObject.auth.awsv4).toEqual({
accessKeyId: '',
secretAccessKey: '',
sessionToken: '',
service: '',
region: '',
profileName: ''
});
});
it('should handle apikey auth', () => {
const auth = {
type: 'apikey',
apikey: {
key: 'test-key',
value: 'test-value'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('apikey');
expect(requestObject.auth.apikey).toEqual({
key: 'test-key',
value: 'test-value',
placement: 'header'
});
});
it('should handle apikey auth with missing values', () => {
const auth = {
type: 'apikey',
apikey: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('apikey');
expect(requestObject.auth.apikey).toEqual({
key: '',
value: '',
placement: 'header'
});
});
it('should handle apikey auth with missing apikey key', () => {
const auth = {
type: 'apikey'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('apikey');
expect(requestObject.auth.apikey).toEqual({
key: '',
value: '',
placement: 'header'
});
});
it('should handle digest auth', () => {
const auth = {
type: 'digest',
digest: {
username: 'testuser',
password: 'testpass'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('digest');
expect(requestObject.auth.digest).toEqual({
username: 'testuser',
password: 'testpass'
});
});
it('should handle digest auth with missing values', () => {
const auth = {
type: 'digest',
digest: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('digest');
expect(requestObject.auth.digest).toEqual({
username: '',
password: ''
});
});
it('should handle digest auth with missing digest key', () => {
const auth = {
type: 'digest'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('digest');
expect(requestObject.auth.digest).toEqual({
username: '',
password: ''
});
});
it('should handle oauth2 auth with authorization_code grant type', () => {
const auth = {
type: 'oauth2',
oauth2: {
grant_type: 'authorization_code',
authUrl: 'https://auth.example.com',
redirect_uri: 'https://callback.example.com',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
addTokenTo: 'header',
client_authentication: 'body'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth2');
expect(requestObject.auth.oauth2).toEqual({
grantType: 'authorization_code',
authorizationUrl: 'https://auth.example.com',
callbackUrl: 'https://callback.example.com',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
pkce: false,
tokenPlacement: 'header',
credentialsPlacement: 'body'
});
});
it('should handle oauth2 auth with password_credentials grant type', () => {
const auth = {
type: 'oauth2',
oauth2: {
grant_type: 'password_credentials',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
username: 'testuser',
password: 'testpass',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
addTokenTo: 'header',
client_authentication: 'body'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth2');
expect(requestObject.auth.oauth2).toEqual({
grantType: 'password',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
username: 'testuser',
password: 'testpass',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
tokenPlacement: 'header',
credentialsPlacement: 'body'
});
});
it('should handle oauth2 auth with client_credentials grant type', () => {
const auth = {
type: 'oauth2',
oauth2: {
grant_type: 'client_credentials',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
addTokenTo: 'header',
client_authentication: 'body'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth2');
expect(requestObject.auth.oauth2).toEqual({
grantType: 'client_credentials',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
tokenPlacement: 'header',
credentialsPlacement: 'body'
});
});
it('should handle oauth2 auth with missing values', () => {
const auth = {
type: 'oauth2',
oauth2: {}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth2');
expect(requestObject.auth.oauth2).toEqual({
grantType: 'authorization_code',
authorizationUrl: '',
callbackUrl: '',
accessTokenUrl: '',
refreshTokenUrl: '',
clientId: '',
clientSecret: '',
scope: '',
state: '',
pkce: false,
tokenPlacement: 'url',
credentialsPlacement: 'basic_auth_header'
});
});
it('should handle oauth2 auth with missing oauth2 key', () => {
const auth = {
type: 'oauth2'
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth2');
expect(requestObject.auth.oauth2).toEqual({
grantType: 'authorization_code',
authorizationUrl: '',
callbackUrl: '',
accessTokenUrl: '',
refreshTokenUrl: '',
clientId: '',
clientSecret: '',
scope: '',
state: '',
pkce: false,
tokenPlacement: 'url',
credentialsPlacement: 'basic_auth_header'
});
});
it('should handle oauth2 auth with authorization_code_with_pkce grant type', () => {
const auth = {
type: 'oauth2',
oauth2: {
grant_type: 'authorization_code_with_pkce',
authUrl: 'https://auth.example.com',
redirect_uri: 'https://callback.example.com',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
addTokenTo: 'header',
client_authentication: 'body'
}
};
processAuth(auth, requestObject);
expect(requestObject.auth.mode).toBe('oauth2');
expect(requestObject.auth.oauth2).toEqual({
grantType: 'authorization_code',
authorizationUrl: 'https://auth.example.com',
callbackUrl: 'https://callback.example.com',
accessTokenUrl: 'https://token.example.com',
refreshTokenUrl: 'https://refresh.example.com',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scope: 'test-scope',
state: 'test-state',
pkce: true,
tokenPlacement: 'header',
credentialsPlacement: 'body'
});
});
});

View File

@@ -130,5 +130,40 @@ describe('Request Authentication', () => {
digest: null
});
});
it('should handle missing basic auth values in request level', async() => {
const postmanCollection = {
info: {
name: 'Missing Auth Request Collection',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'Missing Auth Request',
request: {
method: 'GET',
url: 'https://api.example.com/test',
auth: {
type: 'basic'
}
}
}
]
};
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].request.auth).toEqual({
mode: 'basic',
basic: {
username: '',
password: ''
},
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null
});
});
});

View File

@@ -206,8 +206,8 @@ const configureRequest = async (
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
}
else {
try {
@@ -222,8 +222,8 @@ const configureRequest = async (
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
}
else {
try {
@@ -238,8 +238,8 @@ const configureRequest = async (
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
}
else {
try {
@@ -454,7 +454,7 @@ const registerNetworkIpc = (mainWindow) => {
// stringify the request url encoded params
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data);
request.data = qs.stringify(request.data, { arrayFormat: 'repeat' });
}
if (request.headers['content-type'] === 'multipart/form-data') {

View File

@@ -37,6 +37,9 @@ const defaultPreferences = {
password: ''
},
bypassProxy: ''
},
layout: {
responsePaneOrientation: 'horizontal'
}
};
@@ -69,6 +72,9 @@ const preferencesSchema = Yup.object().shape({
password: Yup.string().max(1024)
}).optional(),
bypassProxy: Yup.string().optional().max(1024)
}),
layout: Yup.object({
responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical'])
})
});
@@ -149,6 +155,9 @@ const preferencesUtil = {
shouldSendCookies: () => {
return get(getPreferences(), 'request.sendCookies', true);
},
getResponsePaneOrientation: () => {
return get(getPreferences(), 'layout.responsePaneOrientation', 'horizontal');
},
getSystemProxyEnvVariables: () => {
const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env;
return {

View File

@@ -141,7 +141,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo
if (pkce) {
data['code_verifier'] = codeVerifier;
}
if (scope) {
if (scope && scope.trim() !== '') {
data.scope = scope;
}
requestCopy.data = qs.stringify(data);
@@ -344,7 +344,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo
if (clientSecret && credentialsPlacement !== "basic_auth_header") {
data.client_secret = clientSecret;
}
if (scope) {
if (scope && scope.trim() !== '') {
data.scope = scope;
}
requestCopy.data = qs.stringify(data);
@@ -515,7 +515,7 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid,
if (clientSecret && credentialsPlacement !== "basic_auth_header") {
data.client_secret = clientSecret;
}
if (scope) {
if (scope && scope.trim() !== '') {
data.scope = scope;
}
requestCopy.data = qs.stringify(data);

View File

@@ -543,7 +543,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
@@ -563,7 +563,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
@@ -579,7 +579,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false

View File

@@ -303,7 +303,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
@@ -323,7 +323,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
@@ -339,7 +339,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : '',
tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false

View File

@@ -1,4 +1,6 @@
import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import http from 'node:http';
import https from 'node:https';
/**
*
@@ -25,6 +27,8 @@ type ModifiedAxiosResponse = AxiosResponse & {
}
const baseRequestConfig: Partial<AxiosRequestConfig> = {
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true }),
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
const contentType = headers.getContentType() || '';
const hasJSONContentType = contentType.includes('json');

View File

@@ -0,0 +1,27 @@
meta {
name: Duplicate Keys
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: formUrlEncoded
auth: none
}
headers {
Content-Type: application/x-www-form-urlencoded
}
body:form-urlencoded {
tags: frontend
tags: api
user: john
}
script:post-response {
test('Response body matches expected value', function () {
expect(res.getBody()).to.eql("tags=frontend&tags=api&user=john");
});
}

View File

@@ -0,0 +1,8 @@
meta {
name: url-serialization
seq: 13
}
auth {
mode: inherit
}