Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Alex Costinescu
2023-10-04 20:37:18 -04:00
89 changed files with 31518 additions and 252 deletions

View File

@@ -30,8 +30,11 @@
"graphiql": "^1.5.9",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"handlebars": "^4.7.8",
"httpsnippet": "^3.0.1",
"idb": "^7.0.0",
"immer": "^9.0.15",
"know-your-http-well": "^0.5.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"mousetrap": "^1.6.5",

View File

@@ -119,7 +119,7 @@ export default class CodeEditor extends React.Component {
render() {
return (
<StyledWrapper
className="h-full"
className="h-full w-full"
aria-label="Code Editor"
ref={(node) => {
this._node = node;

View File

@@ -0,0 +1,75 @@
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
import { useFormik } from 'formik';
import { copyEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import * as Yup from 'yup';
const CopyEnvironment = ({ collection, environment, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name + ' - Copy'
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be atleast 1 characters')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(copyEnvironment(values.name, environment.uid, collection.uid))
.then(() => {
toast.success('Environment created in collection');
onClose();
})
.catch(() => toast.error('An error occurred while created the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal size="sm" title={'Copy Environment'} confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="name" className="block font-semibold">
New Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CopyEnvironment;

View File

@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { IconEdit, IconTrash, IconDatabase } from '@tabler/icons';
import EnvironmentVariables from './EnvironmentVariables';
import RenameEnvironment from '../../RenameEnvironment';
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, collection }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
@@ -20,6 +22,9 @@ const EnvironmentDetails = ({ environment, collection }) => {
collection={collection}
/>
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} collection={collection} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
@@ -27,6 +32,7 @@ const EnvironmentDetails = ({ environment, collection }) => {
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
</div>
</div>

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components';
const Wrapper = styled.div`
color: ${(props) => props.theme.text};
&.modal--animate-out {
animation: fade-out 0.5s forwards cubic-bezier(0.19, 1, 0.22, 1);

View File

@@ -197,10 +197,11 @@ const AssertionRow = ({
<input
type="checkbox"
checked={assertion.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'enabled')}
/>
<button onClick={() => handleRemoveAssertion(assertion)}>
<button tabIndex="-1" onClick={() => handleRemoveAssertion(assertion)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@@ -0,0 +1,28 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.8125rem;
.auth-mode-selector {
background: transparent;
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@@ -0,0 +1,70 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const AuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: value
})
);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default AuthMode;

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleUsernameChange = (username) => {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: basicAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: basicAuth.username,
password: password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={basicAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUsernameChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={basicAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default BasicAuth;

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token')
: get(item, 'request.auth.bearer.token');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleTokenChange = (token) => {
dispatch(
updateAuth({
mode: 'bearer',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
token: token
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Token</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={bearerToken}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleTokenChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default BearerAuth;

View File

@@ -0,0 +1,5 @@
import styled from 'styled-components';
const Wrapper = styled.div``;
export default Wrapper;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import get from 'lodash/get';
import AuthMode from './AuthMode';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import StyledWrapper from './StyledWrapper';
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
}
}
};
return (
<StyledWrapper className="w-full">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);
};
export default Auth;

View File

@@ -116,10 +116,11 @@ const FormUrlEncodedParams = ({ item, collection }) => {
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button onClick={() => handleRemoveParams(param)}>
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@@ -7,6 +7,8 @@ import QueryParams from 'components/RequestPane/QueryParams';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
import AuthMode from 'components/RequestPane/Auth/AuthMode';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
@@ -38,6 +40,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'auth': {
return <Auth item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
@@ -83,6 +88,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>
@@ -95,15 +103,15 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
</div>
{/* Moved to post mvp */}
{/* <div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>Auth</div> */}
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">
<RequestBodyMode item={item} collection={collection} />
</div>
) : null}
</div>
<section className={`flex w-full ${['script', 'vars'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'}`}>
<section
className={`flex w-full ${['script', 'vars', 'auth'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'}`}
>
{getTabPanel(focusedTab.requestPaneTab)}
</section>
</StyledWrapper>

View File

@@ -116,10 +116,11 @@ const MultipartFormParams = ({ item, collection }) => {
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button onClick={() => handleRemoveParams(param)}>
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@@ -115,10 +115,11 @@ const QueryParams = ({ item, collection }) => {
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button onClick={() => handleRemoveParam(param)}>
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@@ -8,6 +8,8 @@ import { addRequestHeader, updateRequestHeader, deleteRequestHeader } from 'prov
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -91,6 +93,7 @@ const RequestHeaders = ({ item, collection }) => {
'name'
)
}
autocomplete={headerAutoCompleteList}
onRun={handleRun}
collection={collection}
/>
@@ -120,10 +123,11 @@ const RequestHeaders = ({ item, collection }) => {
<input
type="checkbox"
checked={header.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
/>
<button onClick={() => handleRemoveHeader(header)}>
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@@ -128,10 +128,11 @@ const VarsTable = ({ item, collection, vars, varType }) => {
<input
type="checkbox"
checked={_var.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleVarChange(e, _var, 'enabled')}
/>
<button onClick={() => handleRemoveVar(_var)}>
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@@ -13,13 +13,11 @@ const Placeholder = () => {
<div className="px-1 py-2">Send Request</div>
<div className="px-1 py-2">New Request</div>
<div className="px-1 py-2">Edit Environments</div>
<div className="px-1 py-2">Help</div>
</div>
<div className="flex flex-1 flex-col px-1">
<div className="px-1 py-2">Cmd + Enter</div>
<div className="px-1 py-2">Cmd + B</div>
<div className="px-1 py-2">Cmd + E</div>
<div className="px-1 py-2">Cmd + H</div>
</div>
</div>
</StyledWrapper>

View File

@@ -1,9 +1,27 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: grid;
grid-template-columns: 100%;
grid-template-rows: 1.25rem calc(100% - 1.25rem);
/* This is a hack to force Codemirror to use all available space */
> div {
position: relative;
}
div.CodeMirror {
/* todo: find a better way */
height: calc(100vh - 220px);
position: absolute;
top: 0;
bottom: 0;
height: 100%;
width: 100%;
}
div[role='tablist'] {
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;

View File

@@ -3,12 +3,57 @@ import CodeEditor from 'components/CodeEditor';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import classnames from 'classnames';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
import { useState } from 'react';
import { useMemo } from 'react';
const QueryResult = ({ item, collection, value, width, disableRunEventListener, mode }) => {
const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers }) => {
const { storedTheme } = useTheme();
const [tab, setTab] = useState('preview');
const dispatch = useDispatch();
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType);
const formatResponse = (data, mode) => {
if (!data) {
return '';
}
if (mode.includes('json')) {
return safeStringifyJSON(data, true);
}
if (mode.includes('xml')) {
let parsed = safeParseXML(data, { collapseContent: true });
if (typeof parsed === 'string') {
return parsed;
}
return safeStringifyJSON(parsed, true);
}
if (['text', 'html'].includes(mode)) {
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
}
// final fallback
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
};
const value = formatResponse(data, mode);
const onRun = () => {
if (disableRunEventListener) {
@@ -17,18 +62,52 @@ const QueryResult = ({ item, collection, value, width, disableRunEventListener,
dispatch(sendRequest(item, collection.uid));
};
return (
<StyledWrapper className="px-3 w-full" style={{ maxWidth: width }}>
<div className="h-full">
<CodeEditor
collection={collection}
theme={storedTheme}
onRun={onRun}
value={value || ''}
mode={mode}
readOnly
const getTabClassname = (tabName) => {
return classnames(`select-none ${tabName}`, {
active: tabName === tab,
'cursor-pointer': tabName !== tab
});
};
const getTabs = () => {
if (!mode.includes('html')) {
return null;
}
return (
<>
<div className={getTabClassname('raw')} role="tab" onClick={() => setTab('raw')}>
Raw
</div>
<div className={getTabClassname('preview')} role="tab" onClick={() => setTab('preview')}>
Preview
</div>
</>
);
};
const activeResult = useMemo(() => {
if (tab === 'preview' && mode.includes('html')) {
// Add the Base tag to the head so content loads properly. This also needs the correct CSP settings
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent.url}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
}
return <CodeEditor collection={collection} theme={storedTheme} onRun={onRun} value={value} mode={mode} readOnly />;
}, [tab, collection, storedTheme, onRun, value, mode]);
return (
<StyledWrapper className="px-3 w-full h-full" style={{ maxWidth: width }}>
<div className="flex justify-end gap-2 text-xs" role="tablist">
{getTabs()}
</div>
{activeResult}
</StyledWrapper>
);
};

View File

@@ -7,7 +7,7 @@ const ResponseSize = ({ size }) => {
if (size > 1024) {
// size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = ((size % 1024) / 1024).toFixed(2) * 100;
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
sizeToDisplay = size + 'B';

View File

@@ -2,7 +2,6 @@ import React from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { getContentType, formatResponse } from 'utils/common';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryResult from './QueryResult';
import Overlay from './Overlay';
@@ -41,8 +40,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
item={item}
collection={collection}
width={rightPaneWidth}
value={response.data ? formatResponse(response) : ''}
mode={getContentType(response.headers)}
data={response.data}
headers={response.headers}
/>
);
}
@@ -93,10 +92,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
};
const isJson = (headers) => {
return getContentType(headers) === 'application/ld+json';
};
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center px-3 tabs" role="tablist">
@@ -120,7 +115,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
</div>
) : null}
</div>
<section className="flex flex-grow mt-5">{getTabPanel(focusedTab.responsePaneTab)}</section>
<section className="flex flex-grow">{getTabPanel(focusedTab.responsePaneTab)}</section>
</StyledWrapper>
);
};

View File

@@ -33,7 +33,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
collection={collection}
width={rightPaneWidth}
disableRunEventListener={true}
value={responseReceived && responseReceived.data ? safeStringifyJSON(responseReceived.data, true) : ''}
data={responseReceived.data}
headers={responseReceived.headers}
/>
);
}

View File

@@ -0,0 +1,21 @@
import CodeEditor from 'components/CodeEditor/index';
import { HTTPSnippet } from 'httpsnippet';
import { useTheme } from 'providers/Theme/index';
import { buildHarRequest } from 'utils/codegenerator/har';
const CodeView = ({ language, item }) => {
const { storedTheme } = useTheme();
const { target, client, language: lang } = language;
let snippet = '';
try {
snippet = new HTTPSnippet(buildHarRequest(item.request)).convert(target, client);
} catch (e) {
console.error(e);
snippet = 'Error generating code snippet';
}
return <CodeEditor readOnly value={snippet} theme={storedTheme} mode={lang} />;
};
export default CodeView;

View File

@@ -0,0 +1,38 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
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};
min-height: 400px;
}
.generate-code-item {
min-width: 150px;
display: block;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
}
}
.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;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,145 @@
import Modal from 'components/Modal/index';
import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url/index';
import get from 'lodash/get';
import handlebars from 'handlebars';
import { findEnvironmentInCollection } from 'utils/collections';
const interpolateUrl = ({ url, envVars, collectionVariables, processEnvVars }) => {
if (!url || !url.length || typeof url !== 'string') {
return str;
}
const template = handlebars.compile(url, { noEscape: true });
return template({
...envVars,
...collectionVariables,
process: {
env: {
...processEnvVars
}
}
});
};
const languages = [
{
name: 'HTTP',
target: 'http',
client: 'http1.1'
},
{
name: 'JavaScript-Fetch',
target: 'javascript',
client: 'fetch'
},
{
name: 'Javascript-jQuery',
target: 'javascript',
client: 'jquery'
},
{
name: 'Javascript-axios',
target: 'javascript',
client: 'axios'
},
{
name: 'Python-Python3',
target: 'python',
client: 'python3'
},
{
name: 'Python-Requests',
target: 'python',
client: 'requests'
},
{
name: 'PHP',
target: 'php',
client: 'curl'
},
{
name: 'Shell-curl',
target: 'shell',
client: 'curl'
},
{
name: 'Shell-httpie',
target: 'shell',
client: 'httpie'
}
];
const GenerateCodeItem = ({ collection, item, onClose }) => {
const url = get(item, 'request.url') || '';
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
envVars = vars.reduce((acc, curr) => {
acc[curr.name] = curr.value;
return acc;
}, {});
}
const interpolatedUrl = interpolateUrl({
url,
envVars,
collectionVariables: collection.collectionVariables,
processEnvVars: collection.processEnvVariables
});
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full">
<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'
}
onClick={() => setSelectedLanguage(language)}
>
<span className="capitalize">{language.name}</span>
</div>
))}
</div>
</div>
<div className="flex-grow p-4">
{isValidUrl(interpolatedUrl) ? (
<CodeView
language={selectedLanguage}
item={{
...item,
request: {
...item.request,
url: interpolatedUrl
}
}}
/>
) : (
<div className="flex flex-col justify-center items-center w-full">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid URL: {interpolatedUrl}</h1>
<p className="text-gray-500">Please check the URL and try again</p>
</div>
</div>
)}
</div>
</div>
</StyledWrapper>
</Modal>
);
};
export default GenerateCodeItem;

View File

@@ -16,6 +16,7 @@ import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
@@ -32,6 +33,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
@@ -113,6 +115,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleDoubleClick = (event) => {
setRenameItemModalOpen(true);
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
@@ -166,6 +172,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
{runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
<div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className="flex items-center h-full w-full">
{indents && indents.length
@@ -173,6 +182,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
return (
<div
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{
@@ -188,6 +198,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
: null}
<div
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
@@ -264,6 +275,18 @@ const CollectionItem = ({ item, collection, searchText }) => {
Clone
</div>
)}
{!isFolder && item.type === 'http-request' && (
<div
className="dropdown-item"
onClick={(e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
setGenerateCodeItemModalOpen(true);
}}
>
Generate Code
</div>
)}
<div
className="dropdown-item delete-item"
onClick={(e) => {

View File

@@ -16,7 +16,7 @@ import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import CollectionProperties from './CollectionProperties';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest, transformCollectionToSaveToIdb } from 'utils/collections';
import { isItemAFolder, isItemARequest, transformCollectionToSaveToExportAsFile } from 'utils/collections';
import exportCollection from 'utils/collections/export';
import RenameCollection from './RenameCollection';
@@ -69,7 +69,7 @@ const Collection = ({ collection, searchText }) => {
const handleExportClick = () => {
const collectionCopy = cloneDeep(collection);
exportCollection(transformCollectionToSaveToIdb(collectionCopy));
exportCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
};
const [{ isOver }, drop] = useDrop({

View File

@@ -1,21 +1,61 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { IconSearch, IconFolders } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import {
IconSearch,
IconFolders,
IconArrowsSort,
IconSortAscendingLetters,
IconSortDescendingLetters
} from '@tabler/icons';
import Collection from '../Collections/Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
// the coding convention is to keep all the components in a folder named after the component
const CollectionsBadge = () => {
const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const sortCollectionOrder = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
return (
<div className="items-center mt-2 relative">
<div className="collections-badge flex items-center pl-2 pr-2 py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
<div className="collections-badge flex items-center justify-between px-2">
<div className="flex items-center py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
</div>
{collections.length >= 1 && (
<button onClick={() => sortCollectionOrder()}>
{collectionSortOrder == 'default' ? (
<IconArrowsSort size={18} strokeWidth={1.5} />
) : collectionSortOrder == 'alphabetical' ? (
<IconSortAscendingLetters size={18} strokeWidth={1.5} />
) : (
<IconSortDescendingLetters size={18} strokeWidth={1.5} />
)}
</button>
)}
</div>
</div>
);

View File

@@ -27,8 +27,9 @@ const CreateCollection = ({ onClose }) => {
collectionFolderName: Yup.string()
.min(1, 'must be atleast 1 characters')
.max(50, 'must be 50 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
.required('folder name is required'),
collectionLocation: Yup.string().required('location is required')
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
onSubmit: (values) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
@@ -43,7 +44,10 @@ const CreateCollection = ({ onClose }) => {
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
formik.setFieldValue('collectionLocation', dirPath);
// When the user closes the diolog without selecting anything dirPath will be false
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
@@ -63,9 +67,8 @@ const CreateCollection = ({ onClose }) => {
<Modal size="sm" title="Create Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="collectionName" className="flex items-center">
<span className="font-semibold">Name</span>
<Tooltip text="Name of the collection" tooltipId="collection-name" />
<label htmlFor="collection-name" className="flex items-center font-semibold">
Name
</label>
<input
id="collection-name"
@@ -84,9 +87,37 @@ const CreateCollection = ({ onClose }) => {
<div className="text-red-500">{formik.errors.collectionName}</div>
) : null}
<label htmlFor="collectionFolderName" className="flex items-center mt-3">
<label htmlFor="collection-location" className="block font-semibold mt-3">
Location
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
<label htmlFor="collection-folder-name" className="flex items-center mt-3">
<span className="font-semibold">Folder Name</span>
<Tooltip text="Name of the folder where your collection is stored" tooltipId="collection-folder-name" />
<Tooltip
text="This folder will be created under the selected location"
tooltipId="collection-folder-name-tooltip"
/>
</label>
<input
id="collection-folder-name"
@@ -103,34 +134,6 @@ const CreateCollection = ({ onClose }) => {
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null}
<>
<label htmlFor="collectionLocation" className="block font-semibold mt-3">
Location
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
/>
</>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
</form>
</Modal>

View File

@@ -96,27 +96,16 @@ const Sidebar = () => {
/>
</div>
<div className="pl-1" style={{ position: 'relative', top: '3px' }}>
{storedTheme === 'dark' ? (
<GitHubButton
href="https://github.com/usebruno/bruno"
data-color-scheme="no-preference: dark; light: dark; dark: light;"
data-show-count="true"
aria-label="Star usebruno/bruno on GitHub"
>
Star
</GitHubButton>
) : (
<GitHubButton
href="https://github.com/usebruno/bruno"
data-color-scheme="no-preference: light; light: light; dark: light;"
data-show-count="true"
aria-label="Star usebruno/bruno on GitHub"
>
Star
</GitHubButton>
)}
<GitHubButton
href="https://github.com/usebruno/bruno"
data-color-scheme={storedTheme}
data-show-count="true"
aria-label="Star usebruno/bruno on GitHub"
>
Star
</GitHubButton>
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.16.3</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.19.0</div>
</div>
</div>
</div>

View File

@@ -9,6 +9,40 @@ const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODE
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
CodeMirror.registerHelper('hint', 'anyword', (editor, options) => {
const word = /[\w$-]+/;
const wordlist = (options && options.autocomplete) || [];
let cur = editor.getCursor(),
curLine = editor.getLine(cur.line);
let end = cur.ch,
start = end;
while (start && word.test(curLine.charAt(start - 1))) --start;
let curWord = start != end && curLine.slice(start, end);
// Check if curWord is a valid string before proceeding
if (typeof curWord !== 'string' || curWord.length < 3) {
return null; // Abort the hint
}
const list = (options && options.list) || [];
const re = new RegExp(word.source, 'g');
for (let dir = -1; dir <= 1; dir += 2) {
let line = cur.line,
endLine = Math.min(Math.max(line + dir * 500, editor.firstLine()), editor.lastLine()) + dir;
for (; line != endLine; line += dir) {
let text = editor.getLine(line),
m;
while ((m = re.exec(text))) {
if (line == cur.line && curWord.length < 3) continue;
list.push(...wordlist.filter((el) => el.toLowerCase().startsWith(curWord.toLowerCase())));
}
}
}
return { list: [...new Set(list)], from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) };
});
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
}
class SingleLineEditor extends Component {
@@ -32,6 +66,7 @@ class SingleLineEditor extends Component {
variables: getAllVariables(this.props.collection)
},
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
@@ -70,9 +105,19 @@ class SingleLineEditor extends Component {
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
Tab: () => {}
// Tabbing disabled to make tabindex work
Tab: false,
'Shift-Tab': false
}
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
this.editor.setValue(this.props.value || '');
this.editor.on('change', this._onEdit);
this.addOverlay();

View File

@@ -87,7 +87,7 @@ const VariablesEditor = ({ collection }) => {
<EnvVariables collection={collection} theme={reactInspectorTheme} />
<div className="mt-8 muted text-xs">
Note: As of today, collection variables can only be set via the api -{' '}
Note: As of today, collection 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 collection variables.
</div>

View File

@@ -54,7 +54,7 @@ const Welcome = () => {
<Bruno width={50} />
</div>
<div className="text-xl font-semibold select-none">bruno</div>
<div className="mt-4">Opensource IDE for exploring and testing api's</div>
<div className="mt-4">Opensource IDE for exploring and testing APIs</div>
<div className="uppercase font-semibold heading mt-10">Collections</div>
<div className="mt-4 flex items-center collection-options select-none">

View File

@@ -0,0 +1,44 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
componentDidMount() {
// Add a global error event listener to capture client-side errors
window.onerror = (message, source, lineno, colno, error) => {
this.setState({ hasError: true, error });
};
}
componentDidCatch(error, errorInfo) {
console.log({ error, errorInfo });
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center p-10">
<div className="bg-white rounded-lg shadow-lg p-4 w-full">
<h1 className="text-2xl font-semibold text-red-600 mb-2">Oops! Something went wrong</h1>
<p className="text-red-600 mb-2">{this.state.error && this.state.error.toString()}</p>
{this.state.error && this.state.error.stack && (
<pre className="bg-gray-100 p-2 rounded-lg overflow-auto">{this.state.error.stack}</pre>
)}
<button
className="bg-red-500 text-white px-4 py-2 mt-4 rounded hover:bg-red-600 transition"
onClick={() => {
this.setState({ hasError: false, error: null });
}}
>
Close
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -7,6 +7,7 @@ import { PreferencesProvider } from 'providers/Preferences';
import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
import ErrorBoundary from './ErrorBoundary';
import '../styles/app.scss';
import '../styles/globals.css';
@@ -41,23 +42,25 @@ function MyApp({ Component, pageProps }) {
}
return (
<SafeHydrate>
<NoSsr>
<Provider store={ReduxStore}>
<ThemeProvider>
<ToastProvider>
<AppProvider>
<PreferencesProvider>
<HotkeysProvider>
<Component {...pageProps} />
</HotkeysProvider>
</PreferencesProvider>
</AppProvider>
</ToastProvider>
</ThemeProvider>
</Provider>
</NoSsr>
</SafeHydrate>
<ErrorBoundary>
<SafeHydrate>
<NoSsr>
<Provider store={ReduxStore}>
<ThemeProvider>
<ToastProvider>
<AppProvider>
<PreferencesProvider>
<HotkeysProvider>
<Component {...pageProps} />
</HotkeysProvider>
</PreferencesProvider>
</AppProvider>
</ToastProvider>
</ThemeProvider>
</Provider>
</NoSsr>
</SafeHydrate>
</ErrorBoundary>
);
}

View File

@@ -7,7 +7,6 @@ import SaveRequest from 'components/RequestPane/SaveRequest';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import BrunoSupport from 'components/BrunoSupport';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
@@ -22,7 +21,6 @@ export const HotkeysProvider = (props) => {
const [showSaveRequestModal, setShowSaveRequestModal] = useState(false);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showBrunoSupportModal, setShowBrunoSupportModal] = useState(false);
const getCurrentCollectionItems = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
@@ -133,18 +131,6 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// help (ctrl/cmd + h)
useEffect(() => {
Mousetrap.bind(['command+h', 'ctrl+h'], (e) => {
setShowBrunoSupportModal(true);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+h', 'ctrl+h']);
};
}, [setShowNewRequestModal]);
// close tab hotkey
useEffect(() => {
Mousetrap.bind(['command+w', 'ctrl+w'], (e) => {
@@ -164,7 +150,6 @@ export const HotkeysProvider = (props) => {
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showBrunoSupportModal && <BrunoSupport onClose={() => setShowBrunoSupportModal(false)} />}
{showSaveRequestModal && (
<SaveRequest items={getCurrentCollectionItems()} onClose={() => setShowSaveRequestModal(false)} />
)}

View File

@@ -12,7 +12,6 @@ import {
getItemsToResequence,
moveCollectionItemToRootOfCollection,
findCollectionByUid,
recursivelyGetAllItemUids,
transformRequestToSaveToFilesystem,
findParentItemInCollection,
findEnvironmentInCollection,
@@ -22,7 +21,7 @@ import {
} from 'utils/collections';
import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common';
import { getDirectoryName } from 'utils/common/platform';
import { getDirectoryName, isWindowsOS } from 'utils/common/platform';
import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import {
@@ -39,6 +38,7 @@ import {
createCollection as _createCollection,
renameCollection as _renameCollection,
removeCollection as _removeCollection,
sortCollections as _sortCollections,
collectionAddEnvFileEvent as _collectionAddEnvFileEvent
} from './index';
@@ -145,6 +145,11 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) =>
.catch((err) => console.log(err));
};
// todo: this can be directly put inside the collections/index.js file
// the coding convention is to put only actions that need ipc in this file
export const sortCollections = (order) => (dispatch) => {
dispatch(_sortCollections(order));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -262,7 +267,19 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-item', item.pathname, newPathname, newName).then(resolve).catch(reject);
ipcRenderer
.invoke('renderer:rename-item', item.pathname, newPathname, newName)
.then(() => {
// In case of Mac and Linux, we get the unlinkDir and addDir IPC events from electron which takes care of updating the state
// But in windows we don't get those events, so we need to update the state manually
// This looks like an issue in our watcher library chokidar
// GH: https://github.com/usebruno/bruno/issues/251
if (isWindowsOS()) {
dispatch(_renameItem({ newName, itemUid, collectionUid }));
}
resolve();
})
.catch(reject);
});
};
@@ -346,7 +363,16 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type)
.then(() => resolve())
.then(() => {
// In case of Mac and Linux, we get the unlinkDir IPC event from electron which takes care of updating the state
// But in windows we don't get those events, so we need to update the state manually
// This looks like an issue in our watcher library chokidar
// GH: https://github.com/usebruno/bruno/issues/265
if (isWindowsOS()) {
dispatch(_deleteItem({ itemUid, collectionUid }));
}
resolve();
})
.catch((error) => reject(error));
}
return;
@@ -620,6 +646,37 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
});
};
export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
const baseEnv = findEnvironmentInCollection(collection, baseEnvUid);
if (!collection) {
return reject(new Error('Environmnent not found'));
}
ipcRenderer
.invoke('renderer:copy-environment', collection.pathname, name, baseEnv.variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
payload: name
}
})
)
)
.then(resolve)
.catch(reject);
});
};
export const renameEnvironment = (newName, environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();

View File

@@ -28,7 +28,8 @@ import { getSubdirectoriesFromRoot, getDirectoryName } from 'utils/common/platfo
const PATH_SEPARATOR = path.sep;
const initialState = {
collections: []
collections: [],
collectionSortOrder: 'default'
};
export const collectionsSlice = createSlice({
@@ -38,12 +39,12 @@ export const collectionsSlice = createSlice({
createCollection: (state, action) => {
const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload;
// last action is used to track the last action performed on the collection
// this is optional
// this is used in scenarios where we want to know the last action performed on the collection
// and take some extra action based on that
// for example, when a env is created, we want to auto select it the env modal
collection.importedAt = new Date().getTime();
collection.lastAction = null;
collapseCollection(collection);
@@ -70,6 +71,20 @@ export const collectionsSlice = createSlice({
removeCollection: (state, action) => {
state.collections = filter(state.collections, (c) => c.uid !== action.payload.collectionUid);
},
sortCollections: (state, action) => {
state.collectionSortOrder = action.payload.order;
switch (action.payload.order) {
case 'default':
state.collections = state.collections.sort((a, b) => a.importedAt - b.importedAt);
break;
case 'alphabetical':
state.collections = state.collections.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'reverseAlphabetical':
state.collections = state.collections.sort((a, b) => b.name.localeCompare(a.name));
break;
}
},
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -307,6 +322,31 @@ export const collectionsSlice = createSlice({
}
}
},
updateAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.auth = item.draft.request.auth || {};
switch (action.payload.mode) {
case 'bearer':
item.draft.request.auth.mode = 'bearer';
item.draft.request.auth.bearer = action.payload.content;
break;
case 'basic':
item.draft.request.auth.mode = 'basic';
item.draft.request.auth.basic = action.payload.content;
break;
}
}
}
},
addQueryParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -563,6 +603,20 @@ export const collectionsSlice = createSlice({
}
}
},
updateRequestAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection && collection.items && collection.items.length) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.auth.mode = action.payload.mode;
}
}
},
updateRequestBodyMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1141,6 +1195,7 @@ export const {
brunoConfigUpdateEvent,
renameCollection,
removeCollection,
sortCollections,
updateLastAction,
collectionUnlinkEnvFileEvent,
saveEnvironment,
@@ -1158,6 +1213,7 @@ export const {
collectionClicked,
collectionFolderClicked,
requestUrlChanged,
updateAuth,
addQueryParam,
updateQueryParam,
deleteQueryParam,
@@ -1170,6 +1226,7 @@ export const {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,
updateRequestBody,
updateRequestGraphqlQuery,

View File

@@ -9,13 +9,20 @@ const darkTheme = {
green: 'rgb(11 178 126)',
danger: '#f06f57',
muted: '#9d9d9d',
purple: '#cd56d6'
purple: '#cd56d6',
yellow: '#f59e0b'
},
bg: {
danger: '#d03544'
}
},
input: {
bg: 'rgb(65, 65, 65)',
border: 'rgb(65, 65, 65)',
focusBorder: 'rgb(65, 65, 65)'
},
variables: {
bg: 'rgb(48, 48, 49)',

View File

@@ -9,13 +9,20 @@ const lightTheme = {
green: '#047857',
danger: 'rgb(185, 28, 28)',
muted: '#4b5563',
purple: '#8e44ad'
purple: '#8e44ad',
yellow: '#d97706'
},
bg: {
danger: '#dc3545'
}
},
input: {
bg: 'white',
border: '#ccc',
focusBorder: '#8b8b8b'
},
menubar: {
bg: 'rgb(44, 44, 44)'
},

View File

@@ -0,0 +1,71 @@
const createContentType = (mode) => {
switch (mode) {
case 'json':
return 'application/json';
case 'xml':
return 'application/xml';
case 'formUrlEncoded':
return 'application/x-www-form-urlencoded';
case 'multipartForm':
return 'multipart/form-data';
default:
return 'application/json';
}
};
const createHeaders = (headers, mode) => {
const contentType = createContentType(mode);
const headersArray = headers
.filter((header) => header.enabled)
.map((header) => {
return {
name: header.name,
value: header.value
};
});
const headerNames = headersArray.map((header) => header.name);
if (!headerNames.includes('Content-Type')) {
return [...headersArray, { name: 'Content-Type', value: contentType }];
}
return headersArray;
};
const createQuery = (queryParams = []) => {
return queryParams.map((param) => {
return {
name: param.name,
value: param.value
};
});
};
const createPostData = (body) => {
const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({ name: param.name, value: param.value }))
};
} else {
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
export const buildHarRequest = (request) => {
return {
method: request.method,
url: request.url,
httpVersion: 'HTTP/1.1',
cookies: [],
headers: createHeaders(request.headers, request.body.mode),
queryString: createQuery(request.params),
postData: createPostData(request.body),
headersSize: 0,
bodySize: 0
};
};

View File

@@ -66,8 +66,7 @@ if (!SERVER_RENDERED) {
if (target.nodeName !== 'SPAN' || state.hoverTimeout !== undefined) {
return;
}
if (target.className !== 'cm-variable-valid') {
if (!target.classList.contains('cm-variable-valid')) {
return;
}

View File

@@ -129,9 +129,11 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
@@ -143,10 +145,12 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
@@ -203,7 +207,7 @@ export const getItemsToResequence = (parent, collection) => {
return itemsToResequence;
};
export const transformCollectionToSaveToIdb = (collection, options = {}) => {
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
return map(headers, (header) => {
return {
@@ -281,6 +285,16 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
formUrlEncoded: copyFormUrlEncodedParams(si.draft.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm)
},
auth: {
mode: get(si.draft.request, 'auth.mode', 'none'),
basic: {
username: get(si.draft.request, 'auth.basic.username', ''),
password: get(si.draft.request, 'auth.basic.password', '')
},
bearer: {
token: get(si.draft.request, 'auth.bearer.token', '')
}
},
script: si.draft.request.script,
vars: si.draft.request.vars,
assertions: si.draft.request.assertions,
@@ -303,6 +317,16 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
},
auth: {
mode: get(si.request, 'auth.mode', 'none'),
basic: {
username: get(si.request, 'auth.basic.username', ''),
password: get(si.request, 'auth.basic.password', '')
},
bearer: {
token: get(si.request, 'auth.bearer.token', '')
}
},
script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
@@ -351,6 +375,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
url: _item.request.url,
params: [],
headers: [],
auth: _item.request.auth,
body: _item.request.body,
script: _item.request.script,
vars: _item.request.vars,
@@ -445,6 +470,22 @@ export const humanizeRequestBodyMode = (mode) => {
return label;
};
export const humanizeRequestAuthMode = (mode) => {
let label = 'No Auth';
switch (mode) {
case 'basic': {
label = 'Basic Auth';
break;
}
case 'bearer': {
label = 'Bearer Token';
break;
}
}
return label;
};
export const refreshUidsInItem = (item) => {
item.uid = uuid();

View File

@@ -25,10 +25,11 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
stream.eat('}');
let found = pathFoundInVariables(word, variables);
if (found) {
return 'variable-valid';
return 'variable-valid random-' + (Math.random() + 1).toString(36).substring(9);
} else {
return 'variable-invalid';
return 'variable-invalid random-' + (Math.random() + 1).toString(36).substring(9);
}
// Random classname added so adjacent variables are not rendered in the same SPAN by CodeMirror.
}
word += ch;
}
@@ -41,3 +42,25 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
return CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
});
};
export const getCodeMirrorModeBasedOnContentType = (contentType) => {
if (!contentType || typeof contentType !== 'string') {
return 'application/text';
}
if (contentType.includes('json')) {
return 'application/ld+json';
} else if (contentType.includes('xml')) {
return 'application/xml';
} else if (contentType.includes('html')) {
return 'application/html';
} else if (contentType.includes('text')) {
return 'application/text';
} else if (contentType.includes('application/edn')) {
return 'application/xml';
} else if (contentType.includes('yaml')) {
return 'application/yaml';
} else {
return 'application/text';
}
};

View File

@@ -51,6 +51,17 @@ export const safeStringifyJSON = (obj, indent = false) => {
}
};
export const safeParseXML = (str, options) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
try {
return xmlFormat(str, options);
} catch (e) {
return str;
}
};
// Remove any characters that are not alphanumeric, spaces, hyphens, or underscores
export const normalizeFileName = (name) => {
if (!name) {
@@ -76,18 +87,10 @@ export const getContentType = (headers) => {
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}
return contentType[0];
}
}
return '';
};
export const formatResponse = (response) => {
let type = getContentType(response.headers);
if (type.includes('json')) {
return safeStringifyJSON(response.data, true);
}
if (type.includes('xml')) {
return xmlFormat(response.data, { collapseContent: true });
}
return response.data;
};

View File

@@ -1,6 +1,7 @@
import trim from 'lodash/trim';
import path from 'path';
import slash from './slash';
import platform from 'platform';
export const isElectron = () => {
if (!window) {
@@ -33,3 +34,10 @@ export const getDirectoryName = (pathname) => {
return path.dirname(pathname);
};
export const isWindowsOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
return osFamily.includes('windows');
};

View File

@@ -30,10 +30,23 @@ const parseGraphQL = (text) => {
}
};
const transformInsomniaRequestItem = (request) => {
const addSuffixToDuplicateName = (item, index, allItems) => {
// Check if the request name already exist and if so add a number suffix
const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
if (otherItem.name === item.name && otherIndex < index) {
nameSuffix++;
}
return nameSuffix;
}, 0);
return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
};
const transformInsomniaRequestItem = (request, index, allRequests) => {
const name = addSuffixToDuplicateName(request, index, allRequests);
const brunoRequestItem = {
uid: uuid(),
name: request.name,
name,
type: 'http-request',
request: {
url: request.url,
@@ -126,9 +139,7 @@ const parseInsomniaCollection = (data) => {
try {
const insomniaExport = JSON.parse(data);
const insomniaResources = get(insomniaExport, 'resources', []);
const insomniaCollection = insomniaResources.find(
(resource) => resource._type === 'workspace' && resource.scope === 'collection'
);
const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
if (!insomniaCollection) {
reject(new BrunoError('Collection not found inside Insomnia export'));
@@ -145,14 +156,15 @@ const parseInsomniaCollection = (data) => {
resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
const folders = requestGroups.map((folder) => {
const folders = requestGroups.map((folder, index, allFolder) => {
const name = addSuffixToDuplicateName(folder, index, allFolder);
const requests = resources.filter(
(resource) => resource._type === 'request' && resource.parentId === folder._id
);
return {
uid: uuid(),
name: folder.name,
name,
type: 'folder',
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
};

View File

@@ -1,10 +1,8 @@
export const sendNetworkRequest = async (item, collection, environment, collectionVariables) => {
return new Promise((resolve, reject) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const timeStart = Date.now();
sendHttpRequest(item, collection, environment, collectionVariables)
.then((response) => {
const timeEnd = Date.now();
resolve({
state: 'success',
data: response.data,
@@ -12,7 +10,7 @@ export const sendNetworkRequest = async (item, collection, environment, collecti
size: response.headers['content-length'] || 0,
status: response.status,
statusText: response.statusText,
duration: timeEnd - timeStart
duration: response.duration
});
})
.catch((err) => reject(err));

View File

@@ -53,3 +53,12 @@ export const splitOnFirst = (str, char) => {
return [str.slice(0, index), str.slice(index + 1)];
};
export const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (err) {
return false;
}
};

View File

@@ -1,5 +1,9 @@
# Changelog
## 0.11.0
- fix(#119) Support for Basic and Bearer Auth
## 0.10.1
- fix(#233) Fixed Issue related to content header parsing

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/cli",
"version": "0.10.1",
"version": "0.11.0",
"license": "MIT",
"main": "src/index.js",
"bin": {
@@ -22,7 +22,7 @@
],
"dependencies": {
"@usebruno/js": "0.6.0",
"@usebruno/lang": "0.4.0",
"@usebruno/lang": "0.5.0",
"axios": "^1.5.1",
"chai": "^4.3.7",
"chalk": "^3.0.0",

View File

@@ -20,7 +20,7 @@ const run = async () => {
.commandDir('commands')
.epilogue(CLI_EPILOGUE)
.usage('Usage: $0 <command> [options]')
.demandCommand(1, "Woof !! Let's play with some apis !!")
.demandCommand(1, "Woof !! Let's play with some APIs !!")
.help('h')
.alias('h', 'help');
};

View File

@@ -109,6 +109,18 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
}
}
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
if (request.auth) {
const username = interpolate(request.auth.username) || '';
const password = interpolate(request.auth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
delete request.auth;
}
return request;
};

View File

@@ -18,6 +18,20 @@ const prepareRequest = (request) => {
headers: headers
};
// Authentication
if (request.auth) {
if (request.auth.mode === 'basic') {
axiosRequest.auth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
}
if (request.auth.mode === 'bearer') {
axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
}
}
request.body = request.body || {};
if (request.body.mode === 'json') {

View File

@@ -163,7 +163,7 @@ const runSingleRequest = async function (
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(
assertions,
@@ -187,7 +187,7 @@ const runSingleRequest = async function (
// run tests
let testResults = [];
const testFile = get(bruJson, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const result = await testRuntime.runTests(
testFile,
@@ -268,7 +268,7 @@ const runSingleRequest = async function (
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(
assertions,
@@ -292,7 +292,7 @@ const runSingleRequest = async function (
// run tests
let testResults = [];
const testFile = get(bruJson, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const result = await testRuntime.runTests(
testFile,

View File

@@ -38,6 +38,7 @@ const bruToJson = (bru) => {
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
auth: _.get(json, 'auth', {}),
params: _.get(json, 'query', []),
headers: _.get(json, 'headers', []),
body: _.get(json, 'body', {}),
@@ -49,6 +50,7 @@ const bruToJson = (bru) => {
};
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
return transformedJson;
} catch (err) {

View File

@@ -1,5 +1,5 @@
{
"version": "v0.16.3",
"version": "v0.19.0",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
@@ -15,7 +15,7 @@
},
"dependencies": {
"@usebruno/js": "0.6.0",
"@usebruno/lang": "0.4.0",
"@usebruno/lang": "0.5.0",
"@usebruno/schema": "0.5.0",
"about-window": "^1.15.2",
"axios": "^1.5.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,8 @@
.versions {
-webkit-user-select: text;
user-select: text;
}
.title {
-webkit-user-select: text;
user-select: text;
}

View File

@@ -51,7 +51,8 @@ const template = [
click: () =>
openAboutWindow({
product_name: 'Bruno',
icon_path: join(process.cwd(), '/resources/icons/png/256x256.png'),
icon_path: join(__dirname, '../about/256x256.png'),
css_path: join(__dirname, '../about/about.css'),
homepage: 'https://www.usebruno.com/',
package_json_dir: join(__dirname, '../..')
})

View File

@@ -61,6 +61,7 @@ const bruToJson = (bru) => {
url: _.get(json, 'http.url'),
params: _.get(json, 'query', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
script: _.get(json, 'script', {}),
vars: _.get(json, 'vars', {}),
@@ -69,6 +70,7 @@ const bruToJson = (bru) => {
}
};
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
return transformedJson;
@@ -104,10 +106,12 @@ const jsonToBru = (json) => {
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),
script: _.get(json, 'request.script', {}),
vars: {

View File

@@ -16,9 +16,7 @@ setContentSecurityPolicy(`
default-src * 'unsafe-inline' 'unsafe-eval';
script-src * 'unsafe-inline' 'unsafe-eval';
connect-src * 'unsafe-inline';
base-uri 'none';
form-action 'none';
img-src 'self' data:image/svg+xml;
`);
const menu = Menu.buildFromTemplate(menuTemplate);
@@ -35,7 +33,8 @@ app.on('ready', async () => {
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
preload: path.join(__dirname, 'preload.js'),
webviewTag: true
}
});

View File

@@ -151,6 +151,28 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
// copy environment
ipcMain.handle('renderer:copy-environment', async (event, collectionPathname, name, baseVariables) => {
try {
const envDirPath = path.join(collectionPathname, 'environments');
if (!fs.existsSync(envDirPath)) {
await createDirectory(envDirPath);
}
const envFilePath = path.join(envDirPath, `${name}.bru`);
if (fs.existsSync(envFilePath)) {
throw new Error(`environment: ${envFilePath} already exists`);
}
const content = envJsonToBru({
variables: baseVariables
});
await writeFile(envFilePath, content);
} catch (error) {
return Promise.reject(error);
}
});
// save environment
ipcMain.handle('renderer:save-environment', async (event, collectionPathname, environment) => {
try {

View File

@@ -0,0 +1,38 @@
const axios = require('axios');
/**
* Function that configures axios with timing interceptors
* Important to note here that the timings are not completely accurate.
* @see https://github.com/axios/axios/issues/695
* @returns {import('axios').AxiosStatic}
*/
function makeAxiosInstance() {
/** @type {import('axios').AxiosStatic} */
const instance = axios.create();
instance.interceptors.request.use((config) => {
config.headers['request-start-time'] = Date.now();
return config;
});
instance.interceptors.response.use(
(response) => {
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
return response;
},
(error) => {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
return Promise.reject(error);
}
);
return instance;
}
module.exports = {
makeAxiosInstance
};

View File

@@ -17,6 +17,7 @@ const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { makeAxiosInstance } = require('./axios-instance');
// override the default escape function to prevent escaping
Mustache.escape = function (value) {
@@ -105,6 +106,8 @@ const registerNetworkIpc = (mainWindow) => {
const request = prepareRequest(_request);
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const allowScriptFilesystemAccess = get(brunoConfig, 'filesystemAccess.allow', false);
try {
// make axios work in node using form data
@@ -156,7 +159,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:script-environment-update', {
@@ -242,7 +246,10 @@ const registerNetworkIpc = (mainWindow) => {
});
}
const response = await axios(request);
const axiosInstance = makeAxiosInstance();
/** @type {import('axios').AxiosResponse} */
const response = await axiosInstance(request);
// run post-response vars
const postResponseVars = get(request, 'vars.res', []);
@@ -280,7 +287,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:script-environment-update', {
@@ -293,7 +301,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions
const assertions = get(request, 'assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(
assertions,
@@ -315,7 +323,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests(
testFile,
@@ -325,7 +333,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:run-request-event', {
@@ -345,12 +354,16 @@ const registerNetworkIpc = (mainWindow) => {
}
deleteCancelToken(cancelTokenUid);
// Prevents the duration on leaking to the actual result
const requestDuration = response.headers.get('request-duration');
response.headers.delete('request-duration');
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
data: response.data,
duration: requestDuration
};
} catch (error) {
// todo: better error handling
@@ -367,7 +380,7 @@ const registerNetworkIpc = (mainWindow) => {
if (error && error.response) {
// run assertions
const assertions = get(request, 'assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(
assertions,
@@ -389,7 +402,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests(
testFile,
@@ -399,7 +412,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:run-request-event', {
@@ -418,11 +432,15 @@ const registerNetworkIpc = (mainWindow) => {
});
}
// Prevents the duration from leaking to the actual result
const requestDuration = error.response.headers.get('request-duration');
error.response.headers.delete('request-duration');
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data
data: error.response.data,
duration: requestDuration ?? 0
};
}
@@ -485,6 +503,8 @@ const registerNetworkIpc = (mainWindow) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null;
const brunoConfig = getBrunoConfig(collectionUid);
const allowScriptFilesystemAccess = get(brunoConfig, 'filesystemAccess.allow', false);
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -590,7 +610,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:script-environment-update', {
@@ -691,7 +712,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:script-environment-update', {
@@ -703,7 +725,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(
assertions,
@@ -724,7 +746,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests(
testFile,
@@ -734,7 +756,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:run-folder-event', {
@@ -782,7 +805,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(
assertions,
@@ -803,7 +826,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests(
testFile,
@@ -813,7 +836,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
);
mainWindow.webContents.send('main:run-folder-event', {

View File

@@ -109,6 +109,18 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
}
}
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
if (request.auth) {
const username = interpolate(request.auth.username) || '';
const password = interpolate(request.auth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
delete request.auth;
}
return request;
};

View File

@@ -18,6 +18,20 @@ const prepareRequest = (request) => {
headers: headers
};
// Authentication
if (request.auth) {
if (request.auth.mode === 'basic') {
axiosRequest.auth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
}
if (request.auth.mode === 'bearer') {
axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
}
}
if (request.body.mode === 'json') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';

View File

@@ -1,46 +1,48 @@
const { nodeResolve } = require("@rollup/plugin-node-resolve");
const commonjs = require("@rollup/plugin-commonjs");
const typescript = require("@rollup/plugin-typescript");
const dts = require("rollup-plugin-dts");
const postcss = require("rollup-plugin-postcss");
const { terser } = require("rollup-plugin-terser");
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const typescript = require('@rollup/plugin-typescript');
const dts = require('rollup-plugin-dts');
const postcss = require('rollup-plugin-postcss');
const { terser } = require('rollup-plugin-terser');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require("./package.json");
const packageJson = require('./package.json');
module.exports = [
{
input: "src/index.ts",
input: 'src/index.ts',
output: [
{
file: packageJson.main,
format: "cjs",
sourcemap: true,
format: 'cjs',
sourcemap: true
},
{
file: packageJson.module,
format: "esm",
sourcemap: true,
},
format: 'esm',
sourcemap: true
}
],
plugins: [
postcss({
minimize: true,
extensions: ['.css']
extensions: ['.css'],
extract: true
}),
peerDepsExternal(),
nodeResolve({
extensions: ['.css']
}),
commonjs(),
typescript({ tsconfig: "./tsconfig.json" }),
typescript({ tsconfig: './tsconfig.json' }),
terser()
],
external: ["react", "react-dom", "index.css"]
external: ['react', 'react-dom', 'index.css']
},
{
input: "dist/esm/index.d.ts",
output: [{ file: "dist/index.d.ts", format: "esm" }],
plugins: [dts.default()],
input: 'dist/esm/index.d.ts',
external: [/\.css$/],
output: [{ file: 'dist/index.d.ts', format: 'esm' }],
plugins: [dts.default()]
}
];
];

View File

@@ -1,6 +1,5 @@
import { DocExplorer } from './components/DocExplorer';
// Todo: Rollup throws error
import './index.css';
export { DocExplorer };

View File

@@ -2,10 +2,11 @@ const Handlebars = require('handlebars');
const { cloneDeep } = require('lodash');
class Bru {
constructor(envVariables, collectionVariables, processEnvVars) {
constructor(envVariables, collectionVariables, processEnvVars, collectionPath) {
this.envVariables = envVariables;
this.collectionVariables = collectionVariables;
this.processEnvVars = cloneDeep(processEnvVars || {});
this.collectionPath = collectionPath;
}
_interpolateEnvVar = (str) => {
@@ -24,6 +25,10 @@ class Bru {
});
};
cwd() {
return this.collectionPath;
}
getEnvName() {
return this.envVariables.__name__;
}

View File

@@ -7,6 +7,7 @@ const util = require('util');
const zlib = require('zlib');
const url = require('url');
const punycode = require('punycode');
const fs = require('fs');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
@@ -27,6 +28,8 @@ const CryptoJS = require('crypto-js');
class ScriptRuntime {
constructor() {}
// This approach is getting out of hand
// Need to refactor this to use a single arg (object) instead of 7
async runRequestScript(
script,
request,
@@ -34,9 +37,10 @@ class ScriptRuntime {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
) {
const bru = new Bru(envVariables, collectionVariables, processEnvVars);
const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
const req = new BrunoRequest(request);
const context = {
@@ -84,7 +88,8 @@ class ScriptRuntime {
axios,
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS
'crypto-js': CryptoJS,
fs: allowScriptFilesystemAccess ? fs : undefined
}
}
});
@@ -105,9 +110,10 @@ class ScriptRuntime {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
) {
const bru = new Bru(envVariables, collectionVariables, processEnvVars);
const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
@@ -138,6 +144,16 @@ class ScriptRuntime {
external: true,
root: [collectionPath],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
atob,
btoa,
lodash,
@@ -146,7 +162,8 @@ class ScriptRuntime {
nanoid,
axios,
'node-fetch': fetch,
'crypto-js': CryptoJS
'crypto-js': CryptoJS,
fs: allowScriptFilesystemAccess ? fs : undefined
}
}
});

View File

@@ -1,6 +1,14 @@
const { NodeVM } = require('vm2');
const chai = require('chai');
const path = require('path');
const http = require('http');
const https = require('https');
const stream = require('stream');
const util = require('util');
const zlib = require('zlib');
const url = require('url');
const punycode = require('punycode');
const fs = require('fs');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
@@ -29,9 +37,10 @@ class TestRuntime {
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars
processEnvVars,
allowScriptFilesystemAccess
) {
const bru = new Bru(envVariables, collectionVariables, processEnvVars);
const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
@@ -78,6 +87,16 @@ class TestRuntime {
external: true,
root: [collectionPath],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
atob,
axios,
btoa,
@@ -86,7 +105,8 @@ class TestRuntime {
uuid,
nanoid,
chai,
'crypto-js': CryptoJS
'crypto-js': CryptoJS,
fs: allowScriptFilesystemAccess ? fs : undefined
}
}
});

View File

@@ -1,7 +1,7 @@
{
"name": "@usebruno/lang",
"version": "0.4.0",
"license" : "MIT",
"version": "0.5.0",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",

View File

@@ -22,7 +22,8 @@ const { outdentString } = require('../../v1/src/utils');
*
*/
const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | headers | bodies | varsandassert | script | tests | docs)*
BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authbasic | authbearer
bodies = bodyjson | bodytext | bodyxml | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
@@ -75,6 +76,9 @@ const grammar = ohm.grammar(`Bru {
varsres = "vars:post-response" dictionary
assert = "assert" assertdictionary
authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary
body = "body" st* "{" nl* textblock tagend
bodyjson = "body:json" st* "{" nl* textblock tagend
bodytext = "body:text" st* "{" nl* textblock tagend
@@ -92,13 +96,21 @@ const grammar = ohm.grammar(`Bru {
docs = "docs" st* "{" nl* textblock tagend
}`);
const mapPairListToKeyValPairs = (pairList = []) => {
const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
if (!pairList.length) {
return [];
}
return _.map(pairList[0], (pair) => {
let name = _.keys(pair)[0];
let value = pair[name];
if (!parseEnabled) {
return {
name,
value
};
}
let enabled = true;
if (name && name.length && name.charAt(0) === '~') {
name = name.slice(1);
@@ -282,6 +294,33 @@ const sem = grammar.createSemantics().addAttribute('ast', {
headers: mapPairListToKeyValPairs(dictionary.ast)
};
},
authbasic(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const usernameKey = _.find(auth, { name: 'username' });
const passwordKey = _.find(auth, { name: 'password' });
const username = usernameKey ? usernameKey.value : '';
const password = passwordKey ? passwordKey.value : '';
return {
auth: {
basic: {
username,
password
}
}
};
},
authbearer(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const tokenKey = _.find(auth, { name: 'token' });
const token = tokenKey ? tokenKey.value : '';
return {
auth: {
bearer: {
token
}
}
};
},
bodyformurlencoded(_1, dictionary) {
return {
body: {

View File

@@ -13,7 +13,7 @@ const stripLastLine = (text) => {
};
const jsonToBru = (json) => {
const { meta, http, query, headers, body, script, tests, vars, assertions, docs } = json;
const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json;
let bru = '';
@@ -34,6 +34,11 @@ const jsonToBru = (json) => {
body: ${http.body}`;
}
if (http.auth && http.auth.length) {
bru += `
auth: ${http.auth}`;
}
bru += `
}
@@ -82,6 +87,23 @@ const jsonToBru = (json) => {
bru += '\n}\n\n';
}
if (auth && auth.basic) {
bru += `auth:basic {
${indentString(`username: ${auth.basic.username}`)}
${indentString(`password: ${auth.basic.password}`)}
}
`;
}
if (auth && auth.bearer) {
bru += `auth:bearer {
${indentString(`token: ${auth.bearer.token}`)}
}
`;
}
if (body && body.json && body.json.length) {
bru += `body:json {
${indentString(body.json)}

View File

@@ -7,6 +7,7 @@ meta {
get {
url: https://api.textlocal.in/send
body: json
auth: bearer
}
query {
@@ -21,6 +22,15 @@ headers {
~transaction-id: {{transactionId}}
}
auth:basic {
username: john
password: secret
}
auth:bearer {
token: 123
}
body:json {
{
"hello": "world"

View File

@@ -7,7 +7,8 @@
"http": {
"method": "get",
"url": "https://api.textlocal.in/send",
"body": "json"
"body": "json",
"auth": "bearer"
},
"query": [
{
@@ -43,6 +44,15 @@
"enabled": false
}
],
"auth": {
"basic": {
"username": "john",
"password": "secret"
},
"bearer": {
"token": "123"
}
},
"body": {
"json": "{\n \"hello\": \"world\"\n}",
"text": "This is a text body",

View File

@@ -14,7 +14,7 @@ describe('bruToJson', () => {
});
describe('jsonToBru', () => {
it('should parse the bru file', () => {
it('should parse the json file', () => {
const input = require('./fixtures/request.json');
const expected = fs.readFileSync(path.join(__dirname, 'fixtures', 'request.bru'), 'utf8');
const output = jsonToBru(input);

View File

@@ -69,6 +69,27 @@ const requestBodySchema = Yup.object({
.noUnknown(true)
.strict();
const authBasicSchema = Yup.object({
username: Yup.string().nullable(),
password: Yup.string().nullable()
})
.noUnknown(true)
.strict();
const authBearerSchema = Yup.object({
token: Yup.string().nullable()
})
.noUnknown(true)
.strict();
const authSchema = Yup.object({
mode: Yup.string().oneOf(['none', 'basic', 'bearer']).required('mode is required'),
basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable()
})
.noUnknown(true)
.strict();
// Right now, the request schema is very tightly coupled with http request
// As we introduce more request types in the future, we will improve the definition to support
// schema structure based on other request type
@@ -77,6 +98,7 @@ const requestSchema = Yup.object({
method: requestMethodSchema,
headers: Yup.array().of(keyValueSchema).required('headers are required'),
params: Yup.array().of(keyValueSchema).required('params are required'),
auth: authSchema,
body: requestBodySchema,
script: Yup.object({
req: Yup.string().nullable(),