mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 20:01:28 +00:00
Merge branch 'main' into codemirror_autocomplete_logic_refactor
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"assets/*": ["src/assets/*"],
|
||||
"ui/*": ["src/ui/*"],
|
||||
"components/*": ["src/components/*"],
|
||||
"hooks/*": ["src/hooks/*"],
|
||||
"themes/*": ["src/themes/*"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
packages/bruno-app/src/components/BulkEditor/index.js
Normal file
40
packages/bruno-app/src/components/BulkEditor/index.js
Normal 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;
|
||||
@@ -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')
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
.btn-action {
|
||||
font-size: 0.8125rem;
|
||||
&:hover span {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -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}>
|
||||
+ <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}>
|
||||
+ <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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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}}}\'');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import themes from 'themes/index';
|
||||
import useLocalStorage from 'hooks/useLocalStorage/index';
|
||||
|
||||
|
||||
@@ -248,6 +248,10 @@ const darkTheme = {
|
||||
codemirror: {
|
||||
bg: '#1e1e1e',
|
||||
border: '#373737',
|
||||
placeholder: {
|
||||
color: '#a2a2a2',
|
||||
opacity: 0.50
|
||||
},
|
||||
gutter: {
|
||||
bg: '#262626'
|
||||
},
|
||||
|
||||
@@ -249,6 +249,10 @@ const lightTheme = {
|
||||
codemirror: {
|
||||
bg: 'white',
|
||||
border: '#efefef',
|
||||
placeholder: {
|
||||
color: '#a2a2a2',
|
||||
opacity: 0.75
|
||||
},
|
||||
gutter: {
|
||||
bg: '#f3f3f3'
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
16
packages/bruno-app/src/ui/HeightBoundContainer/index.js
Normal file
16
packages/bruno-app/src/ui/HeightBoundContainer/index.js
Normal 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;
|
||||
@@ -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),
|
||||
|
||||
20
packages/bruno-app/src/utils/common/bulkKeyValueUtils.js
Normal file
20
packages/bruno-app/src/utils/common/bulkKeyValueUtils.js
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,144 +2,754 @@ const { describe, it, expect } = require('@jest/globals');
|
||||
import parseCurlCommand from './parse-curl';
|
||||
|
||||
describe('parseCurlCommand', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should handle basic GET request', () => {
|
||||
const result = parseCurlCommand('curl https://api.example.com/users');
|
||||
describe('Basic HTTP Methods', () => {
|
||||
it('should parse simple GET request', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users',
|
||||
method: 'get'
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse explicit POST method', () => {
|
||||
const result = parseCurlCommand('curl -X POST https://api.example.com/users');
|
||||
const result = parseCurlCommand(`
|
||||
curl -X POST https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users',
|
||||
method: 'post'
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse PUT method', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -X PUT https://api.example.com/users/1
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'put',
|
||||
url: 'https://api.example.com/users/1',
|
||||
urlWithoutQuery: 'https://api.example.com/users/1'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse DELETE method', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -X DELETE https://api.example.com/users/1
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'delete',
|
||||
url: 'https://api.example.com/users/1',
|
||||
urlWithoutQuery: 'https://api.example.com/users/1'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse HEAD method', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -I https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'head',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('headers handling', () => {
|
||||
it('should parse multiple headers', () => {
|
||||
const result = parseCurlCommand(
|
||||
`curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com`
|
||||
);
|
||||
describe('Headers', () => {
|
||||
it('should parse single header', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --header "Content-Type: application/json" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse multiple headers', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer token" \
|
||||
https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer token'
|
||||
}
|
||||
'Authorization': 'Bearer token'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse user-agent', () => {
|
||||
const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`);
|
||||
it('should parse user-agent header', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -A "Custom User Agent" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com',
|
||||
method: 'get',
|
||||
headers: {
|
||||
'User-Agent': 'Custom Agent'
|
||||
}
|
||||
'User-Agent': 'Custom User Agent'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth handling', () => {
|
||||
it('should parse basic auth', () => {
|
||||
const result = parseCurlCommand(`curl -u user:pass https://api.example.com`);
|
||||
describe('Data and Request Body', () => {
|
||||
it('should parse JSON data and change method to POST', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -d '{"name": "John", "age": 30}' https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: '{"name": "John", "age": 30}',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse post data', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --data "name=John&age=30" https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: 'name=John&age=30',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple data flags', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -d "name=John" \
|
||||
-d "age=30" \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: 'name=John&age=30',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep multiline data', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -d '{"key": "some long message with line breaks
|
||||
|
||||
|
||||
multiline"}' \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: `{"key": "some long message with line breaks
|
||||
|
||||
|
||||
multiline"}`,
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep multi space data', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -d '{"key": "some long spaced message"}' \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: '{"key": "some long spaced message"}',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse binary data flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --data-binary "@/path/to/file" https://api.example.com/upload
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: '@/path/to/file',
|
||||
isDataBinary: true,
|
||||
url: 'https://api.example.com/upload',
|
||||
urlWithoutQuery: 'https://api.example.com/upload'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse raw data flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --data-raw '{"raw": "data"}' https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: '{"raw": "data"}',
|
||||
isDataRaw: true,
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('should parse basic authentication', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -u "username:password" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
auth: {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
}
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle username without password', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --user "username" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
auth: {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'username',
|
||||
password: ''
|
||||
}
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Data', () => {
|
||||
it('should parse form data with text fields', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -F "name=John" \
|
||||
-F "age=30" \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
multipartUploads: [
|
||||
{ name: 'name', value: 'John', type: 'text', enabled: true },
|
||||
{ name: 'age', value: '30', type: 'text', enabled: true }
|
||||
],
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse form data with file uploads', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --form "file=@/path/to/file.txt" https://api.example.com/upload
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
multipartUploads: [
|
||||
{ name: 'file', value: '/path/to/file.txt', type: 'file', enabled: true }
|
||||
],
|
||||
url: 'https://api.example.com/upload',
|
||||
urlWithoutQuery: 'https://api.example.com/upload'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cookie', () => {
|
||||
it('should handle cookie flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -b "session=abc123" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Cookie': 'session=abc123'
|
||||
},
|
||||
cookieString: "session=abc123",
|
||||
cookies: {
|
||||
session: 'abc123'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cookie flag with multiple cookies', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -b "session=abc123; user=john" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Cookie': 'session=abc123; user=john'
|
||||
},
|
||||
cookieString: "session=abc123; user=john",
|
||||
cookies: {
|
||||
session: 'abc123',
|
||||
user: 'john'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple cookie flags', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -b "session=abc123" -b "user=john" https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Cookie': 'session=abc123; user=john'
|
||||
},
|
||||
cookieString: "session=abc123; user=john",
|
||||
cookies: {
|
||||
session: 'abc123',
|
||||
user: 'john'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex cookie string', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -b "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly" \
|
||||
https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Cookie': 'session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly'
|
||||
},
|
||||
cookieString: "session=abc123; user=john; path=/; domain=example.com; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly",
|
||||
cookies: {
|
||||
session: 'abc123',
|
||||
user: 'john',
|
||||
path: '/',
|
||||
domain: 'example.com',
|
||||
expires: 'Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Quote Handling', () => {
|
||||
it(`should handle shell quote patterns ('\'' => \')`, () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -d '{"name": "John\'\\'\'s data"}' https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: '{"name": "John\'s data"}',
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex escaped quotes', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -d '{"message": "Don\\'t stop believing"}' https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
data: '{"message": "Don\'t stop believing"}',
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Handling', () => {
|
||||
it('should parse URLs with query parameters', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl https://api.example.com/users?page=1&limit=10&sort=asc
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
query: {
|
||||
page: '1',
|
||||
limit: '10',
|
||||
sort: 'asc'
|
||||
},
|
||||
url: 'https://api.example.com/users?page=1&limit=10&sort=asc',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URLs with paths', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl https://api.example.com/v1/users/123
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://api.example.com/v1/users/123',
|
||||
urlWithoutQuery: 'https://api.example.com/v1/users/123'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle compressed flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --compressed https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Accept-Encoding': 'deflate, gzip'
|
||||
},
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle concatenated HTTP methods', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -XPOST https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle newlines and continuations', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d '{"name": "John"}' \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: '{"name": "John"}',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Examples', () => {
|
||||
it('should parse a complex curl command with multiple features', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer token123" \
|
||||
-H "X-Custom-Header: custom header" \
|
||||
-d '{"name": "John\\'s data", "email": "john@example.com", "message": "Don\\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}' \
|
||||
-u "api_user:api_pass" \
|
||||
--compressed \
|
||||
https://api.example.com/v1/users?param1=value1¶m2=custom+param
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer token123',
|
||||
'X-Custom-Header': 'custom header',
|
||||
'Accept-Encoding': 'deflate, gzip'
|
||||
},
|
||||
data: '{"name": "John\'s data", "email": "john@example.com", "message": "Don\'t stop believing!", "path": "/home/user/file.txt", "json": {"nested": "value", "array": [1, 2, 3]}}',
|
||||
auth: {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'api_user',
|
||||
password: 'api_pass'
|
||||
}
|
||||
},
|
||||
query: {
|
||||
param1: 'value1',
|
||||
param2: 'custom param'
|
||||
},
|
||||
url: 'https://api.example.com/v1/users?param1=value1¶m2=custom+param',
|
||||
urlWithoutQuery: 'https://api.example.com/v1/users'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('curl command with complex escape characters', () => {
|
||||
it('should parse a curl command with complex escape characters', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer token123" \
|
||||
-d '{"name": "John\\'s data", "email": "john@example.com"}' \
|
||||
-u "api_user:api_pass" \
|
||||
--compressed \
|
||||
https://api.example.com/v1/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer token123',
|
||||
'Accept-Encoding': 'deflate, gzip'
|
||||
},
|
||||
data: '{"name": "John\'s data", "email": "john@example.com"}',
|
||||
auth: {
|
||||
mode: 'basic',
|
||||
basic: {
|
||||
username: 'api_user',
|
||||
password: 'api_pass'
|
||||
}
|
||||
},
|
||||
url: 'https://api.example.com/v1/users',
|
||||
urlWithoutQuery: 'https://api.example.com/v1/users'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON Flag', () => {
|
||||
it('should handle basic JSON request', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --json '{"name": "John Doe", "email": "john@example.com"}' \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: '{"name": "John Doe", "email": "john@example.com"}',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with authentication headers', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --json '{"title": "New Post", "content": "Post content"}' \
|
||||
-H "Authorization: Bearer token123" \
|
||||
https://api.example.com/posts
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer token123'
|
||||
},
|
||||
data: '{"title": "New Post", "content": "Post content"}',
|
||||
url: 'https://api.example.com/posts',
|
||||
urlWithoutQuery: 'https://api.example.com/posts'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex JSON data', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --json '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}' \
|
||||
https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: '{"user": {"name": "Jane", "email": "jane@example.com"}, "metadata": {"source": "web"}}',
|
||||
url: 'https://api.example.com/users',
|
||||
urlWithoutQuery: 'https://api.example.com/users'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with escaped quotes', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --json '{"message": "Don\\'t stop believing!", "user": "John\\'s account"}' \
|
||||
https://api.example.com/messages
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: '{"message": "Don\'t stop believing!", "user": "John\'s account"}',
|
||||
url: 'https://api.example.com/messages',
|
||||
urlWithoutQuery: 'https://api.example.com/messages'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with arrays and nested objects', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --json '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}' \
|
||||
https://api.example.com/orders
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: '{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}], "total": 2}',
|
||||
url: 'https://api.example.com/orders',
|
||||
urlWithoutQuery: 'https://api.example.com/orders'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON with custom method', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -X PUT \
|
||||
--json '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}' \
|
||||
https://api.example.com/tasks/123
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'put',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: '{"status": "completed", "updated_at": "2024-01-15T10:30:00Z"}',
|
||||
url: 'https://api.example.com/tasks/123',
|
||||
urlWithoutQuery: 'https://api.example.com/tasks/123'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Insecure Flag', () => {
|
||||
it('should handle -k flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -k https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
insecure: true,
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle --insecure flag', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl --insecure https://api.example.com
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
insecure: true,
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query Flag', () => {
|
||||
it('should handle -G flag to convert POST data to GET query parameters', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -G -d "name=John" -d "age=30" https://api.example.com/users
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://api.example.com/users?name=John&age=30',
|
||||
urlWithoutQuery: 'https://api.example.com/users',
|
||||
query: {
|
||||
name: 'John',
|
||||
age: '30'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle -G flag with --data-urlencode', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -G --data-urlencode "name=John Doe" \
|
||||
--data-urlencode "email=john@example.com" \
|
||||
--data-urlencode "hello" \
|
||||
https://api.example.com/users?test=urlquery&hello
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://api.example.com/users?test=urlquery&name=John%20Doe&email=john@example.com&hello',
|
||||
urlWithoutQuery: 'https://api.example.com/users',
|
||||
query: {
|
||||
email: 'john@example.com',
|
||||
hello: '',
|
||||
name: 'John Doe',
|
||||
test: 'urlquery'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle -G flag with complex data', () => {
|
||||
const result = parseCurlCommand(`
|
||||
curl -G -d "search=test+query" \
|
||||
-d "filter=active" \
|
||||
-d "sort=name" \
|
||||
-d "page=1" \
|
||||
https://api.example.com/search
|
||||
`);
|
||||
|
||||
expect(result).toEqual({
|
||||
method: 'get',
|
||||
url: 'https://api.example.com/search?search=test+query&filter=active&sort=name&page=1',
|
||||
urlWithoutQuery: 'https://api.example.com/search',
|
||||
query: {
|
||||
search: 'test query',
|
||||
filter: 'active',
|
||||
sort: 'name',
|
||||
page: '1'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data handling', () => {
|
||||
it('should parse POST data', () => {
|
||||
const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`);
|
||||
expect(result).toEqual({
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com',
|
||||
method: 'post',
|
||||
data: 'foo=bar&baz=qux'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle data-binary', () => {
|
||||
const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`);
|
||||
expect(result).toEqual({
|
||||
url: 'https://api.example.com',
|
||||
urlWithoutQuery: 'https://api.example.com',
|
||||
method: 'post',
|
||||
data: '@file.json',
|
||||
isDataBinary: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form data handling', () => {
|
||||
it('should parse complex form data with multiple fields and file upload', () => {
|
||||
const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \
|
||||
--form 'id="1"' \
|
||||
--form 'documentid="ADMINN_ID"' \
|
||||
--form 'appoinID="12376"' \
|
||||
--form 'autoclose="false"' \
|
||||
--form 'fileData=@"/path/to/file"'`;
|
||||
|
||||
const result = parseCurlCommand(curlCommand);
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
|
||||
urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
|
||||
method: 'post',
|
||||
multipartUploads: [
|
||||
{
|
||||
name: 'id',
|
||||
value: '1',
|
||||
type: 'text',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'documentid',
|
||||
value: 'ADMINN_ID',
|
||||
type: 'text',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'appoinID',
|
||||
value: '12376',
|
||||
type: 'text',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'autoclose',
|
||||
value: 'false',
|
||||
type: 'text',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'fileData',
|
||||
value: '/path/to/file',
|
||||
type: 'file',
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: url-serialization
|
||||
seq: 13
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
Reference in New Issue
Block a user