Merge remote-tracking branch 'upstream/main'

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

View File

@ -13,6 +13,8 @@ jobs:
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 16
- name: Check package-lock.json
run: npm ci
- name: Install dependencies - name: Install dependencies
run: npm i --legacy-peer-deps run: npm i --legacy-peer-deps
- name: Test Package bruno-query - name: Test Package bruno-query

1
.gitignore vendored
View File

@ -4,7 +4,6 @@
node_modules node_modules
yarn.lock yarn.lock
pnpm-lock.yaml pnpm-lock.yaml
package-lock.json
.pnp .pnp
.pnp.js .pnp.js

View File

@ -19,7 +19,7 @@ Libraries we use
### Dependencies ### Dependencies
You would need [Node v14.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project You would need [Node v18.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
### Lets start coding ### Lets start coding

View File

@ -3,7 +3,8 @@
Bruno is being developed as a desktop app. You need to load the app by running the nextjs app in one terminal and then run the electron app in another terminal. Bruno is being developed as a desktop app. You need to load the app by running the nextjs app in one terminal and then run the electron app in another terminal.
### Dependencies ### Dependencies
* NodeJS v18
- NodeJS v18
### Local Development ### Local Development
@ -15,7 +16,6 @@ nvm use
npm i --legacy-peer-deps npm i --legacy-peer-deps
# build graphql docs # build graphql docs
# note: you can for now ignore the error thrown while building the graphql docs
npm run build:graphql-docs npm run build:graphql-docs
# build bruno query # build bruno query

29838
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -119,7 +119,7 @@ export default class CodeEditor extends React.Component {
render() { render() {
return ( return (
<StyledWrapper <StyledWrapper
className="h-full" className="h-full w-full"
aria-label="Code Editor" aria-label="Code Editor"
ref={(node) => { ref={(node) => {
this._node = 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 { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { IconEdit, IconTrash, IconDatabase } from '@tabler/icons'; import { useState } from 'react';
import EnvironmentVariables from './EnvironmentVariables'; import CopyEnvironment from '../../CopyEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment'; import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, collection }) => { const EnvironmentDetails = ({ environment, collection }) => {
const [openEditModal, setOpenEditModal] = useState(false); const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false); const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
return ( return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}> <div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
@ -20,6 +22,9 @@ const EnvironmentDetails = ({ environment, collection }) => {
collection={collection} collection={collection}
/> />
)} )}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} collection={collection} />
)}
<div className="flex"> <div className="flex">
<div className="flex flex-grow items-center"> <div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} /> <IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
@ -27,6 +32,7 @@ const EnvironmentDetails = ({ environment, collection }) => {
</div> </div>
<div className="flex gap-x-4 pl-4"> <div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} /> <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)} /> <IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,27 @@
import styled from 'styled-components'; import styled from 'styled-components';
const StyledWrapper = styled.div` 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 { div.CodeMirror {
/* todo: find a better way */ position: absolute;
height: calc(100vh - 220px); 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 { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; 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 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 { storedTheme } = useTheme();
const [tab, setTab] = useState('preview');
const dispatch = useDispatch(); 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 = () => { const onRun = () => {
if (disableRunEventListener) { if (disableRunEventListener) {
@ -17,18 +62,52 @@ const QueryResult = ({ item, collection, value, width, disableRunEventListener,
dispatch(sendRequest(item, collection.uid)); dispatch(sendRequest(item, collection.uid));
}; };
return ( const getTabClassname = (tabName) => {
<StyledWrapper className="px-3 w-full" style={{ maxWidth: width }}> return classnames(`select-none ${tabName}`, {
<div className="h-full"> active: tabName === tab,
<CodeEditor 'cursor-pointer': tabName !== tab
collection={collection} });
theme={storedTheme} };
onRun={onRun}
value={value || ''} const getTabs = () => {
mode={mode} if (!mode.includes('html')) {
readOnly 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> </div>
{activeResult}
</StyledWrapper> </StyledWrapper>
); );
}; };

View File

@ -7,7 +7,7 @@ const ResponseSize = ({ size }) => {
if (size > 1024) { if (size > 1024) {
// size is greater than 1kb // size is greater than 1kb
let kb = Math.floor(size / 1024); 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'; sizeToDisplay = kb + '.' + decimal + 'KB';
} else { } else {
sizeToDisplay = size + 'B'; sizeToDisplay = size + 'B';

View File

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

View File

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

View File

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

View File

@ -1,21 +1,61 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { IconSearch, IconFolders } from '@tabler/icons'; import {
IconSearch,
IconFolders,
IconArrowsSort,
IconSortAscendingLetters,
IconSortDescendingLetters
} from '@tabler/icons';
import Collection from '../Collections/Collection'; import Collection from '../Collections/Collection';
import CreateCollection from '../CreateCollection'; import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection'; import CreateOrOpenCollection from './CreateOrOpenCollection';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; 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 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 ( return (
<div className="items-center mt-2 relative"> <div className="items-center mt-2 relative">
<div className="collections-badge flex items-center pl-2 pr-2 py-1 select-none"> <div className="collections-badge flex items-center justify-between px-2">
<span className="mr-2"> <div className="flex items-center py-1 select-none">
<IconFolders size={18} strokeWidth={1.5} /> <span className="mr-2">
</span> <IconFolders size={18} strokeWidth={1.5} />
<span>Collections</span> </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>
</div> </div>
); );

View File

@ -27,8 +27,9 @@ const CreateCollection = ({ onClose }) => {
collectionFolderName: Yup.string() collectionFolderName: Yup.string()
.min(1, 'must be atleast 1 characters') .min(1, 'must be atleast 1 characters')
.max(50, 'must be 50 characters or less') .max(50, 'must be 50 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
.required('folder name is required'), .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) => { onSubmit: (values) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation)) dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
@ -43,7 +44,10 @@ const CreateCollection = ({ onClose }) => {
const browse = () => { const browse = () => {
dispatch(browseDirectory()) dispatch(browseDirectory())
.then((dirPath) => { .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) => { .catch((error) => {
formik.setFieldValue('collectionLocation', ''); formik.setFieldValue('collectionLocation', '');
@ -63,9 +67,8 @@ const CreateCollection = ({ onClose }) => {
<Modal size="sm" title="Create Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title="Create Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div> <div>
<label htmlFor="collectionName" className="flex items-center"> <label htmlFor="collection-name" className="flex items-center font-semibold">
<span className="font-semibold">Name</span> Name
<Tooltip text="Name of the collection" tooltipId="collection-name" />
</label> </label>
<input <input
id="collection-name" id="collection-name"
@ -84,9 +87,37 @@ const CreateCollection = ({ onClose }) => {
<div className="text-red-500">{formik.errors.collectionName}</div> <div className="text-red-500">{formik.errors.collectionName}</div>
) : null} ) : 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> <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> </label>
<input <input
id="collection-folder-name" id="collection-folder-name"
@ -103,34 +134,6 @@ const CreateCollection = ({ onClose }) => {
{formik.touched.collectionFolderName && formik.errors.collectionFolderName ? ( {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
<div className="text-red-500">{formik.errors.collectionFolderName}</div> <div className="text-red-500">{formik.errors.collectionFolderName}</div>
) : null} ) : 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> </div>
</form> </form>
</Modal> </Modal>

View File

@ -96,27 +96,16 @@ const Sidebar = () => {
/> />
</div> </div>
<div className="pl-1" style={{ position: 'relative', top: '3px' }}> <div className="pl-1" style={{ position: 'relative', top: '3px' }}>
{storedTheme === 'dark' ? ( <GitHubButton
<GitHubButton href="https://github.com/usebruno/bruno"
href="https://github.com/usebruno/bruno" data-color-scheme={storedTheme}
data-color-scheme="no-preference: dark; light: dark; dark: light;" data-show-count="true"
data-show-count="true" aria-label="Star usebruno/bruno on GitHub"
aria-label="Star usebruno/bruno on GitHub" >
> Star
Star </GitHubButton>
</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>
)}
</div> </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> </div>
</div> </div>

View File

@ -9,6 +9,40 @@ const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODE
if (!SERVER_RENDERED) { if (!SERVER_RENDERED) {
CodeMirror = require('codemirror'); 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 { class SingleLineEditor extends Component {
@ -32,6 +66,7 @@ class SingleLineEditor extends Component {
variables: getAllVariables(this.props.collection) variables: getAllVariables(this.props.collection)
}, },
scrollbarStyle: null, scrollbarStyle: null,
tabindex: 0,
extraKeys: { extraKeys: {
Enter: () => { Enter: () => {
if (this.props.onRun) { if (this.props.onRun) {
@ -70,9 +105,19 @@ class SingleLineEditor extends Component {
}, },
'Cmd-F': () => {}, 'Cmd-F': () => {},
'Ctrl-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.setValue(this.props.value || '');
this.editor.on('change', this._onEdit); this.editor.on('change', this._onEdit);
this.addOverlay(); this.addOverlay();

View File

@ -87,7 +87,7 @@ const VariablesEditor = ({ collection }) => {
<EnvVariables collection={collection} theme={reactInspectorTheme} /> <EnvVariables collection={collection} theme={reactInspectorTheme} />
<div className="mt-8 muted text-xs"> <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 /> <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. In the next release, we will add a UI to set and modify collection variables.
</div> </div>

View File

@ -54,7 +54,7 @@ const Welcome = () => {
<Bruno width={50} /> <Bruno width={50} />
</div> </div>
<div className="text-xl font-semibold select-none">bruno</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="uppercase font-semibold heading mt-10">Collections</div>
<div className="mt-4 flex items-center collection-options select-none"> <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 ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index'; import ThemeProvider from 'providers/Theme/index';
import ErrorBoundary from './ErrorBoundary';
import '../styles/app.scss'; import '../styles/app.scss';
import '../styles/globals.css'; import '../styles/globals.css';
@ -41,23 +42,25 @@ function MyApp({ Component, pageProps }) {
} }
return ( return (
<SafeHydrate> <ErrorBoundary>
<NoSsr> <SafeHydrate>
<Provider store={ReduxStore}> <NoSsr>
<ThemeProvider> <Provider store={ReduxStore}>
<ToastProvider> <ThemeProvider>
<AppProvider> <ToastProvider>
<PreferencesProvider> <AppProvider>
<HotkeysProvider> <PreferencesProvider>
<Component {...pageProps} /> <HotkeysProvider>
</HotkeysProvider> <Component {...pageProps} />
</PreferencesProvider> </HotkeysProvider>
</AppProvider> </PreferencesProvider>
</ToastProvider> </AppProvider>
</ThemeProvider> </ToastProvider>
</Provider> </ThemeProvider>
</NoSsr> </Provider>
</SafeHydrate> </NoSsr>
</SafeHydrate>
</ErrorBoundary>
); );
} }

View File

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

View File

@ -12,7 +12,6 @@ import {
getItemsToResequence, getItemsToResequence,
moveCollectionItemToRootOfCollection, moveCollectionItemToRootOfCollection,
findCollectionByUid, findCollectionByUid,
recursivelyGetAllItemUids,
transformRequestToSaveToFilesystem, transformRequestToSaveToFilesystem,
findParentItemInCollection, findParentItemInCollection,
findEnvironmentInCollection, findEnvironmentInCollection,
@ -22,7 +21,7 @@ import {
} from 'utils/collections'; } from 'utils/collections';
import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema'; import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common'; 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 { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import { import {
@ -39,6 +38,7 @@ import {
createCollection as _createCollection, createCollection as _createCollection,
renameCollection as _renameCollection, renameCollection as _renameCollection,
removeCollection as _removeCollection, removeCollection as _removeCollection,
sortCollections as _sortCollections,
collectionAddEnvFileEvent as _collectionAddEnvFileEvent collectionAddEnvFileEvent as _collectionAddEnvFileEvent
} from './index'; } from './index';
@ -145,6 +145,11 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) =>
.catch((err) => console.log(err)); .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) => { export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -262,7 +267,19 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
} }
const { ipcRenderer } = window; 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 ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type) .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)); .catch((error) => reject(error));
} }
return; 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) => { export const renameEnvironment = (newName, environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const state = getState(); const state = getState();

View File

@ -28,7 +28,8 @@ import { getSubdirectoriesFromRoot, getDirectoryName } from 'utils/common/platfo
const PATH_SEPARATOR = path.sep; const PATH_SEPARATOR = path.sep;
const initialState = { const initialState = {
collections: [] collections: [],
collectionSortOrder: 'default'
}; };
export const collectionsSlice = createSlice({ export const collectionsSlice = createSlice({
@ -38,12 +39,12 @@ export const collectionsSlice = createSlice({
createCollection: (state, action) => { createCollection: (state, action) => {
const collectionUids = map(state.collections, (c) => c.uid); const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload; const collection = action.payload;
// last action is used to track the last action performed on the collection // last action is used to track the last action performed on the collection
// this is optional // this is optional
// this is used in scenarios where we want to know the last action performed on the collection // 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 // and take some extra action based on that
// for example, when a env is created, we want to auto select it the env modal // for example, when a env is created, we want to auto select it the env modal
collection.importedAt = new Date().getTime();
collection.lastAction = null; collection.lastAction = null;
collapseCollection(collection); collapseCollection(collection);
@ -70,6 +71,20 @@ export const collectionsSlice = createSlice({
removeCollection: (state, action) => { removeCollection: (state, action) => {
state.collections = filter(state.collections, (c) => c.uid !== action.payload.collectionUid); 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) => { updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload; const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid); 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) => { addQueryParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid); 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) => { updateRequestBodyMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -1141,6 +1195,7 @@ export const {
brunoConfigUpdateEvent, brunoConfigUpdateEvent,
renameCollection, renameCollection,
removeCollection, removeCollection,
sortCollections,
updateLastAction, updateLastAction,
collectionUnlinkEnvFileEvent, collectionUnlinkEnvFileEvent,
saveEnvironment, saveEnvironment,
@ -1158,6 +1213,7 @@ export const {
collectionClicked, collectionClicked,
collectionFolderClicked, collectionFolderClicked,
requestUrlChanged, requestUrlChanged,
updateAuth,
addQueryParam, addQueryParam,
updateQueryParam, updateQueryParam,
deleteQueryParam, deleteQueryParam,
@ -1170,6 +1226,7 @@ export const {
addMultipartFormParam, addMultipartFormParam,
updateMultipartFormParam, updateMultipartFormParam,
deleteMultipartFormParam, deleteMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode, updateRequestBodyMode,
updateRequestBody, updateRequestBody,
updateRequestGraphqlQuery, updateRequestGraphqlQuery,

View File

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

View File

@ -9,13 +9,20 @@ const lightTheme = {
green: '#047857', green: '#047857',
danger: 'rgb(185, 28, 28)', danger: 'rgb(185, 28, 28)',
muted: '#4b5563', muted: '#4b5563',
purple: '#8e44ad' purple: '#8e44ad',
yellow: '#d97706'
}, },
bg: { bg: {
danger: '#dc3545' danger: '#dc3545'
} }
}, },
input: {
bg: 'white',
border: '#ccc',
focusBorder: '#8b8b8b'
},
menubar: { menubar: {
bg: 'rgb(44, 44, 44)' 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) { if (target.nodeName !== 'SPAN' || state.hoverTimeout !== undefined) {
return; return;
} }
if (!target.classList.contains('cm-variable-valid')) {
if (target.className !== 'cm-variable-valid') {
return; return;
} }

View File

@ -129,9 +129,11 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid); let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) { if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename); draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else { } else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid); 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); let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) { if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid); let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem); targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename); draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else { } else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid); let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem); collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename); draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
@ -203,7 +207,7 @@ export const getItemsToResequence = (parent, collection) => {
return itemsToResequence; return itemsToResequence;
}; };
export const transformCollectionToSaveToIdb = (collection, options = {}) => { export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => { const copyHeaders = (headers) => {
return map(headers, (header) => { return map(headers, (header) => {
return { return {
@ -281,6 +285,16 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
formUrlEncoded: copyFormUrlEncodedParams(si.draft.request.body.formUrlEncoded), formUrlEncoded: copyFormUrlEncodedParams(si.draft.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm) 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, script: si.draft.request.script,
vars: si.draft.request.vars, vars: si.draft.request.vars,
assertions: si.draft.request.assertions, assertions: si.draft.request.assertions,
@ -303,6 +317,16 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded), formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm) 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, script: si.request.script,
vars: si.request.vars, vars: si.request.vars,
assertions: si.request.assertions, assertions: si.request.assertions,
@ -351,6 +375,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
url: _item.request.url, url: _item.request.url,
params: [], params: [],
headers: [], headers: [],
auth: _item.request.auth,
body: _item.request.body, body: _item.request.body,
script: _item.request.script, script: _item.request.script,
vars: _item.request.vars, vars: _item.request.vars,
@ -445,6 +470,22 @@ export const humanizeRequestBodyMode = (mode) => {
return label; 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) => { export const refreshUidsInItem = (item) => {
item.uid = uuid(); item.uid = uuid();

View File

@ -25,10 +25,11 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
stream.eat('}'); stream.eat('}');
let found = pathFoundInVariables(word, variables); let found = pathFoundInVariables(word, variables);
if (found) { if (found) {
return 'variable-valid'; return 'variable-valid random-' + (Math.random() + 1).toString(36).substring(9);
} else { } 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; word += ch;
} }
@ -41,3 +42,25 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
return CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay); 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 // Remove any characters that are not alphanumeric, spaces, hyphens, or underscores
export const normalizeFileName = (name) => { export const normalizeFileName = (name) => {
if (!name) { if (!name) {
@ -76,18 +87,10 @@ export const getContentType = (headers) => {
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) { } else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml'; return 'application/xml';
} }
return contentType[0];
} }
} }
return ''; 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 trim from 'lodash/trim';
import path from 'path'; import path from 'path';
import slash from './slash'; import slash from './slash';
import platform from 'platform';
export const isElectron = () => { export const isElectron = () => {
if (!window) { if (!window) {
@ -33,3 +34,10 @@ export const getDirectoryName = (pathname) => {
return path.dirname(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 = { const brunoRequestItem = {
uid: uuid(), uid: uuid(),
name: request.name, name,
type: 'http-request', type: 'http-request',
request: { request: {
url: request.url, url: request.url,
@ -126,9 +139,7 @@ const parseInsomniaCollection = (data) => {
try { try {
const insomniaExport = JSON.parse(data); const insomniaExport = JSON.parse(data);
const insomniaResources = get(insomniaExport, 'resources', []); const insomniaResources = get(insomniaExport, 'resources', []);
const insomniaCollection = insomniaResources.find( const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
(resource) => resource._type === 'workspace' && resource.scope === 'collection'
);
if (!insomniaCollection) { if (!insomniaCollection) {
reject(new BrunoError('Collection not found inside Insomnia export')); 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) || []; resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
const requests = resources.filter((resource) => resource._type === 'request' && 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( const requests = resources.filter(
(resource) => resource._type === 'request' && resource.parentId === folder._id (resource) => resource._type === 'request' && resource.parentId === folder._id
); );
return { return {
uid: uuid(), uid: uuid(),
name: folder.name, name,
type: 'folder', type: 'folder',
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem)) items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
}; };

View File

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

View File

@ -53,3 +53,12 @@ export const splitOnFirst = (str, char) => {
return [str.slice(0, index), str.slice(index + 1)]; 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 # Changelog
## 0.11.0
- fix(#119) Support for Basic and Bearer Auth
## 0.10.1 ## 0.10.1
- fix(#233) Fixed Issue related to content header parsing - fix(#233) Fixed Issue related to content header parsing

View File

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

View File

@ -20,7 +20,7 @@ const run = async () => {
.commandDir('commands') .commandDir('commands')
.epilogue(CLI_EPILOGUE) .epilogue(CLI_EPILOGUE)
.usage('Usage: $0 <command> [options]') .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') .help('h')
.alias('h', 'help'); .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; return request;
}; };

View File

@ -18,6 +18,20 @@ const prepareRequest = (request) => {
headers: headers 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 || {}; request.body = request.body || {};
if (request.body.mode === 'json') { if (request.body.mode === 'json') {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "v0.16.3", "version": "v0.19.0",
"name": "bruno", "name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs", "description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com", "homepage": "https://www.usebruno.com",
@ -15,7 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@usebruno/js": "0.6.0", "@usebruno/js": "0.6.0",
"@usebruno/lang": "0.4.0", "@usebruno/lang": "0.5.0",
"@usebruno/schema": "0.5.0", "@usebruno/schema": "0.5.0",
"about-window": "^1.15.2", "about-window": "^1.15.2",
"axios": "^1.5.1", "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: () => click: () =>
openAboutWindow({ openAboutWindow({
product_name: 'Bruno', 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/', homepage: 'https://www.usebruno.com/',
package_json_dir: join(__dirname, '../..') package_json_dir: join(__dirname, '../..')
}) })

View File

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

View File

@ -16,9 +16,7 @@ setContentSecurityPolicy(`
default-src * 'unsafe-inline' 'unsafe-eval'; default-src * 'unsafe-inline' 'unsafe-eval';
script-src * 'unsafe-inline' 'unsafe-eval'; script-src * 'unsafe-inline' 'unsafe-eval';
connect-src * 'unsafe-inline'; connect-src * 'unsafe-inline';
base-uri 'none';
form-action 'none'; form-action 'none';
img-src 'self' data:image/svg+xml;
`); `);
const menu = Menu.buildFromTemplate(menuTemplate); const menu = Menu.buildFromTemplate(menuTemplate);
@ -35,7 +33,8 @@ app.on('ready', async () => {
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
contextIsolation: 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 // save environment
ipcMain.handle('renderer:save-environment', async (event, collectionPathname, environment) => { ipcMain.handle('renderer:save-environment', async (event, collectionPathname, environment) => {
try { 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 { getBrunoConfig } = require('../../store/bruno-config');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent');
const { makeAxiosInstance } = require('./axios-instance');
// override the default escape function to prevent escaping // override the default escape function to prevent escaping
Mustache.escape = function (value) { Mustache.escape = function (value) {
@ -105,6 +106,8 @@ const registerNetworkIpc = (mainWindow) => {
const request = prepareRequest(_request); const request = prepareRequest(_request);
const envVars = getEnvVars(environment); const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid); const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const allowScriptFilesystemAccess = get(brunoConfig, 'filesystemAccess.allow', false);
try { try {
// make axios work in node using form data // make axios work in node using form data
@ -156,7 +159,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:script-environment-update', { 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 // run post-response vars
const postResponseVars = get(request, 'vars.res', []); const postResponseVars = get(request, 'vars.res', []);
@ -280,7 +287,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:script-environment-update', { mainWindow.webContents.send('main:script-environment-update', {
@ -293,7 +301,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions // run assertions
const assertions = get(request, 'assertions'); const assertions = get(request, 'assertions');
if (assertions && assertions.length) { if (assertions) {
const assertRuntime = new AssertRuntime(); const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions( const results = assertRuntime.runAssertions(
assertions, assertions,
@ -315,7 +323,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests // run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.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 testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, testFile,
@ -325,7 +333,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:run-request-event', { mainWindow.webContents.send('main:run-request-event', {
@ -345,12 +354,16 @@ const registerNetworkIpc = (mainWindow) => {
} }
deleteCancelToken(cancelTokenUid); deleteCancelToken(cancelTokenUid);
// Prevents the duration on leaking to the actual result
const requestDuration = response.headers.get('request-duration');
response.headers.delete('request-duration');
return { return {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: response.headers, headers: response.headers,
data: response.data data: response.data,
duration: requestDuration
}; };
} catch (error) { } catch (error) {
// todo: better error handling // todo: better error handling
@ -367,7 +380,7 @@ const registerNetworkIpc = (mainWindow) => {
if (error && error.response) { if (error && error.response) {
// run assertions // run assertions
const assertions = get(request, 'assertions'); const assertions = get(request, 'assertions');
if (assertions && assertions.length) { if (assertions) {
const assertRuntime = new AssertRuntime(); const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions( const results = assertRuntime.runAssertions(
assertions, assertions,
@ -389,7 +402,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests // run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.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 testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, testFile,
@ -399,7 +412,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:run-request-event', { 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 { return {
status: error.response.status, status: error.response.status,
statusText: error.response.statusText, statusText: error.response.statusText,
headers: error.response.headers, 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 collectionUid = collection.uid;
const collectionPath = collection.pathname; const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null; const folderUid = folder ? folder.uid : null;
const brunoConfig = getBrunoConfig(collectionUid);
const allowScriptFilesystemAccess = get(brunoConfig, 'filesystemAccess.allow', false);
const onConsoleLog = (type, args) => { const onConsoleLog = (type, args) => {
console[type](...args); console[type](...args);
@ -590,7 +610,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:script-environment-update', { mainWindow.webContents.send('main:script-environment-update', {
@ -691,7 +712,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:script-environment-update', { mainWindow.webContents.send('main:script-environment-update', {
@ -703,7 +725,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions // run assertions
const assertions = get(item, 'request.assertions'); const assertions = get(item, 'request.assertions');
if (assertions && assertions.length) { if (assertions) {
const assertRuntime = new AssertRuntime(); const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions( const results = assertRuntime.runAssertions(
assertions, assertions,
@ -724,7 +746,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests // run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.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 testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, testFile,
@ -734,7 +756,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:run-folder-event', { mainWindow.webContents.send('main:run-folder-event', {
@ -782,7 +805,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions // run assertions
const assertions = get(item, 'request.assertions'); const assertions = get(item, 'request.assertions');
if (assertions && assertions.length) { if (assertions) {
const assertRuntime = new AssertRuntime(); const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions( const results = assertRuntime.runAssertions(
assertions, assertions,
@ -803,7 +826,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests // run tests
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.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 testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, testFile,
@ -813,7 +836,8 @@ const registerNetworkIpc = (mainWindow) => {
collectionVariables, collectionVariables,
collectionPath, collectionPath,
onConsoleLog, onConsoleLog,
processEnvVars processEnvVars,
allowScriptFilesystemAccess
); );
mainWindow.webContents.send('main:run-folder-event', { 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; return request;
}; };

View File

@ -18,6 +18,20 @@ const prepareRequest = (request) => {
headers: headers 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 (request.body.mode === 'json') {
if (!contentTypeDefined) { if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json'; axiosRequest.headers['content-type'] = 'application/json';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,8 @@ const { outdentString } = require('../../v1/src/utils');
* *
*/ */
const grammar = ohm.grammar(`Bru { 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 bodies = bodyjson | bodytext | bodyxml | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart bodyforms = bodyformurlencoded | bodymultipart
@ -75,6 +76,9 @@ const grammar = ohm.grammar(`Bru {
varsres = "vars:post-response" dictionary varsres = "vars:post-response" dictionary
assert = "assert" assertdictionary assert = "assert" assertdictionary
authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary
body = "body" st* "{" nl* textblock tagend body = "body" st* "{" nl* textblock tagend
bodyjson = "body:json" st* "{" nl* textblock tagend bodyjson = "body:json" st* "{" nl* textblock tagend
bodytext = "body:text" 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 docs = "docs" st* "{" nl* textblock tagend
}`); }`);
const mapPairListToKeyValPairs = (pairList = []) => { const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
if (!pairList.length) { if (!pairList.length) {
return []; return [];
} }
return _.map(pairList[0], (pair) => { return _.map(pairList[0], (pair) => {
let name = _.keys(pair)[0]; let name = _.keys(pair)[0];
let value = pair[name]; let value = pair[name];
if (!parseEnabled) {
return {
name,
value
};
}
let enabled = true; let enabled = true;
if (name && name.length && name.charAt(0) === '~') { if (name && name.length && name.charAt(0) === '~') {
name = name.slice(1); name = name.slice(1);
@ -282,6 +294,33 @@ const sem = grammar.createSemantics().addAttribute('ast', {
headers: mapPairListToKeyValPairs(dictionary.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) { bodyformurlencoded(_1, dictionary) {
return { return {
body: { body: {

View File

@ -13,7 +13,7 @@ const stripLastLine = (text) => {
}; };
const jsonToBru = (json) => { 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 = ''; let bru = '';
@ -34,6 +34,11 @@ const jsonToBru = (json) => {
body: ${http.body}`; body: ${http.body}`;
} }
if (http.auth && http.auth.length) {
bru += `
auth: ${http.auth}`;
}
bru += ` bru += `
} }
@ -82,6 +87,23 @@ const jsonToBru = (json) => {
bru += '\n}\n\n'; 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) { if (body && body.json && body.json.length) {
bru += `body:json { bru += `body:json {
${indentString(body.json)} ${indentString(body.json)}

View File

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

View File

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

View File

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

View File

@ -69,6 +69,27 @@ const requestBodySchema = Yup.object({
.noUnknown(true) .noUnknown(true)
.strict(); .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 // 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 // As we introduce more request types in the future, we will improve the definition to support
// schema structure based on other request type // schema structure based on other request type
@ -77,6 +98,7 @@ const requestSchema = Yup.object({
method: requestMethodSchema, method: requestMethodSchema,
headers: Yup.array().of(keyValueSchema).required('headers are required'), headers: Yup.array().of(keyValueSchema).required('headers are required'),
params: Yup.array().of(keyValueSchema).required('params are required'), params: Yup.array().of(keyValueSchema).required('params are required'),
auth: authSchema,
body: requestBodySchema, body: requestBodySchema,
script: Yup.object({ script: Yup.object({
req: Yup.string().nullable(), req: Yup.string().nullable(),

View File

@ -1,7 +1,7 @@
<br /> <br />
<img src="assets/images/logo-transparent.png" width="80"/> <img src="assets/images/logo-transparent.png" width="80"/>
### Bruno - Opensource IDE for exploring and testing api's. ### Bruno - Opensource IDE for exploring and testing APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) [![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/workflows/unit-tests.yml) [![CI](https://github.com/usebruno/bruno/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/workflows/unit-tests.yml)
@ -10,38 +10,48 @@
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) [![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads) [![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there. Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.
Bruno stores your collections directly in a folder on your filesystem. We use a plain text markup language, Bru, to save information about API requests. Bruno stores your collections directly in a folder on your filesystem. We use a plain text markup language, Bru, to save information about API requests.
You can use git or any version control of your choice to collaborate over your api collections. You can use git or any version control of your choice to collaborate over your API collections.
Bruno is offline-only. There are no plans to add cloud-sync to Bruno, ever. We value your data privacy and believe it should stay on your device. Read our long-term vision [here](https://github.com/usebruno/bruno/discussions/269)
![bruno](assets/images/landing-2.png) <br /><br /> ![bruno](assets/images/landing-2.png) <br /><br />
### Run across multiple platforms 🖥️ ### Run across multiple platforms 🖥️
![bruno](assets/images/run-anywhere.png) <br /><br /> ![bruno](assets/images/run-anywhere.png) <br /><br />
### Collaborate via Git 👩‍💻🧑‍💻 ### Collaborate via Git 👩‍💻🧑‍💻
Or any version control system of your choice Or any version control system of your choice
![bruno](assets/images/version-control.png) <br /><br /> ![bruno](assets/images/version-control.png) <br /><br />
### Website 📄 ### Website 📄
Please visit [here](https://www.usebruno.com) to checkout our website and download the app Please visit [here](https://www.usebruno.com) to checkout our website and download the app
### Documentation 📄 ### Documentation 📄
Please visit [here](https://docs.usebruno.com) for documentation Please visit [here](https://docs.usebruno.com) for documentation
### Support ❤️
Woof! If you like project, hit that ⭐ button !!
### Share Testimonials 📣
If Bruno has helped you at work and your teams, please don't forget to share your [testimonials on our github discussion](https://github.com/usebruno/bruno/discussions/343)
### Contribute 👩‍💻🧑‍💻 ### Contribute 👩‍💻🧑‍💻
I am happy that you are looking to improve bruno. Please checkout the [contributing guide](contributing.md) I am happy that you are looking to improve bruno. Please checkout the [contributing guide](contributing.md)
Even if you are not able to make contributions via code, please don't hesitate to file bugs and feature requests that needs to be implemented to solve your use case. Even if you are not able to make contributions via code, please don't hesitate to file bugs and feature requests that needs to be implemented to solve your use case.
### Support ❤️
Woof! If you like project, hit that ⭐ button !!
### Authors ### Authors
<div align="center"> <div align="center">
@ -51,9 +61,11 @@ Woof! If you like project, hit that ⭐ button !!
</div> </div>
### Stay in touch 🌐 ### Stay in touch 🌐
[Twitter](https://twitter.com/use_bruno) <br /> [Twitter](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com) <br /> [Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq) [Discord](https://discord.com/invite/KgcZUncpjq)
### License 📄 ### License 📄
[MIT](license.md) [MIT](license.md)