diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..785378f91b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +# Description + + + +### Contribution Checklist: + +- [ ] **The pull request only addresses one issue or adds one feature.** +- [ ] **The pull request does not introduce any breaking changes** +- [ ] **I have added screenshots or gifs to help explain the change if applicable.** +- [ ] **I have read the [contribution guidelines](https://github.com/usebruno/bruno/blob/main/contributing.md).** +- [ ] **Create an issue and link to the pull request.** + +Note: Keeping the PR small and focused helps make it easier to review and merge. If you have multiple changes you want to make, please consider submitting them as separate pull requests. diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 86a9e0ebdb..a2c73beec1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,18 +5,16 @@ on: pull_request: branches: [main] jobs: - test: + tests: timeout-minutes: 60 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: 16 - - name: Check package-lock.json - run: npm ci + node-version-file: '.nvmrc' - name: Install dependencies - run: npm i --legacy-peer-deps + run: npm ci --legacy-peer-deps - name: Test Package bruno-query run: npm run test --workspace=packages/bruno-query - name: Build Package bruno-query @@ -33,3 +31,15 @@ jobs: run: npm run test --workspace=packages/bruno-cli - name: Test Package bruno-electron run: npm run test --workspace=packages/bruno-electron + + prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version-file: '.nvmrc' + - name: Install dependencies + run: npm ci --legacy-peer-deps + - name: Run Prettier + run: npm run test:prettier:web diff --git a/contributing.md b/contributing.md index abfcce4d33..966a6134b3 100644 --- a/contributing.md +++ b/contributing.md @@ -1,4 +1,4 @@ -**English** | [Русский](/contributing_ru.md) +**English** | [Українська](/contributing_ua.md) | [Русский](/contributing_ru.md) ## Lets make bruno better, together !! diff --git a/contributing_ru.md b/contributing_ru.md index a6993ae102..6636004ba5 100644 --- a/contributing_ru.md +++ b/contributing_ru.md @@ -1,4 +1,4 @@ -[English](/contributing.md) | **Русский** +[English](/contributing.md) | [Українська](/contributing_ua.md) | **Русский** ## Давайте вместе сделаем Бруно лучше!!! diff --git a/contributing_ua.md b/contributing_ua.md new file mode 100644 index 0000000000..75760f5653 --- /dev/null +++ b/contributing_ua.md @@ -0,0 +1,37 @@ +[English](/contributing.md) | **Українська** | [Русский](/contributing_ru.md) + +## Давайте зробимо Bruno краще, разом !! + +Я дуже радий що Ви бажаєте покращити Bruno. Нижче наведені вказівки як розпочати розробку Bruno на Вашому комп'ютері. + +### Стек технологій + +Bruno побудований на NextJs та React. Також для десктопної версії (яка підтримує локальні колекції) використовується Electron + +Бібліотеки, які ми використовуємо + +- CSS - Tailwind +- Редактори коду - Codemirror +- Керування станом - Redux +- Іконки - Tabler Icons +- Форми - formik +- Валідація по схемі - Yup +- Клієнт запитів - axios +- Спостерігач за файловою системою - chokidar + +### Залежності + +Вам знадобиться [Node v18.x або остання LTS версія](https://nodejs.org/en/) та npm 8.x. Ми використовуєм npm workspaces в цьому проекті + +### Починаєм писати код + +Будь ласка, зверніться до [development_ua.md](docs/development_ua.md) за інструкціями щодо запуску локального середовища розробки. + +### Створення Pull Request-ів + +- Будь ласка, робіть PR-и маленькими і сфокусованими на одній речі +- Будь ласка, слідуйте формату назв гілок + - feature/[назва feature]: Така гілка має містити зміни лише щодо конкретної feature + - Приклад: feature/dark-mode + - bugfix/[назва баґу]: Така гілка має містити лише виправлення конкретного багу + - Приклад: bugfix/bug-1 diff --git a/docs/development.md b/docs/development.md index c1c402e087..d56d3e6cf8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,4 +1,4 @@ -**English** | [Русский](/docs/development_ru.md) +**English** | [Українська](/docs/development_ua.md) | [Русский](/docs/development_ru.md) ## Development diff --git a/docs/development_ru.md b/docs/development_ru.md index 4d4e3a80e1..3816066e0f 100644 --- a/docs/development_ru.md +++ b/docs/development_ru.md @@ -1,4 +1,4 @@ -[English](/docs/development.md) | **Русский** +[English](/docs/development.md) | [Українська](/docs/development_ua.md) | **Русский** ## Разработка diff --git a/docs/development_ua.md b/docs/development_ua.md new file mode 100644 index 0000000000..d6d5bcdf87 --- /dev/null +++ b/docs/development_ua.md @@ -0,0 +1,55 @@ +[English](/docs/development.md) | **Українська** | [Русский](/docs/development_ru.md) + +## Розробка + +Bruno розробляється як декстопний застосунок. Вам потрібно запустити nextjs в одній сесії терміналу, та запустити застосунок Electron в іншій сесії терміналу. + +### Залежності + +- NodeJS v18 + +### Локальна розробка + +```bash +# Використовуйте nodejs 18-ї версії +nvm use + +# встановіть залежності +npm i --legacy-peer-deps + +# зберіть документацію graphql +npm run build:graphql-docs + +# зберіть bruno query +npm run build:bruno-query + +# запустіть додаток next (термінал 1) +npm run dev:web + +# запустіть додаток електрон (термінал 2) +npm run dev:electron +``` + +### Усунення несправностей + +Ви можете зтикнутись із помилкою `Unsupported platform` коли запускаєте `npm install`. Щоб усунути цю проблему, вам потрібно видалити `node_modules` та `package-lock.json`, і тоді запустити `npm install`. Це має встановити всі потрібні для запуску додатку пекеджі. + +```shell +# Видаліть node_modules в піддиректоріях +find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do + rm -rf "$dir" +done + +# Видаліть package-lock в піддиректоріях +find . -type f -name "package-lock.json" -delete +``` + +### Тестування + +```bash +# bruno-schema +npm test --workspace=packages/bruno-schema + +# bruno-lang +npm test --workspace=packages/bruno-lang +``` diff --git a/package-lock.json b/package-lock.json index e8f35901bd..a39d0c0282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16717,7 +16717,7 @@ }, "packages/bruno-electron": { "name": "bruno", - "version": "v0.24.0", + "version": "v0.25.0", "dependencies": { "@aws-sdk/credential-providers": "^3.425.0", "@usebruno/js": "0.8.0", diff --git a/package.json b/package.json index 311da85f4f..eab3cd9da9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "build:electron:snap": "./scripts/build-electron.sh snap", "test:e2e": "npx playwright test", "test:report": "npx playwright show-report", + "test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app", "prepare": "husky install" }, "overrides": { diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index ea4e9434d1..8ead3b9254 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "next lint", "test": "jest", + "test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"", "prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"" }, "dependencies": { diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index 09de00ddc2..fe0f4cc199 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -4,6 +4,7 @@ const StyledWrapper = styled.div` div.CodeMirror { background: ${(props) => props.theme.codemirror.bg}; border: solid 1px ${(props) => props.theme.codemirror.border}; + font-family: ${(props) => (props.font ? props.font : 'default')}; } .CodeMirror-overlayscroll-horizontal div, diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 96d5bb48a8..6ad999e6ba 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -121,6 +121,7 @@ export default class CodeEditor extends React.Component { { this._node = node; }} diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js index 1fe35eea08..bc9cb67b58 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AwsV4Auth/index.js @@ -12,7 +12,6 @@ const AwsV4Auth = ({ collection }) => { const { storedTheme } = useTheme(); const awsv4Auth = get(collection, 'root.request.auth.awsv4', {}); - console.log('saved auth', awsv4Auth); const handleSave = () => dispatch(saveCollectionRoot(collection.uid)); diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js new file mode 100644 index 0000000000..625bc98e6e --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/StyledWrapper.js @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .settings-label { + width: 90px; + } + + .certificate-icon { + color: ${(props) => props.theme.colors.text.yellow}; + } + + input { + width: 300px; + } + + .available-certificates { + background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + + button.remove-certificate { + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .textbox { + border: 1px solid #ccc; + padding: 0.15rem 0.45rem; + box-shadow: none; + border-radius: 0px; + outline: none; + box-shadow: none; + transition: border-color ease-in-out 0.1s; + border-radius: 3px; + background-color: ${(props) => props.theme.modal.input.bg}; + border: 1px solid ${(props) => props.theme.modal.input.border}; + + &:focus { + border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important; + outline: none !important; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js new file mode 100644 index 0000000000..235e274f5e --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -0,0 +1,130 @@ +import React from 'react'; +import { IconCertificate, IconTrash, IconWorld } from '@tabler/icons'; +import { useFormik } from 'formik'; +import { uuid } from 'utils/common'; +import * as Yup from 'yup'; + +import StyledWrapper from './StyledWrapper'; + +const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => { + const formik = useFormik({ + initialValues: { + domain: '', + certFilePath: '', + keyFilePath: '', + passphrase: '' + }, + validationSchema: Yup.object({ + domain: Yup.string().required(), + certFilePath: Yup.string().required(), + keyFilePath: Yup.string().required(), + passphrase: Yup.string() + }), + onSubmit: (values) => { + onUpdate(values); + } + }); + + const getFile = (e) => { + formik.values[e.name] = e.files[0].path; + }; + + return ( + +
+ Client Certificates +
+ + +

Add Client Certicate

+
+
+ + + {formik.touched.domain && formik.errors.domain ? ( +
{formik.errors.domain}
+ ) : null} +
+
+ + getFile(e.target)} + /> + {formik.touched.certFilePath && formik.errors.certFilePath ? ( +
{formik.errors.certFilePath}
+ ) : null} +
+
+ + getFile(e.target)} + /> + {formik.touched.keyFilePath && formik.errors.keyFilePath ? ( +
{formik.errors.keyFilePath}
+ ) : null} +
+
+ + + {formik.touched.passphrase && formik.errors.passphrase ? ( +
{formik.errors.passphrase}
+ ) : null} +
+
+ +
+
+
+ ); +}; + +export default ClientCertSettings; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index ca15cb3a58..f759af2e3e 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -1,7 +1,7 @@ import 'github-markdown-css/github-markdown.css'; import get from 'lodash/get'; import { updateCollectionDocs } from 'providers/ReduxStore/slices/collections'; -import { useTheme } from 'providers/Theme/index'; +import { useTheme } from 'providers/Theme'; import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions'; diff --git a/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js index 8bd1ece42c..fd3cc89865 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js @@ -1,13 +1,55 @@ import React, { useEffect } from 'react'; import { useFormik } from 'formik'; -import * as Yup from 'yup'; - +import Tooltip from 'components/Tooltip'; import StyledWrapper from './StyledWrapper'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; const ProxySettings = ({ proxyConfig, onUpdate }) => { + const proxySchema = Yup.object({ + use: Yup.string().oneOf(['global', 'true', 'false']), + protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']), + hostname: Yup.string() + .when('use', { + is: true, + then: (hostname) => hostname.required('Specify the hostname for your proxy.'), + otherwise: (hostname) => hostname.nullable() + }) + .max(1024), + port: Yup.number() + .when('use', { + is: true, + then: (port) => port.required('Specify port between 1 and 65535').typeError('Specify port between 1 and 65535'), + otherwise: (port) => port.nullable().transform((_, val) => (val ? Number(val) : null)) + }) + .min(1) + .max(65535), + auth: Yup.object() + .when('enabled', { + is: true, + then: Yup.object({ + enabled: Yup.boolean(), + username: Yup.string() + .when('enabled', { + is: true, + then: (username) => username.required('Specify username for proxy authentication.') + }) + .max(1024), + password: Yup.string() + .when('enabled', { + is: true, + then: (password) => password.required('Specify password for proxy authentication.') + }) + .max(1024) + }) + }) + .optional(), + bypassProxy: Yup.string().optional().max(1024) + }); + const formik = useFormik({ initialValues: { - enabled: proxyConfig.enabled || false, + use: proxyConfig.use || 'global', protocol: proxyConfig.protocol || 'http', hostname: proxyConfig.hostname || '', port: proxyConfig.port || '', @@ -15,27 +57,33 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false, username: proxyConfig.auth ? proxyConfig.auth.username || '' : '', password: proxyConfig.auth ? proxyConfig.auth.password || '' : '' - } + }, + bypassProxy: proxyConfig.bypassProxy || '' }, - validationSchema: Yup.object({ - enabled: Yup.boolean(), - protocol: Yup.string().oneOf(['http', 'https', 'socks5']), - hostname: Yup.string().max(1024), - port: Yup.number().min(0).max(65535), - auth: Yup.object({ - enabled: Yup.boolean(), - username: Yup.string().max(1024), - password: Yup.string().max(1024) - }) - }), + validationSchema: proxySchema, onSubmit: (values) => { - onUpdate(values); + proxySchema + .validate(values, { abortEarly: true }) + .then((validatedProxy) => { + // serialize 'use' to boolean + if (validatedProxy.use === 'true') { + validatedProxy.use = true; + } else if (validatedProxy.use === 'false') { + validatedProxy.use = false; + } + + onUpdate(validatedProxy); + }) + .catch((error) => { + let errMsg = error.message || 'Preferences validation error'; + toast.error(errMsg); + }); } }); useEffect(() => { formik.setValues({ - enabled: proxyConfig.enabled || false, + use: proxyConfig.use === true ? 'true' : proxyConfig.use === false ? 'false' : 'global', protocol: proxyConfig.protocol || 'http', hostname: proxyConfig.hostname || '', port: proxyConfig.port || '', @@ -43,18 +91,66 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false, username: proxyConfig.auth ? proxyConfig.auth.username || '' : '', password: proxyConfig.auth ? proxyConfig.auth.password || '' : '' - } + }, + bypassProxy: proxyConfig.bypassProxy || '' }); }, [proxyConfig]); return ( +

Proxy Settings

-
+ `} + tooltipId="request-var" + /> - +
+ + + +
+
@@ -132,7 +239,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { onChange={formik.handleChange} value={formik.values.port} /> - {formik.touched.port && formik.errors.port ?
{formik.errors.port}
: null} + {formik.touched.port && formik.errors.port ? ( +
{formik.errors.port}
+ ) : null}
@@ -183,10 +292,30 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => { onChange={formik.handleChange} /> {formik.touched.auth?.password && formik.errors.auth?.password ? ( -
{formik.errors.auth.password}
+
{formik.errors.auth.password}
) : null}
+
+ + + {formik.touched.bypassProxy && formik.errors.bypassProxy ? ( +
{formik.errors.bypassProxy}
+ ) : null} +
-
{getTabPanel(tab)}
+
+ {getTabPanel(tab)} +
); }; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js index 15c4efa008..d86a0641e0 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; -import Portal from 'components/Portal/index'; -import Modal from 'components/Modal/index'; +import Portal from 'components/Portal'; +import Modal from 'components/Modal'; import toast from 'react-hot-toast'; import { useFormik } from 'formik'; import { addEnvironment } from 'providers/ReduxStore/slices/collections/actions'; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js index 722a15db17..687cde46c9 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -10,6 +10,7 @@ const StyledWrapper = styled.div` 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; + height: 100%; } .environment-item { @@ -35,7 +36,8 @@ const StyledWrapper = styled.div` } } - .btn-create-environment { + .btn-create-environment, + .btn-import-environment { padding: 8px 10px; cursor: pointer; border-bottom: none; @@ -47,6 +49,10 @@ const StyledWrapper = styled.div` } } } + + .btn-import-environment { + color: ${(props) => props.theme.colors.text.muted}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js index b80cd92a50..dd7ac4f798 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -1,14 +1,19 @@ import React, { useEffect, useState, forwardRef, useRef } from 'react'; import { findEnvironmentInCollection } from 'utils/collections'; +import toast from 'react-hot-toast'; +import { toastError } from 'utils/common/error'; import usePrevious from 'hooks/usePrevious'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from '../CreateEnvironment/index'; +import CreateEnvironment from '../CreateEnvironment'; +import { IconDownload } from '@tabler/icons'; +import ImportEnvironment from '../ImportEnvironment'; import StyledWrapper from './StyledWrapper'; const EnvironmentList = ({ collection }) => { const { environments } = collection; const [selectedEnvironment, setSelectedEnvironment] = useState(null); const [openCreateModal, setOpenCreateModal] = useState(false); + const [openImportModal, setOpenImportModal] = useState(false); const envUids = environments ? environments.map((env) => env.uid) : []; const prevEnvUids = usePrevious(envUids); @@ -48,9 +53,10 @@ const EnvironmentList = ({ collection }) => { return ( {openCreateModal && setOpenCreateModal(false)} />} + {openImportModal && setOpenImportModal(false)} />}
-
+
{environments && environments.length && environments.map((env) => ( @@ -65,6 +71,11 @@ const EnvironmentList = ({ collection }) => {
setOpenCreateModal(true)}> + Create
+ +
setOpenImportModal(true)}> + + Import +
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js new file mode 100644 index 0000000000..5caba79b21 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import Portal from 'components/Portal'; +import toast from 'react-hot-toast'; +import { useDispatch } from 'react-redux'; +import importPostmanEnvironment from 'utils/importers/postman-environment'; +import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { toastError } from 'utils/common/error'; +import Modal from 'components/Modal'; + +const ImportEnvironment = ({ onClose, collection }) => { + const dispatch = useDispatch(); + + const handleImportPostmanEnvironment = () => { + importPostmanEnvironment() + .then((environment) => { + dispatch(importEnvironment(environment.name, environment.variables, collection.uid)) + .then(() => { + toast.success('Environment imported successfully'); + onClose(); + }) + .catch(() => toast.error('An error occurred while importing the environment')); + }) + .catch((err) => toastError(err, 'Postman Import environment failed')); + }; + + return ( + + +
+
+ Postman Environment +
+
+
+
+ ); +}; + +export default ImportEnvironment; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js index 855e0fb31f..6daccc3746 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js @@ -3,10 +3,12 @@ import React, { useState } from 'react'; import CreateEnvironment from './CreateEnvironment'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; +import ImportEnvironment from './ImportEnvironment'; const EnvironmentSettings = ({ collection, onClose }) => { const { environments } = collection; const [openCreateModal, setOpenCreateModal] = useState(false); + const [openImportModal, setOpenImportModal] = useState(false); if (!environments || !environments.length) { return ( @@ -20,13 +22,23 @@ const EnvironmentSettings = ({ collection, onClose }) => { hideCancel={true} > {openCreateModal && setOpenCreateModal(false)} />} -
+ {openImportModal && setOpenImportModal(false)} />} +

No environments found!

+ + Or + +
diff --git a/packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js new file mode 100644 index 0000000000..d45eda5b68 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.text}; +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/Font/index.js b/packages/bruno-app/src/components/Preferences/Font/index.js new file mode 100644 index 0000000000..2f27fea8b7 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Font/index.js @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import get from 'lodash/get'; +import { useSelector, useDispatch } from 'react-redux'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from './StyledWrapper'; + +const Font = ({ close }) => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + + const [codeFont, setCodeFont] = useState(get(preferences, 'font.codeFont', 'default')); + + const handleInputChange = (event) => { + setCodeFont(event.target.value); + }; + + const handleSave = () => { + dispatch( + savePreferences({ + ...preferences, + font: { + codeFont + } + }) + ).then(() => { + close(); + }); + }; + + return ( + + + + +
+ +
+
+ ); +}; + +export default Font; diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js index 637c483e5f..00e91df7c9 100644 --- a/packages/bruno-app/src/components/Preferences/General/index.js +++ b/packages/bruno-app/src/components/Preferences/General/index.js @@ -1,36 +1,103 @@ -import React, { useState } from 'react'; -import { usePreferences } from 'providers/Preferences'; +import React from 'react'; +import { useFormik } from 'formik'; +import { useSelector, useDispatch } from 'react-redux'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; import StyledWrapper from './StyledWrapper'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; -const General = () => { - const { preferences, setPreferences } = usePreferences(); +const General = ({ close }) => { + const preferences = useSelector((state) => state.app.preferences); + const dispatch = useDispatch(); - const [sslVerification, setSslVerification] = useState(preferences.request.sslVerification); + const preferencesSchema = Yup.object().shape({ + sslVerification: Yup.boolean(), + timeout: Yup.mixed() + .transform((value, originalValue) => { + return originalValue === '' ? undefined : value; + }) + .nullable() + .test('isNumber', 'Request Timeout must be a number', (value) => { + return value === undefined || !isNaN(value); + }) + .test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => { + return value === undefined || Number(value) >= 0; + }) + }); - const handleCheckboxChange = () => { - const updatedPreferences = { - ...preferences, - request: { - ...preferences.request, - sslVerification: !sslVerification + const formik = useFormik({ + initialValues: { + sslVerification: preferences.request.sslVerification, + timeout: preferences.request.timeout + }, + validationSchema: preferencesSchema, + onSubmit: async (values) => { + try { + const newPreferences = await preferencesSchema.validate(values, { abortEarly: true }); + handleSave(newPreferences); + } catch (error) { + console.error('Preferences validation error:', error.message); } - }; + } + }); - setPreferences(updatedPreferences) + const handleSave = (newPreferences) => { + dispatch( + savePreferences({ + ...preferences, + request: { + sslVerification: newPreferences.sslVerification, + timeout: newPreferences.timeout + } + }) + ) .then(() => { - setSslVerification(!sslVerification); + close(); }) - .catch((err) => { - console.error(err); - }); + .catch((err) => console.log(err) && toast.error('Failed to update preferences')); }; return ( -
- - SSL Certificate Verification -
+ +
+ + +
+
+ + +
+ {formik.touched.timeout && formik.errors.timeout ? ( +
{formik.errors.timeout}
+ ) : null} +
+ +
+
); }; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js new file mode 100644 index 0000000000..42d06266d3 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .settings-label { + width: 80px; + } + + .textbox { + border: 1px solid #ccc; + padding: 0.15rem 0.45rem; + box-shadow: none; + outline: none; + transition: border-color ease-in-out 0.1s; + border-radius: 3px; + background-color: ${(props) => props.theme.modal.input.bg}; + border: 1px solid ${(props) => props.theme.modal.input.border}; + + &:focus { + border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important; + outline: none !important; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js new file mode 100644 index 0000000000..6b3fb7877b --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js @@ -0,0 +1,292 @@ +import React, { useEffect } from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; + +import StyledWrapper from './StyledWrapper'; +import { useDispatch, useSelector } from 'react-redux'; + +const ProxySettings = ({ close }) => { + const preferences = useSelector((state) => state.app.preferences); + const dispatch = useDispatch(); + + const proxySchema = Yup.object({ + enabled: Yup.boolean(), + protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']), + hostname: Yup.string() + .when('enabled', { + is: true, + then: (hostname) => hostname.required('Specify the hostname for your proxy.'), + otherwise: (hostname) => hostname.nullable() + }) + .max(1024), + port: Yup.number() + .when('enabled', { + is: true, + then: (port) => port.required('Specify port between 1 and 65535').typeError('Specify port between 1 and 65535'), + otherwise: (port) => port.nullable().transform((_, val) => (val ? Number(val) : null)) + }) + .min(1) + .max(65535), + auth: Yup.object() + .when('enabled', { + is: true, + then: Yup.object({ + enabled: Yup.boolean(), + username: Yup.string() + .when(['enabled'], { + is: true, + then: (username) => username.required('Specify username for proxy authentication.') + }) + .max(1024), + password: Yup.string() + .when('enabled', { + is: true, + then: (password) => password.required('Specify password for proxy authentication.') + }) + .max(1024) + }) + }) + .optional(), + bypassProxy: Yup.string().optional().max(1024) + }); + + const formik = useFormik({ + initialValues: { + enabled: preferences.proxy.enabled || false, + protocol: preferences.proxy.protocol || 'http', + hostname: preferences.proxy.hostname || '', + port: preferences.proxy.port || 0, + auth: { + enabled: preferences.proxy.auth ? preferences.proxy.auth.enabled || false : false, + username: preferences.proxy.auth ? preferences.proxy.auth.username || '' : '', + password: preferences.proxy.auth ? preferences.proxy.auth.password || '' : '' + }, + bypassProxy: preferences.proxy.bypassProxy || '' + }, + validationSchema: proxySchema, + onSubmit: (values) => { + onUpdate(values); + } + }); + + const onUpdate = (values) => { + proxySchema + .validate(values, { abortEarly: true }) + .then((validatedProxy) => { + dispatch( + savePreferences({ + ...preferences, + proxy: validatedProxy + }) + ).then(() => { + close(); + }); + }) + .catch((error) => { + let errMsg = error.message || 'Preferences validation error'; + toast.error(errMsg); + }); + }; + + useEffect(() => { + formik.setValues({ + enabled: preferences.proxy.enabled || false, + protocol: preferences.proxy.protocol || 'http', + hostname: preferences.proxy.hostname || '', + port: preferences.proxy.port || '', + auth: { + enabled: preferences.proxy.auth ? preferences.proxy.auth.enabled || false : false, + username: preferences.proxy.auth ? preferences.proxy.auth.username || '' : '', + password: preferences.proxy.auth ? preferences.proxy.auth.password || '' : '' + }, + bypassProxy: preferences.proxy.bypassProxy || '' + }); + }, [preferences]); + + return ( + +

Global Proxy Settings

+
+
+ + +
+
+ +
+ + + + +
+
+
+ + + {formik.touched.hostname && formik.errors.hostname ? ( +
{formik.errors.hostname}
+ ) : null} +
+
+ + + {formik.touched.port && formik.errors.port ? ( +
{formik.errors.port}
+ ) : null} +
+
+ + +
+
+
+ + + {formik.touched.auth?.username && formik.errors.auth?.username ? ( +
{formik.errors.auth.username}
+ ) : null} +
+
+ + + {formik.touched.auth?.password && formik.errors.auth?.password ? ( +
{formik.errors.auth.password}
+ ) : null} +
+
+
+ + + {formik.touched.bypassProxy && formik.errors.bypassProxy ? ( +
{formik.errors.bypassProxy}
+ ) : null} +
+
+ +
+
+
+ ); +}; + +export default ProxySettings; diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js index 455a6748fb..843fd8228d 100644 --- a/packages/bruno-app/src/components/Preferences/index.js +++ b/packages/bruno-app/src/components/Preferences/index.js @@ -3,7 +3,9 @@ import classnames from 'classnames'; import React, { useState } from 'react'; import Support from './Support'; import General from './General'; +import Font from './Font'; import Theme from './Theme'; +import Proxy from './ProxySettings'; import StyledWrapper from './StyledWrapper'; const Preferences = ({ onClose }) => { @@ -18,16 +20,24 @@ const Preferences = ({ onClose }) => { const getTabPanel = (tab) => { switch (tab) { case 'general': { - return ; + return ; + } + + case 'proxy': { + return ; } case 'theme': { - return ; + return ; } case 'support': { return ; } + + case 'font': { + return ; + } } }; @@ -41,6 +51,12 @@ const Preferences = ({ onClose }) => {
setTab('theme')}> Theme
+
setTab('font')}> + Font +
+
setTab('proxy')}> + Proxy +
setTab('support')}> Support
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js index 9ed29ac073..7c144fbf86 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js @@ -13,7 +13,6 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => { const { storedTheme } = useTheme(); const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {}); - console.log('saved auth', awsv4Auth); const handleRun = () => dispatch(sendRequest(item, collection.uid)); const handleSave = () => dispatch(saveRequest(item.uid, collection.uid)); diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index 29c46de770..98845b55b8 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -36,12 +36,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog const request = item.draft ? item.draft.request : item.request; - let { - schema, - loadSchema, - isLoading: isSchemaLoading, - error: schemaError - } = useGraphqlSchema(url, environment, request, collection); + let { schema, loadSchema, isLoading: isSchemaLoading } = useGraphqlSchema(url, environment, request, collection); const loadGqlSchema = () => { if (!isSchemaLoading) { diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js index e28ea5bc47..c824c57513 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js @@ -26,7 +26,12 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => { const loadSchema = () => { setIsLoading(true); fetchGqlSchema(endpoint, environment, request, collection) - .then((res) => res.data) + .then((res) => { + if (!res || res.status !== 200) { + return Promise.reject(new Error(res.statusText)); + } + return res.data; + }) .then((s) => { if (s && s.data) { setSchema(buildClientSchema(s.data)); @@ -40,7 +45,7 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => { .catch((err) => { setIsLoading(false); setError(err); - toast.error('Error occurred while loading GraphQL Schema'); + toast.error(`Error occurred while loading GraphQL Schema: ${err.message}`); }); }; diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js index 59b1320447..0d913d97f8 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js @@ -1,5 +1,6 @@ import React from 'react'; -import { useDispatch } from 'react-redux'; +import get from 'lodash/get'; +import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -10,6 +11,7 @@ const GraphQLVariables = ({ variables, item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); const onEdit = (value) => { dispatch( @@ -30,6 +32,7 @@ const GraphQLVariables = ({ variables, item, collection }) => { collection={collection} value={variables || ''} theme={storedTheme} + font={get(preferences, 'font.codeFont', 'default')} onEdit={onEdit} mode="javascript" onRun={onRun} diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js index e727745243..3d571b4bf1 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js @@ -4,7 +4,7 @@ const Wrapper = styled.div` font-size: 0.8125rem; .body-mode-selector { - background: ${(props) => props.theme.requestTabPanel.bodyModeSelect.color}; + background: transparent; border-radius: 3px; .dropdown-item { @@ -15,6 +15,10 @@ const Wrapper = styled.div` .label-item { padding: 0.2rem 0.6rem !important; } + + .selected-body-mode { + color: ${(props) => props.theme.colors.text.yellow}; + } } .caret { diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js index 0d3b63df6d..ef000431fe 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js @@ -6,16 +6,19 @@ import { useDispatch } from 'react-redux'; import { updateRequestBodyMode } from 'providers/ReduxStore/slices/collections'; import { humanizeRequestBodyMode } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; +import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index'; +import { toastError } from 'utils/common/error'; const RequestBodyMode = ({ item, collection }) => { const dispatch = useDispatch(); const dropdownTippyRef = useRef(); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode'); + const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); + const bodyMode = body?.mode; const Icon = forwardRef((props, ref) => { return ( -
+
{humanizeRequestBodyMode(bodyMode)}
); @@ -31,6 +34,24 @@ const RequestBodyMode = ({ item, collection }) => { ); }; + const onPrettify = () => { + if (body?.json && bodyMode === 'json') { + try { + const bodyJson = JSON.parse(body.json); + const prettyBodyJson = JSON.stringify(bodyJson, null, 2); + dispatch( + updateRequestBody({ + content: prettyBodyJson, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + } catch (e) { + toastError(new Error('Unable to prettify. Invalid JSON format.')); + } + } + }; + return (
@@ -103,6 +124,11 @@ const RequestBodyMode = ({ item, collection }) => {
+ {bodyMode === 'json' && ( + + )} ); }; diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js index c69a6b0ca3..9daaf37f14 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js @@ -3,7 +3,7 @@ import get from 'lodash/get'; import CodeEditor from 'components/CodeEditor'; import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams'; import MultipartFormParams from 'components/RequestPane/MultipartFormParams'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { updateRequestBody } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -14,6 +14,7 @@ const RequestBody = ({ item, collection }) => { const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode'); const { storedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); const onEdit = (value) => { dispatch( @@ -48,6 +49,7 @@ const RequestBody = ({ item, collection }) => { { const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res'); const { storedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); const onRequestScriptEdit = (value) => { dispatch( @@ -45,6 +46,7 @@ const Script = ({ item, collection }) => { collection={collection} value={requestScript || ''} theme={storedTheme} + font={get(preferences, 'font.codeFont', 'default')} onEdit={onRequestScriptEdit} mode="javascript" onRun={onRun} @@ -57,6 +59,7 @@ const Script = ({ item, collection }) => { collection={collection} value={responseScript || ''} theme={storedTheme} + font={get(preferences, 'font.codeFont', 'default')} onEdit={onResponseScriptEdit} mode="javascript" onRun={onRun} diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js index 351afd3d32..66645509aa 100644 --- a/packages/bruno-app/src/components/RequestPane/Tests/index.js +++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js @@ -1,6 +1,6 @@ import React from 'react'; import get from 'lodash/get'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import { updateRequestTests } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -12,6 +12,7 @@ const Tests = ({ item, collection }) => { const tests = item.draft ? get(item, 'draft.request.tests') : get(item, 'request.tests'); const { storedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); const onEdit = (value) => { dispatch( @@ -32,6 +33,7 @@ const Tests = ({ item, collection }) => { collection={collection} value={tests || ''} theme={storedTheme} + font={get(preferences, 'font.codeFont', 'default')} onEdit={onEdit} mode="javascript" onRun={onRun} diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 6a7d37445f..bb29abd3ab 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -1,7 +1,8 @@ import React from 'react'; +import get from 'lodash/get'; import CodeEditor from 'components/CodeEditor'; import { useTheme } from 'providers/Theme'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import classnames from 'classnames'; import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common'; @@ -13,6 +14,7 @@ import { useMemo } from 'react'; const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers, error }) => { const { storedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); const [tab, setTab] = useState('preview'); const dispatch = useDispatch(); const contentType = getContentType(headers); @@ -111,7 +113,17 @@ const QueryResult = ({ item, collection, data, width, disableRunEventListener, h return image; } - return ; + return ( + + ); }, [tab, collection, storedTheme, onRun, value, mode]); return ( diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js index 2500474cba..b956b0813b 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js @@ -13,6 +13,10 @@ const ResponseSize = ({ size }) => { sizeToDisplay = size + 'B'; } - return {sizeToDisplay}; + return ( + + {sizeToDisplay} + + ); }; export default ResponseSize; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js index 79d636dafb..64c229ae41 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js @@ -1,10 +1,13 @@ import CodeEditor from 'components/CodeEditor/index'; +import get from 'lodash/get'; import { HTTPSnippet } from 'httpsnippet'; import { useTheme } from 'providers/Theme/index'; import { buildHarRequest } from 'utils/codegenerator/har'; +import { useSelector } from 'react-redux'; const CodeView = ({ language, item }) => { const { storedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); const { target, client, language: lang } = language; let snippet = ''; @@ -15,7 +18,15 @@ const CodeView = ({ language, item }) => { snippet = 'Error generating code snippet'; } - return ; + return ( + + ); }; export default CodeView; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js index f1c1c33e47..635c545e92 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js @@ -9,6 +9,7 @@ const StyledWrapper = styled.div` 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; + height: 100%; } .generate-code-item { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 0b33941f75..db5bfc02c3 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -88,44 +88,41 @@ const CollectionItem = ({ item, collection, searchText }) => { }); const handleClick = (event) => { - switch (event.button) { - case 0: // left click - if (isItemARequest(item)) { - dispatch(hideHomePage()); - if (itemIsOpenedInTabs(item, tabs)) { - dispatch( - focusTab({ - uid: item.uid - }) - ); - return; - } - dispatch( - addTab({ - uid: item.uid, - collectionUid: collection.uid, - requestPaneTab: getDefaultRequestPaneTab(item) - }) - ); - return; - } + if (isItemARequest(item)) { + dispatch(hideHomePage()); + if (itemIsOpenedInTabs(item, tabs)) { dispatch( - collectionFolderClicked({ - itemUid: item.uid, - collectionUid: collection.uid + focusTab({ + uid: item.uid }) ); return; - case 2: // right click - const _menuDropdown = dropdownTippyRef.current; - if (_menuDropdown) { - let menuDropdownBehavior = 'show'; - if (_menuDropdown.state.isShown) { - menuDropdownBehavior = 'hide'; - } - _menuDropdown[menuDropdownBehavior](); - } - return; + } + dispatch( + addTab({ + uid: item.uid, + collectionUid: collection.uid, + requestPaneTab: getDefaultRequestPaneTab(item) + }) + ); + return; + } + dispatch( + collectionFolderClicked({ + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const handleRightClick = (event) => { + const _menuDropdown = dropdownTippyRef.current; + if (_menuDropdown) { + let menuDropdownBehavior = 'show'; + if (_menuDropdown.state.isShown) { + menuDropdownBehavior = 'hide'; + } + _menuDropdown[menuDropdownBehavior](); } }; @@ -203,7 +200,8 @@ const CollectionItem = ({ item, collection, searchText }) => { ? indents.map((i) => { return (
{ }) : null}
{ return ( - Are you sure you want to remove this collection? + Are you sure you want to delete collection {collection.name} ? ); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 37683d6ee8..bf7d41d6db 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -66,20 +66,17 @@ const Collection = ({ collection, searchText }) => { }); const handleClick = (event) => { + dispatch(collectionClicked(collection.uid)); + }; + + const handleRightClick = (event) => { const _menuDropdown = menuDropdownTippyRef.current; - switch (event.button) { - case 0: // left click - dispatch(collectionClicked(collection.uid)); - return; - case 2: // right click - if (_menuDropdown) { - let menuDropdownBehavior = 'show'; - if (_menuDropdown.state.isShown) { - menuDropdownBehavior = 'hide'; - } - _menuDropdown[menuDropdownBehavior](); - } - return; + if (_menuDropdown) { + let menuDropdownBehavior = 'show'; + if (_menuDropdown.state.isShown) { + menuDropdownBehavior = 'hide'; + } + _menuDropdown[menuDropdownBehavior](); } }; @@ -138,7 +135,11 @@ const Collection = ({ collection, searchText }) => { setCollectionPropertiesModal(false)} /> )}
-
+
{ name="collectionName" ref={inputRef} className="block textbox mt-2 w-full" - onChange={formik.handleChange} + onChange={(e) => { + formik.handleChange(e); + if (formik.values.collectionName === formik.values.collectionFolderName) { + formik.setFieldValue('collectionFolderName', e.target.value); + } + }} autoComplete="off" autoCorrect="off" autoCapitalize="off" diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index b074a23fa1..a27ef6320c 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -105,7 +105,7 @@ const Sidebar = () => { Star
-
v0.24.0
+
v0.25.0
diff --git a/packages/bruno-app/src/components/Tooltip/index.js b/packages/bruno-app/src/components/Tooltip/index.js index b1ca2643c8..d5ab5c41dc 100644 --- a/packages/bruno-app/src/components/Tooltip/index.js +++ b/packages/bruno-app/src/components/Tooltip/index.js @@ -18,7 +18,7 @@ const Tooltip = ({ text, tooltipId }) => { - + ); }; diff --git a/packages/bruno-app/src/components/Welcome/index.js b/packages/bruno-app/src/components/Welcome/index.js index 2785165385..adfce3dd88 100644 --- a/packages/bruno-app/src/components/Welcome/index.js +++ b/packages/bruno-app/src/components/Welcome/index.js @@ -2,7 +2,7 @@ import { useState } from 'react'; import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions'; -import { IconBrandGithub, IconPlus, IconUpload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons'; +import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons'; import Bruno from 'components/Bruno'; import CreateCollection from 'components/Sidebar/CreateCollection'; @@ -69,7 +69,7 @@ const Welcome = () => { Open Collection
setImportCollectionModalOpen(true)}> - + Import Collection diff --git a/packages/bruno-app/src/pageComponents/Index/StyledWrapper.js b/packages/bruno-app/src/pages/Bruno/StyledWrapper.js similarity index 100% rename from packages/bruno-app/src/pageComponents/Index/StyledWrapper.js rename to packages/bruno-app/src/pages/Bruno/StyledWrapper.js diff --git a/packages/bruno-app/src/pageComponents/Index/index.js b/packages/bruno-app/src/pages/Bruno/index.js similarity index 97% rename from packages/bruno-app/src/pageComponents/Index/index.js rename to packages/bruno-app/src/pages/Bruno/index.js index 480ea08f33..9f4de24347 100644 --- a/packages/bruno-app/src/pageComponents/Index/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -9,7 +9,6 @@ import StyledWrapper from './StyledWrapper'; import 'codemirror/theme/material.css'; import 'codemirror/theme/monokai.css'; import 'codemirror/addon/scroll/simplescrollbars.css'; -import Documentation from 'components/Documentation'; const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; if (!SERVER_RENDERED) { diff --git a/packages/bruno-app/src/pages/_app.js b/packages/bruno-app/src/pages/_app.js index 6761290490..0b2f9c3a48 100644 --- a/packages/bruno-app/src/pages/_app.js +++ b/packages/bruno-app/src/pages/_app.js @@ -3,7 +3,6 @@ import { Provider } from 'react-redux'; import { AppProvider } from 'providers/App'; import { ToastProvider } from 'providers/Toaster'; import { HotkeysProvider } from 'providers/Hotkeys'; -import { PreferencesProvider } from 'providers/Preferences'; import ReduxStore from 'providers/ReduxStore'; import ThemeProvider from 'providers/Theme/index'; @@ -50,11 +49,9 @@ function MyApp({ Component, pageProps }) { - - - - - + + + diff --git a/packages/bruno-app/src/pages/index.js b/packages/bruno-app/src/pages/index.js index 8c1a8ae5e7..1567ef2c4f 100644 --- a/packages/bruno-app/src/pages/index.js +++ b/packages/bruno-app/src/pages/index.js @@ -1,5 +1,5 @@ import Head from 'next/head'; -import IndexPage from 'pageComponents/Index'; +import Bruno from './Bruno'; import GlobalStyle from '../globalStyles'; export default function Home() { @@ -13,7 +13,7 @@ export default function Home() {
- +
); diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js index 041bf6e9db..2fbd17e759 100644 --- a/packages/bruno-app/src/providers/App/index.js +++ b/packages/bruno-app/src/providers/App/index.js @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import useTelemetry from './useTelemetry'; -import useCollectionTreeSync from './useCollectionTreeSync'; +import useIpcEvents from './useIpcEvents'; import useCollectionNextAction from './useCollectionNextAction'; import { useDispatch } from 'react-redux'; import { refreshScreenWidth } from 'providers/ReduxStore/slices/app'; @@ -10,7 +10,7 @@ export const AppContext = React.createContext(); export const AppProvider = (props) => { useTelemetry(); - useCollectionTreeSync(); + useIpcEvents(); useCollectionNextAction(); const dispatch = useDispatch(); diff --git a/packages/bruno-app/src/providers/App/useCollectionTreeSync.js b/packages/bruno-app/src/providers/App/useIpcEvents.js similarity index 57% rename from packages/bruno-app/src/providers/App/useCollectionTreeSync.js rename to packages/bruno-app/src/providers/App/useIpcEvents.js index caf057d5b8..8e87b1cf9d 100644 --- a/packages/bruno-app/src/providers/App/useCollectionTreeSync.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -14,11 +14,12 @@ import { runFolderEvent, brunoConfigUpdateEvent } from 'providers/ReduxStore/slices/collections'; +import { updatePreferences } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; import { openCollectionEvent, collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections/actions'; import { isElectron } from 'utils/common/platform'; -const useCollectionTreeSync = () => { +const useIpcEvents = () => { const dispatch = useDispatch(); useEffect(() => { @@ -28,10 +29,6 @@ const useCollectionTreeSync = () => { const { ipcRenderer } = window; - const _openCollection = (pathname, uid, brunoConfig) => { - dispatch(openCollectionEvent(uid, pathname, brunoConfig)); - }; - const _collectionTreeUpdated = (type, val) => { if (window.__IS_DEV__) { console.log(type); @@ -82,69 +79,73 @@ const useCollectionTreeSync = () => { } }; - const _collectionAlreadyOpened = (pathname) => { + ipcRenderer.invoke('renderer:ready'); + const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated); + + const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => { + dispatch(openCollectionEvent(uid, pathname, brunoConfig)); + }); + + const removeCollectionAlreadyOpenedListener = ipcRenderer.on('main:collection-already-opened', (pathname) => { toast.success('Collection is already opened'); - }; + }); - const _displayError = (error) => { + const removeDisplayErrorListener = ipcRenderer.on('main:display-error', (error) => { if (typeof error === 'string') { return toast.error(error || 'Something went wrong!'); } if (typeof message === 'object') { return toast.error(error.message || 'Something went wrong!'); } - }; + }); - const _scriptEnvironmentUpdate = (val) => { + const removeScriptEnvUpdateListener = ipcRenderer.on('main:script-environment-update', (val) => { dispatch(scriptEnvironmentUpdateEvent(val)); - }; - - const _processEnvUpdate = (val) => { - dispatch(processEnvUpdateEvent(val)); - }; + }); - const _collectionRenamed = (val) => { + const removeCollectionRenamedListener = ipcRenderer.on('main:collection-renamed', (val) => { dispatch(collectionRenamedEvent(val)); - }; + }); - const _runFolderEvent = (val) => { + const removeRunFolderEventListener = ipcRenderer.on('main:run-folder-event', (val) => { dispatch(runFolderEvent(val)); - }; + }); - const _runRequestEvent = (val) => { + const removeRunRequestEventListener = ipcRenderer.on('main:run-request-event', (val) => { dispatch(runRequestEvent(val)); - }; + }); - ipcRenderer.invoke('renderer:ready'); + const removeProcessEnvUpdatesListener = ipcRenderer.on('main:process-env-update', (val) => { + dispatch(processEnvUpdateEvent(val)); + }); - const removeListener1 = ipcRenderer.on('main:collection-opened', _openCollection); - const removeListener2 = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated); - const removeListener3 = ipcRenderer.on('main:collection-already-opened', _collectionAlreadyOpened); - const removeListener4 = ipcRenderer.on('main:display-error', _displayError); - const removeListener5 = ipcRenderer.on('main:script-environment-update', _scriptEnvironmentUpdate); - const removeListener6 = ipcRenderer.on('main:collection-renamed', _collectionRenamed); - const removeListener7 = ipcRenderer.on('main:run-folder-event', _runFolderEvent); - const removeListener8 = ipcRenderer.on('main:run-request-event', _runRequestEvent); - const removeListener9 = ipcRenderer.on('main:process-env-update', _processEnvUpdate); - const removeListener10 = ipcRenderer.on('main:console-log', (val) => { + const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { console[val.type](...val.args); }); - const removeListener11 = ipcRenderer.on('main:bruno-config-update', (val) => dispatch(brunoConfigUpdateEvent(val))); + + const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) => + dispatch(brunoConfigUpdateEvent(val)) + ); + + const removePreferencesUpdatesListener = ipcRenderer.on('main:load-preferences', (val) => { + dispatch(updatePreferences(val)); + }); return () => { - removeListener1(); - removeListener2(); - removeListener3(); - removeListener4(); - removeListener5(); - removeListener6(); - removeListener7(); - removeListener8(); - removeListener9(); - removeListener10(); - removeListener11(); + removeCollectionTreeUpdateListener(); + removeOpenCollectionListener(); + removeCollectionAlreadyOpenedListener(); + removeDisplayErrorListener(); + removeScriptEnvUpdateListener(); + removeCollectionRenamedListener(); + removeRunFolderEventListener(); + removeRunRequestEventListener(); + removeProcessEnvUpdatesListener(); + removeConsoleLogListener(); + removeConfigUpdatesListener(); + removePreferencesUpdatesListener(); }; }, [isElectron]); }; -export default useCollectionTreeSync; +export default useIpcEvents; diff --git a/packages/bruno-app/src/providers/Preferences/index.js b/packages/bruno-app/src/providers/Preferences/index.js deleted file mode 100644 index 9b03450046..0000000000 --- a/packages/bruno-app/src/providers/Preferences/index.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Preferences Provider - * - * This provider is responsible for managing the user's preferences in the app. - * The preferences are stored in the browser local storage. - * - * On start, an IPC event is published to the main process to set the preferences in the electron process. - */ - -import { useEffect, createContext, useContext } from 'react'; -import * as Yup from 'yup'; -import useLocalStorage from 'hooks/useLocalStorage/index'; -import toast from 'react-hot-toast'; - -const defaultPreferences = { - request: { - sslVerification: true - } -}; - -const preferencesSchema = Yup.object().shape({ - request: Yup.object().shape({ - sslVerification: Yup.boolean() - }) -}); - -export const PreferencesContext = createContext(); -export const PreferencesProvider = (props) => { - const [preferences, setPreferences] = useLocalStorage('bruno.preferences', defaultPreferences); - const { ipcRenderer } = window; - - useEffect(() => { - ipcRenderer.invoke('renderer:set-preferences', preferences).catch((err) => { - toast.error(err.message || 'Preferences sync error'); - }); - }, [preferences, toast]); - - const validatedSetPreferences = (newPreferences) => { - return new Promise((resolve, reject) => { - preferencesSchema - .validate(newPreferences, { abortEarly: true }) - .then((validatedPreferences) => { - setPreferences(validatedPreferences); - resolve(validatedPreferences); - }) - .catch((error) => { - let errMsg = error.message || 'Preferences validation error'; - toast.error(errMsg); - reject(error); - }); - }); - }; - - // todo: setPreferences must validate the preferences object against a schema - const value = { - preferences, - setPreferences: validatedSetPreferences - }; - - return ( - - <>{props.children} - - ); -}; - -export const usePreferences = () => { - const context = useContext(PreferencesContext); - - if (context === undefined) { - throw new Error(`usePreferences must be used within a PreferencesProvider`); - } - - return context; -}; - -export default PreferencesProvider; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index f1e9b91177..6f5c849f94 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -1,11 +1,21 @@ import { createSlice } from '@reduxjs/toolkit'; +import toast from 'react-hot-toast'; const initialState = { isDragging: false, idbConnectionReady: false, leftSidebarWidth: 222, screenWidth: 500, - showHomePage: false + showHomePage: false, + preferences: { + request: { + sslVerification: true, + timeout: 0 + }, + font: { + codeFont: 'default' + } + } }; export const appSlice = createSlice({ @@ -29,6 +39,9 @@ export const appSlice = createSlice({ }, hideHomePage: (state) => { state.showHomePage = false; + }, + updatePreferences: (state, action) => { + state.preferences = action.payload; } } }); @@ -39,7 +52,25 @@ export const { updateLeftSidebarWidth, updateIsDragging, showHomePage, - hideHomePage + hideHomePage, + updatePreferences } = appSlice.actions; +export const savePreferences = (preferences) => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + + ipcRenderer + .invoke('renderer:save-preferences', preferences) + .then(() => toast.success('Preferences saved successfully')) + .then(() => dispatch(updatePreferences(preferences))) + .then(resolve) + .catch((err) => { + toast.error('An error occurred while saving preferences'); + console.error(err); + reject(err); + }); + }); +}; + export default appSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 95eea255f5..52203a0e9e 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -18,9 +18,9 @@ import { isItemAFolder, refreshUidsInItem } from 'utils/collections'; -import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema'; +import { collectionSchema, itemSchema, environmentSchema } from '@usebruno/schema'; import { waitForNextTick } from 'utils/common'; -import { getDirectoryName } from 'utils/common/platform'; +import { getDirectoryName, PATH_SEPARATOR } from 'utils/common/platform'; import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network'; import { @@ -30,7 +30,6 @@ import { requestCancelled, responseReceived, newItem as _newItem, - renameItem as _renameItem, cloneItem as _cloneItem, deleteItem as _deleteItem, saveRequest as _saveRequest, @@ -44,13 +43,9 @@ import { import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; -import { sanitizeFilenme } from 'utils/common/index'; -import os from 'os'; import { parseQueryParams, splitOnFirst } from 'utils/url/index'; import { each } from 'lodash'; -const PATH_SEPARATOR = /Windows/i.test(os.release()) ? '\\' : '/'; - export const renameCollection = (newName, collectionUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -97,7 +92,6 @@ export const saveRequest = (itemUid, collectionUid) => (dispatch, getState) => { export const saveCollectionRoot = (collectionUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); - console.log(collection.root); return new Promise((resolve, reject) => { if (!collection) { @@ -187,11 +181,6 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) => .catch((err) => console.log(err)); }; -// todo: this can be directly put inside the collections/index.js file -// the coding convention is to put only actions that need ipc in this file -export const sortCollections = (order) => (dispatch) => { - dispatch(_sortCollections(order)); -}; export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -300,13 +289,7 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta const dirname = getDirectoryName(item.pathname); const { ipcRenderer } = window; - ipcRenderer - .invoke('renderer:rename-item', item.pathname, dirname, newName) - .then(() => { - dispatch(_renameItem({ newName, itemUid, collectionUid })); - resolve(); - }) - .catch(reject); + ipcRenderer.invoke('renderer:rename-item', item.pathname, dirname, newName).then(resolve).catch(reject); }); }; @@ -386,18 +369,15 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => { if (item) { const { ipcRenderer } = window; - ipcRenderer - .invoke('renderer:delete-item', item.pathname, item.type) - .then(() => { - dispatch(_deleteItem({ itemUid, collectionUid })); - resolve(); - }) - .catch((error) => reject(error)); + ipcRenderer.invoke('renderer:delete-item', item.pathname, item.type).then(resolve).catch(reject); } return; }); }; +export const sortCollections = () => (dispatch) => { + dispatch(_sortCollections()); +}; export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -702,6 +682,32 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => { }); }; +export const importEnvironment = (name, variables, 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')); + } + + ipcRenderer + .invoke('renderer:create-environment', collection.pathname, name, variables) + .then( + dispatch( + updateLastAction({ + collectionUid, + lastAction: { + type: 'ADD_ENVIRONMENT', + payload: name + } + }) + ) + ) + .then(resolve) + .catch(reject); + }); +}; + export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, getState) => { return new Promise((resolve, reject) => { const state = getState(); @@ -716,7 +722,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g } ipcRenderer - .invoke('renderer:copy-environment', collection.pathname, name, baseEnv.variables) + .invoke('renderer:create-environment', collection.pathname, name, baseEnv.variables) .then( dispatch( updateLastAction({ diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index fd675738c3..246d5c2dbc 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -1,4 +1,3 @@ -import path from 'path'; import { uuid } from 'utils/common'; import find from 'lodash/find'; import map from 'lodash/map'; @@ -25,9 +24,7 @@ import { areItemsTheSameExceptSeqUpdate } from 'utils/collections'; import { parseQueryParams, stringifyQueryParams } from 'utils/url'; -import { getSubdirectoriesFromRoot, getDirectoryName } from 'utils/common/platform'; - -const PATH_SEPARATOR = path.sep; +import { getSubdirectoriesFromRoot, getDirectoryName, PATH_SEPARATOR } from 'utils/common/platform'; const initialState = { collections: [], @@ -1060,7 +1057,6 @@ export const collectionsSlice = createSlice({ if (collection) { collection.root = file.data; } - console.log('collectionAddFileEvent', file); return; } diff --git a/packages/bruno-app/src/utils/common/platform.js b/packages/bruno-app/src/utils/common/platform.js index ceb84ad4b7..03ff9539e9 100644 --- a/packages/bruno-app/src/utils/common/platform.js +++ b/packages/bruno-app/src/utils/common/platform.js @@ -1,6 +1,7 @@ import trim from 'lodash/trim'; import path from 'path'; import slash from './slash'; +import platform from 'platform'; export const isElectron = () => { if (!window) { @@ -47,3 +48,5 @@ export const isMacOS = () => { return osFamily.includes('os x'); }; + +export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/'; diff --git a/packages/bruno-app/src/utils/common/regex.js b/packages/bruno-app/src/utils/common/regex.js index 6c348a8907..c939218360 100644 --- a/packages/bruno-app/src/utils/common/regex.js +++ b/packages/bruno-app/src/utils/common/regex.js @@ -1,3 +1,3 @@ // See https://github.com/usebruno/bruno/pull/349 for more info // Scrict regex for validating directories. Covers most edge cases like windows device names -export const dirnameRegex = /^(?!CON|PRN|AUX|NUL|COM\d|LPT\d|^ |^\-)[\w\-\. \(\)\[\]\!]+[^\. ]$/; +export const dirnameRegex = /^(?!CON|PRN|AUX|NUL|COM\d|LPT\d|^ |^\-)[^<>:"/\\|?*\x00-\x1F]+[^\. ]$/; diff --git a/packages/bruno-app/src/utils/importers/postman-environment.js b/packages/bruno-app/src/utils/importers/postman-environment.js new file mode 100644 index 0000000000..61c62311c7 --- /dev/null +++ b/packages/bruno-app/src/utils/importers/postman-environment.js @@ -0,0 +1,71 @@ +import each from 'lodash/each'; +import fileDialog from 'file-dialog'; +import { BrunoError } from 'utils/common/error'; + +const readFile = (files) => { + return new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = (e) => resolve(e.target.result); + fileReader.onerror = (err) => reject(err); + fileReader.readAsText(files[0]); + }); +}; + +const isSecret = (type) => { + return type === 'secret'; +}; + +const importPostmanEnvironmentVariables = (brunoEnvironment, values) => { + brunoEnvironment.variables = brunoEnvironment.variables || []; + + each(values, (i) => { + const brunoEnvironmentVariable = { + name: i.key, + value: i.value, + enabled: i.enabled, + secret: isSecret(i.type) + }; + + brunoEnvironment.variables.push(brunoEnvironmentVariable); + }); +}; + +const importPostmanEnvironment = (environment) => { + const brunoEnvironment = { + name: environment.name, + variables: [] + }; + + importPostmanEnvironmentVariables(brunoEnvironment, environment.values); + return brunoEnvironment; +}; + +const parsePostmanEnvironment = (str) => { + return new Promise((resolve, reject) => { + try { + let environment = JSON.parse(str); + return resolve(importPostmanEnvironment(environment)); + } catch (err) { + console.log(err); + if (err instanceof BrunoError) { + return reject(err); + } + return reject(new BrunoError('Unable to parse the postman environment json file')); + } + }); +}; + +const importEnvironment = () => { + return new Promise((resolve, reject) => { + fileDialog({ accept: 'application/json' }) + .then(readFile) + .then(parsePostmanEnvironment) + .then((environment) => resolve(environment)) + .catch((err) => { + console.log(err); + reject(new BrunoError('Import Environment failed')); + }); + }); +}; + +export default importEnvironment; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index 8952e1986a..c54c3338ed 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -45,6 +45,8 @@ export const fetchGqlSchema = async (endpoint, environment, request, collection) export const cancelNetworkRequest = async (cancelTokenUid) => { return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('cancel-http-request', cancelTokenUid).then(resolve).catch(reject); }); }; diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 78b7226ca4..4ddad0691e 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -162,9 +162,7 @@ const getCollectionRoot = (dir) => { } const content = fs.readFileSync(collectionRootPath, 'utf8'); - const json = collectionBruToJson(content); - - return json; + return collectionBruToJson(content); }; const builder = async (yargs) => { diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 5568ae311d..ace3b3101a 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -88,6 +88,13 @@ const prepareRequest = (request, collectionRoot) => { axiosRequest.data = request.body.xml; } + if (request.body.mode === 'sparql') { + if (!contentTypeDefined) { + axiosRequest.headers['content-type'] = 'application/sparql-query'; + } + axiosRequest.data = request.body.sparql; + } + if (request.body.mode === 'formUrlEncoded') { axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; const params = {}; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index ee67f60b31..bd440d62e6 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -16,6 +16,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { makeAxiosInstance } = require('../utils/axios-instance'); +const { shouldUseProxy } = require('../utils/proxy-util'); const runSingleRequest = async function ( filename, @@ -47,7 +48,7 @@ const runSingleRequest = async function ( // run pre-request vars const preRequestVars = get(bruJson, 'request.vars.req'); - if (preRequestVars && preRequestVars.length) { + if (preRequestVars?.length) { const varsRuntime = new VarsRuntime(); varsRuntime.runPreRequestVars( preRequestVars, @@ -64,7 +65,7 @@ const runSingleRequest = async function ( get(collectionRoot, 'request.script.req'), get(bruJson, 'request.script.req') ]).join(os.EOL); - if (requestScriptFile && requestScriptFile.length) { + if (requestScriptFile?.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runRequestScript( decomment(requestScriptFile), @@ -87,36 +88,56 @@ const runSingleRequest = async function ( if (insecure) { httpsAgentRequestFields['rejectUnauthorized'] = false; } else { - const cacertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS]; - const cacert = cacertArray.find((el) => el); - if (cacert && cacert.length > 1) { + const caCertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS]; + const caCert = caCertArray.find((el) => el); + if (caCert && caCert.length > 1) { try { - caCrt = fs.readFileSync(cacert); - httpsAgentRequestFields['ca'] = caCrt; + httpsAgentRequestFields['ca'] = fs.readFileSync(caCert); } catch (err) { - console.log('Error reading CA cert file:' + cacert, err); + console.log('Error reading CA cert file:' + caCert, err); + } + } + } + + const interpolationOptions = { + envVars: envVariables, + collectionVariables, + processEnvVars + }; + + // client certificate config + const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); + for (let clientCert of clientCertConfig) { + const domain = interpolateString(clientCert.domain, interpolationOptions); + const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); + const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); + if (domain && certFilePath && keyFilePath) { + const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + + if (request.url.match(hostRegex)) { + try { + httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); + httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); + } catch (err) { + console.log('Error reading cert/key file', err); + } + httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions); + break; } } } // set proxy if enabled const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - let proxyUri; - const interpolationOptions = { - envVars: envVariables, - collectionVariables, - processEnvVars - }; - + const shouldProxy = shouldUseProxy(request.url, get(brunoConfig, 'proxy.bypassProxy', '')); + if (proxyEnabled && shouldProxy) { const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); const socksEnabled = proxyProtocol.includes('socks'); - interpolateString; - + let proxyUri; if (proxyAuthEnabled) { const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); @@ -128,16 +149,13 @@ const runSingleRequest = async function ( if (socksEnabled) { const socksProxyAgent = new SocksProxyAgent(proxyUri); - request.httpsAgent = socksProxyAgent; - request.httpAgent = socksProxyAgent; } else { request.httpsAgent = new HttpsProxyAgent( proxyUri, Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined ); - request.httpAgent = new HttpProxyAgent(proxyUri); } } else if (Object.keys(httpsAgentRequestFields).length > 0) { @@ -163,7 +181,7 @@ const runSingleRequest = async function ( responseTime = response.headers.get('request-duration'); response.headers.delete('request-duration'); } catch (err) { - if (err && err.response) { + if (err?.response) { response = err.response; // Prevents the duration on leaking to the actual result @@ -199,7 +217,7 @@ const runSingleRequest = async function ( // run post-response vars const postResponseVars = get(bruJson, 'request.vars.res'); - if (postResponseVars && postResponseVars.length) { + if (postResponseVars?.length) { const varsRuntime = new VarsRuntime(); varsRuntime.runPostResponseVars( postResponseVars, @@ -217,7 +235,7 @@ const runSingleRequest = async function ( get(collectionRoot, 'request.script.res'), get(bruJson, 'request.script.res') ]).join(os.EOL); - if (responseScriptFile && responseScriptFile.length) { + if (responseScriptFile?.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runResponseScript( decomment(responseScriptFile), @@ -275,7 +293,7 @@ const runSingleRequest = async function ( testResults = get(result, 'results', []); } - if (testResults && testResults.length) { + if (testResults?.length) { each(testResults, (testResult) => { if (testResult.status === 'pass') { console.log(chalk.green(` ✓ `) + chalk.dim(testResult.description)); diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js index 286ffc0f54..2251564840 100644 --- a/packages/bruno-cli/src/utils/axios-instance.js +++ b/packages/bruno-cli/src/utils/axios-instance.js @@ -4,10 +4,10 @@ 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} + * @returns {axios.AxiosInstance} */ function makeAxiosInstance() { - /** @type {import('axios').AxiosStatic} */ + /** @type {axios.AxiosInstance} */ const instance = axios.create(); instance.interceptors.request.use((config) => { @@ -26,9 +26,7 @@ function makeAxiosInstance() { if (error.response) { const end = Date.now(); const start = error.config.headers['request-start-time']; - if (error.response) { - error.response.headers['request-duration'] = end - start; - } + error.response.headers['request-duration'] = end - start; } return Promise.reject(error); } diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js new file mode 100644 index 0000000000..dd526d0d79 --- /dev/null +++ b/packages/bruno-cli/src/utils/proxy-util.js @@ -0,0 +1,66 @@ +const parseUrl = require('url').parse; + +const DEFAULT_PORTS = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443 +}; +/** + * check for proxy bypass, Copied form 'proxy-from-env' + */ +const shouldUseProxy = (url, proxyBypass) => { + if (proxyBypass === '*') { + return false; // Never proxy if wildcard is set. + } + + // use proxy if no proxyBypass is set + if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) { + return true; + } + + const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {}; + let proto = parsedUrl.protocol; + let hostname = parsedUrl.host; + let port = parsedUrl.port; + if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') { + return false; // Don't proxy URLs without a valid scheme or host. + } + + proto = proto.split(':', 1)[0]; + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, ''); + port = parseInt(port) || DEFAULT_PORTS[proto] || 0; + + return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) { + if (!dontProxyFor) { + return true; // Skip zero-length hosts. + } + + const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/); + let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor; + const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0; + if (parsedProxyPort && parsedProxyPort !== port) { + return true; // Skip if ports don't match. + } + + if (!/^[.*]/.test(parsedProxyHostname)) { + // No wildcards, so stop proxying if there is an exact match. + return hostname !== parsedProxyHostname; + } + + if (parsedProxyHostname.charAt(0) === '*') { + // Remove leading wildcard. + parsedProxyHostname = parsedProxyHostname.slice(1); + } + // Stop proxying if the hostname ends with the no_proxy host. + return !hostname.endsWith(parsedProxyHostname); + }); +}; + +module.exports = { + shouldUseProxy +}; diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index ffe266f3e4..70a74326be 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -1,5 +1,5 @@ { - "version": "v0.24.0", + "version": "v0.25.0", "name": "bruno", "description": "Opensource API Client for Exploring and Testing APIs", "homepage": "https://www.usebruno.com", diff --git a/packages/bruno-electron/src/app/menu-template.js b/packages/bruno-electron/src/app/menu-template.js index 6b47077292..cc0f1267e3 100644 --- a/packages/bruno-electron/src/app/menu-template.js +++ b/packages/bruno-electron/src/app/menu-template.js @@ -44,7 +44,7 @@ const template = [ }, { role: 'window', - submenu: [{ role: 'minimize' }, { role: 'close' }] + submenu: [{ role: 'minimize' }, { role: 'close', accelerator: 'CommandOrControl+Shift+Q' }] }, { role: 'help', diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 1acaa7adc1..f7cb3acc01 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -8,8 +8,9 @@ const menuTemplate = require('./app/menu-template'); const LastOpenedCollections = require('./store/last-opened-collections'); const registerNetworkIpc = require('./ipc/network'); const registerCollectionsIpc = require('./ipc/collection'); +const registerPreferencesIpc = require('./ipc/preferences'); const Watcher = require('./app/watcher'); -const { loadWindowState, saveWindowState } = require('./utils/window'); +const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window'); const lastOpenedCollections = new LastOpenedCollections(); @@ -32,15 +33,15 @@ let watcher; // Prepare the renderer once the app is ready app.on('ready', async () => { - const { x, y, width, height } = loadWindowState(); + const { maximized, x, y, width, height } = loadWindowState(); mainWindow = new BrowserWindow({ x, y, width, height, - minWidth:1000, - minHeight:640, + minWidth: 1000, + minHeight: 640, webPreferences: { nodeIntegration: true, contextIsolation: true, @@ -54,6 +55,10 @@ app.on('ready', async () => { // autoHideMenuBar: true }); + if (maximized) { + mainWindow.maximize(); + } + const url = isDev ? 'http://localhost:3000' : format({ @@ -65,8 +70,17 @@ app.on('ready', async () => { mainWindow.loadURL(url); watcher = new Watcher(); - mainWindow.on('resize', () => saveWindowState(mainWindow)); - mainWindow.on('move', () => saveWindowState(mainWindow)); + const handleBoundsChange = () => { + if (!mainWindow.isMaximized()) { + saveBounds(mainWindow); + } + }; + + mainWindow.on('resize', handleBoundsChange); + mainWindow.on('move', handleBoundsChange); + + mainWindow.on('maximize', () => saveMaximized(true)); + mainWindow.on('unmaximize', () => saveMaximized(false)); mainWindow.webContents.on('new-window', function (e, url) { e.preventDefault(); @@ -74,8 +88,9 @@ app.on('ready', async () => { }); // register all ipc handlers - registerNetworkIpc(mainWindow, watcher, lastOpenedCollections); + registerNetworkIpc(mainWindow); registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections); + registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections); }); // Quit the app once all windows are closed diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 1bb4bf0607..a104e34c13 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -15,10 +15,9 @@ const { sanitizeFilenme } = require('../utils/filesystem'); const { stringifyJson } = require('../utils/common'); -const { openCollectionDialog, openCollection } = require('../app/collections'); +const { openCollectionDialog } = require('../app/collections'); const { generateUidBasedOnHash } = require('../utils/common'); const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids'); -const { setPreferences } = require('../store/preferences'); const EnvironmentSecretsStore = require('../store/env-secrets'); const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -33,9 +32,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // browse directory ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => { try { - const dirPath = await browseDirectory(mainWindow); - - return dirPath; + return await browseDirectory(mainWindow); } catch (error) { return Promise.reject(error); } @@ -64,8 +61,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid); - - return; } catch (error) { return Promise.reject(error); } @@ -90,8 +85,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection collectionPathname, newName }); - - return; } catch (error) { return Promise.reject(error); } @@ -141,47 +134,30 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection }); // create environment - ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name) => { + ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables) => { try { const envDirPath = path.join(collectionPathname, 'environments'); if (!fs.existsSync(envDirPath)) { await createDirectory(envDirPath); } - const filenameSanatized = `${sanitizeFilenme(name)}.bru`; + const filenameSanatized = sanitizeFilenme(`${name}.bru`); const envFilePath = path.join(envDirPath, filenameSanatized); if (fs.existsSync(envFilePath)) { throw new Error(`environment: ${envFilePath} already exists`); } - const content = envJsonToBru({ - variables: [], - name: name - }); - await writeFile(envFilePath, content); - } catch (error) { - return Promise.reject(error); - } - }); + const environment = { + name: name, + variables: variables || [] + }; - // 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); + if (envHasSecrets(environment)) { + environmentSecretsStore.storeEnvSecrets(collectionPathname, environment); } - const filenameSanatized = sanitizeFilenme(`${name}.bru`); - const envFilePath = path.join(envDirPath, filenameSanatized); - if (fs.existsSync(envFilePath)) { - throw new Error(`environment: ${envFilePath} already exists`); - } + const content = envJsonToBru(environment); - const content = envJsonToBru({ - variables: baseVariables, - name: name - }); await writeFile(envFilePath, content); } catch (error) { return Promise.reject(error); @@ -359,7 +335,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection fs.unlinkSync(pathname); } else { - return Promise.reject(error); + return Promise.reject(); } } catch (error) { return Promise.reject(error); @@ -502,25 +478,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); - ipcMain.handle('renderer:ready', async (event) => { - // reload last opened collections - const lastOpened = lastOpenedCollections.getAll(); - - if (lastOpened && lastOpened.length) { - for (let collectionPath of lastOpened) { - if (isDirectory(collectionPath)) { - openCollection(mainWindow, watcher, collectionPath, { - dontSendDisplayErrors: true - }); - } - } - } - }); - - ipcMain.handle('renderer:set-preferences', async (event, preferences) => { - setPreferences(preferences); - }); - ipcMain.handle('renderer:update-bruno-config', async (event, brunoConfig, collectionPath, collectionUid) => { try { const brunoConfigPath = path.join(collectionPath, 'bruno.json'); diff --git a/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js b/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js index e3c9906248..4a2ff5aa24 100644 --- a/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js +++ b/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js @@ -5,7 +5,7 @@ function isStrPresent(str) { return str && str !== '' && str !== 'undefined'; } -async function resolveCredentials(request) { +async function resolveAwsV4Credentials(request) { const awsv4 = request.awsv4config; if (isStrPresent(awsv4.profileName)) { try { @@ -52,5 +52,5 @@ function addAwsV4Interceptor(axiosInstance, request) { module.exports = { addAwsV4Interceptor, - resolveCredentials + resolveAwsV4Credentials }; diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js index f4810becd3..2251564840 100644 --- a/packages/bruno-electron/src/ipc/network/axios-instance.js +++ b/packages/bruno-electron/src/ipc/network/axios-instance.js @@ -4,10 +4,10 @@ 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} + * @returns {axios.AxiosInstance} */ function makeAxiosInstance() { - /** @type {import('axios').AxiosStatic} */ + /** @type {axios.AxiosInstance} */ const instance = axios.create(); instance.interceptors.request.use((config) => { diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 8e8cb62472..9c9ada3f2b 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -1,4 +1,5 @@ const os = require('os'); +const fs = require('fs'); const qs = require('qs'); const https = require('https'); const axios = require('axios'); @@ -15,14 +16,15 @@ const { uuid } = require('../../utils/common'); const interpolateVars = require('./interpolate-vars'); const { interpolateString } = require('./interpolate-string'); const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper'); -const { getPreferences } = require('../../store/preferences'); +const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const { makeAxiosInstance } = require('./axios-instance'); -const { addAwsV4Interceptor, resolveCredentials } = require('./awsv4auth-helper'); +const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper'); +const { shouldUseProxy } = require('../../utils/proxy-util'); // override the default escape function to prevent escaping Mustache.escape = function (value) { @@ -82,6 +84,96 @@ const getSize = (data) => { return 0; }; +const configureRequest = async (collectionUid, request, envVars, collectionVariables, processEnvVars) => { + const httpsAgentRequestFields = {}; + if (!preferencesUtil.shouldVerifyTls()) { + httpsAgentRequestFields['rejectUnauthorized'] = false; + } + + const brunoConfig = getBrunoConfig(collectionUid); + const interpolationOptions = { + envVars, + collectionVariables, + processEnvVars + }; + + // client certificate config + const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); + for (let clientCert of clientCertConfig) { + const domain = interpolateString(clientCert.domain, interpolationOptions); + const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions); + const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions); + if (domain && certFilePath && keyFilePath) { + const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + + if (request.url.match(hostRegex)) { + try { + httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath); + httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath); + } catch (err) { + console.log('Error reading cert/key file', err); + } + httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions); + break; + } + } + } + + // proxy configuration + let proxyConfig = get(brunoConfig, 'proxy', {}); + let proxyEnabled = get(proxyConfig, 'enabled', 'disabled'); + if (proxyEnabled === 'global') { + proxyConfig = preferencesUtil.getGlobalProxyConfig(); + proxyEnabled = get(proxyConfig, 'enabled', false); + } + const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', '')); + if (proxyEnabled === true && shouldProxy) { + const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); + const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); + const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); + const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const socksEnabled = proxyProtocol.includes('socks'); + + let proxyUri; + if (proxyAuthEnabled) { + const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions); + const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions); + + proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; + } else { + proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; + } + + if (socksEnabled) { + const socksProxyAgent = new SocksProxyAgent(proxyUri); + request.httpsAgent = socksProxyAgent; + request.httpAgent = socksProxyAgent; + } else { + request.httpsAgent = new HttpsProxyAgent( + proxyUri, + Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined + ); + request.httpAgent = new HttpProxyAgent(proxyUri); + } + } else if (Object.keys(httpsAgentRequestFields).length > 0) { + request.httpsAgent = new https.Agent({ + ...httpsAgentRequestFields + }); + } + + const axiosInstance = makeAxiosInstance(); + + if (request.awsv4config) { + request.awsv4config = await resolveAwsV4Credentials(request); + addAwsV4Interceptor(axiosInstance, request); + delete request.awsv4config; + } + + request.timeout = preferencesUtil.getRequestTimeout(); + + return axiosInstance; +}; + const registerNetworkIpc = (mainWindow) => { // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => { @@ -133,7 +225,7 @@ const registerNetworkIpc = (mainWindow) => { // run pre-request vars const preRequestVars = get(request, 'vars.req', []); - if (preRequestVars && preRequestVars.length) { + if (preRequestVars?.length) { const varsRuntime = new VarsRuntime(); const result = varsRuntime.runPreRequestVars( preRequestVars, @@ -158,7 +250,7 @@ const registerNetworkIpc = (mainWindow) => { const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join( os.EOL ); - if (requestScript && requestScript.length) { + if (requestScript?.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( decomment(requestScript), @@ -204,86 +296,49 @@ const registerNetworkIpc = (mainWindow) => { cancelTokenUid }); - const preferences = getPreferences(); - const sslVerification = get(preferences, 'request.sslVerification', true); - const httpsAgentRequestFields = {}; - if (!sslVerification) { - httpsAgentRequestFields['rejectUnauthorized'] = false; - } else { - const cacertArray = [preferences['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS]; - cacertFile = cacertArray.find((el) => el); - if (cacertFile && cacertFile.length > 1) { - try { - const fs = require('fs'); - caCrt = fs.readFileSync(cacertFile); - httpsAgentRequestFields['ca'] = caCrt; - } catch (err) { - console.log('Error reading CA cert file:' + cacertFile, err); - } - } - } - - // proxy configuration - const brunoConfig = getBrunoConfig(collectionUid); - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - let proxyUri; - - const interpolationOptions = { - envVars, - collectionVariables, - processEnvVars - }; + const axiosInstance = await configureRequest( + collectionUid, + request, + envVars, + collectionVariables, + processEnvVars + ); - const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); - const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); - const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - const socksEnabled = proxyProtocol.includes('socks'); + let response, responseTime; + try { + /** @type {import('axios').AxiosResponse} */ + response = await axiosInstance(request); - if (proxyAuthEnabled) { - const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); - const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); + // Prevents the duration on leaking to the actual result + responseTime = response.headers.get('request-duration'); + response.headers.delete('request-duration'); + } catch (error) { + deleteCancelToken(cancelTokenUid); - proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; - } else { - proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; + // if it's a cancel request, don't continue + if (axios.isCancel(error)) { + let error = new Error('Request cancelled'); + error.isCancel = true; + return Promise.reject(error); } - if (socksEnabled) { - const socksProxyAgent = new SocksProxyAgent(proxyUri); - - request.httpsAgent = socksProxyAgent; + if (error?.response) { + response = error.response; - request.httpAgent = socksProxyAgent; + // Prevents the duration on leaking to the actual result + responseTime = response.headers.get('request-duration'); + response.headers.delete('request-duration'); } else { - request.httpsAgent = new HttpsProxyAgent( - proxyUri, - Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined - ); - - request.httpAgent = new HttpProxyAgent(proxyUri); + // if it's not a network error, don't continue + return Promise.reject(error); } - } else if (Object.keys(httpsAgentRequestFields).length > 0) { - request.httpsAgent = new https.Agent({ - ...httpsAgentRequestFields - }); - } - - const axiosInstance = makeAxiosInstance(); - - if (request.awsv4config) { - request.awsv4config = await resolveCredentials(request); - addAwsV4Interceptor(axiosInstance, request); - delete request.awsv4config; } - /** @type {import('axios').AxiosResponse} */ - const response = await axiosInstance(request); + // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests // run post-response vars const postResponseVars = get(request, 'vars.res', []); - if (postResponseVars && postResponseVars.length) { + if (postResponseVars?.length) { const varsRuntime = new VarsRuntime(); const result = varsRuntime.runPostResponseVars( postResponseVars, @@ -309,7 +364,7 @@ const registerNetworkIpc = (mainWindow) => { const responseScript = compact([get(collectionRoot, 'request.script.res'), get(request, 'script.res')]).join( os.EOL ); - if (responseScript && responseScript.length) { + if (responseScript?.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runResponseScript( decomment(responseScript), @@ -388,100 +443,16 @@ const registerNetworkIpc = (mainWindow) => { }); } - deleteCancelToken(cancelTokenUid); - // Prevents the duration on leaking to the actual result - const requestDuration = response.headers.get('request-duration'); - response.headers.delete('request-duration'); - return { status: response.status, statusText: response.statusText, headers: response.headers, data: response.data, - duration: requestDuration + duration: responseTime ?? 0 }; } catch (error) { - // todo: better error handling - // need to convey the error to the UI - // and need not be always a network error deleteCancelToken(cancelTokenUid); - if (axios.isCancel(error)) { - let error = new Error('Request cancelled'); - error.isCancel = true; - return Promise.reject(error); - } - - if (error && error.response) { - // run assertions - const assertions = get(request, 'assertions'); - if (assertions) { - const assertRuntime = new AssertRuntime(); - const results = assertRuntime.runAssertions( - assertions, - request, - error.response, - envVars, - collectionVariables, - collectionPath - ); - - mainWindow.webContents.send('main:run-request-event', { - type: 'assertion-results', - results: results, - itemUid: item.uid, - requestUid, - collectionUid - }); - } - - // run tests - const testFile = compact([ - get(collectionRoot, 'request.tests'), - item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests') - ]).join(os.EOL); - if (typeof testFile === 'string') { - const testRuntime = new TestRuntime(); - const testResults = await testRuntime.runTests( - decomment(testFile), - request, - error.response, - envVars, - collectionVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig - ); - - mainWindow.webContents.send('main:run-request-event', { - type: 'test-results', - results: testResults.results, - itemUid: item.uid, - requestUid, - collectionUid - }); - - mainWindow.webContents.send('main:script-environment-update', { - envVariables: testResults.envVariables, - collectionVariables: testResults.collectionVariables, - requestUid, - collectionUid - }); - } - - // Prevents the duration from leaking to the actual result - const requestDuration = error.response.headers.get('request-duration'); - error.response.headers.delete('request-duration'); - return { - status: error.response.status, - statusText: error.response.statusText, - headers: error.response.headers, - data: error.response.data, - duration: requestDuration ?? 0 - }; - } - return Promise.reject(error); } }); @@ -504,10 +475,9 @@ const registerNetworkIpc = (mainWindow) => { const collectionRoot = get(collection, 'root', {}); const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request, collectionRoot); - const preferences = getPreferences(); - const sslVerification = get(preferences, 'request.sslVerification', true); + request.timeout = preferencesUtil.getRequestTimeout(); - if (!sslVerification) { + if (!preferencesUtil.shouldVerifyTls()) { request.httpsAgent = new https.Agent({ rejectUnauthorized: false }); @@ -516,7 +486,14 @@ const registerNetworkIpc = (mainWindow) => { const processEnvVars = getProcessEnvVars(collection.uid); interpolateVars(preparedRequest, envVars, collection.collectionVariables, processEnvVars); - const response = await axios(preparedRequest); + const axiosInstance = await configureRequest( + collection.uid, + preparedRequest, + envVars, + collection.collectionVariables, + processEnvVars + ); + const response = await axiosInstance(preparedRequest); return { status: response.status, @@ -645,7 +622,7 @@ const registerNetworkIpc = (mainWindow) => { const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join( os.EOL ); - if (requestScript && requestScript.length) { + if (requestScript?.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( decomment(requestScript), @@ -682,68 +659,61 @@ const registerNetworkIpc = (mainWindow) => { ...eventData }); - const preferences = getPreferences(); - const sslVerification = get(preferences, 'request.sslVerification', true); - - // proxy configuration - const brunoConfig = getBrunoConfig(collectionUid); - const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); - if (proxyEnabled) { - let proxyUri; - const interpolationOptions = { - envVars, - collectionVariables, - processEnvVars - }; - - const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); - const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); - const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); - const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); - const socksEnabled = proxyProtocol.includes('socks'); - - if (proxyAuthEnabled) { - const proxyAuthUsername = interpolateString( - get(brunoConfig, 'proxy.auth.username'), - interpolationOptions - ); - - const proxyAuthPassword = interpolateString( - get(brunoConfig, 'proxy.auth.password'), - interpolationOptions - ); - - proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`; - } else { - proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`; - } + const axiosInstance = await configureRequest( + collectionUid, + request, + envVars, + collectionVariables, + processEnvVars + ); - if (socksEnabled) { - const socksProxyAgent = new SocksProxyAgent(proxyUri); + timeStart = Date.now(); + let response; + try { + /** @type {import('axios').AxiosResponse} */ + response = await axiosInstance(request); + timeEnd = Date.now(); - request.httpsAgent = socksProxyAgent; - request.httpAgent = socksProxyAgent; - } else { - request.httpsAgent = new HttpsProxyAgent(proxyUri, { - rejectUnauthorized: sslVerification + mainWindow.webContents.send('main:run-folder-event', { + type: 'response-received', + responseReceived: { + status: response.status, + statusText: response.statusText, + headers: Object.entries(response.headers), + duration: timeEnd - timeStart, + size: response.headers['content-length'] || getSize(response.data), + data: response.data + }, + ...eventData + }); + } catch (error) { + if (error?.response) { + timeEnd = Date.now(); + response = { + status: error.response.status, + statusText: error.response.statusText, + headers: Object.entries(error.response.headers), + duration: timeEnd - timeStart, + size: error.response.headers['content-length'] || getSize(error.response.data), + data: error.response.data + }; + + // if we get a response from the server, we consider it as a success + mainWindow.webContents.send('main:run-folder-event', { + type: 'response-received', + error: error ? error.message : 'An error occurred while running the request', + responseReceived: response, + ...eventData }); - - request.httpAgent = new HttpProxyAgent(proxyUri); + } else { + // if it's not a network error, don't continue + throw Promise.reject(error); } - } else if (!sslVerification) { - request.httpsAgent = new https.Agent({ - rejectUnauthorized: false - }); } - // send request - timeStart = Date.now(); - const response = await axios(request); - timeEnd = Date.now(); - // run post-response vars const postResponseVars = get(request, 'vars.res', []); - if (postResponseVars && postResponseVars.length) { + if (postResponseVars?.length) { const varsRuntime = new VarsRuntime(); const result = varsRuntime.runPostResponseVars( postResponseVars, @@ -842,105 +812,11 @@ const registerNetworkIpc = (mainWindow) => { collectionUid }); } - - mainWindow.webContents.send('main:run-folder-event', { - type: 'response-received', - ...eventData, - responseReceived: { - status: response.status, - statusText: response.statusText, - headers: Object.entries(response.headers), - duration: timeEnd - timeStart, - size: response.headers['content-length'] || getSize(response.data), - data: response.data - } - }); } catch (error) { - let responseReceived = {}; - let duration = 0; - - if (timeStart && timeEnd) { - duration = timeEnd - timeStart; - } - - if (error && error.response) { - responseReceived = { - status: error.response.status, - statusText: error.response.statusText, - headers: Object.entries(error.response.headers), - duration: duration, - size: error.response.headers['content-length'] || getSize(error.response.data), - data: error.response.data - }; - - // run assertions - const assertions = get(item, 'request.assertions'); - if (assertions) { - const assertRuntime = new AssertRuntime(); - const results = assertRuntime.runAssertions( - assertions, - request, - error.response, - envVars, - collectionVariables, - collectionPath - ); - - mainWindow.webContents.send('main:run-folder-event', { - type: 'assertion-results', - assertionResults: results, - itemUid: item.uid, - collectionUid - }); - } - - // run tests - const testFile = compact([ - get(collectionRoot, 'request.tests'), - item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests') - ]).join(os.EOL); - if (typeof testFile === 'string') { - const testRuntime = new TestRuntime(); - const testResults = await testRuntime.runTests( - decomment(testFile), - request, - error.response, - envVars, - collectionVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig - ); - - mainWindow.webContents.send('main:run-folder-event', { - type: 'test-results', - testResults: testResults.results, - ...eventData - }); - - mainWindow.webContents.send('main:script-environment-update', { - envVariables: testResults.envVariables, - collectionVariables: testResults.collectionVariables, - collectionUid - }); - } - - // if we get a response from the server, we consider it as a success - mainWindow.webContents.send('main:run-folder-event', { - type: 'response-received', - error: error ? error.message : 'An error occurred while running the request', - responseReceived: responseReceived, - ...eventData - }); - - continue; - } - mainWindow.webContents.send('main:run-folder-event', { type: 'error', error: error ? error.message : 'An error occurred while running the request', - responseReceived: responseReceived, + responseReceived: {}, ...eventData }); } diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js new file mode 100644 index 0000000000..679c74e371 --- /dev/null +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -0,0 +1,35 @@ +const { ipcMain } = require('electron'); +const { getPreferences, savePreferences } = require('../store/preferences'); +const { isDirectory } = require('../utils/filesystem'); +const { openCollection } = require('../app/collections'); +``; +const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => { + ipcMain.handle('renderer:ready', async (event) => { + // load preferences + const preferences = getPreferences(); + mainWindow.webContents.send('main:load-preferences', preferences); + + // reload last opened collections + const lastOpened = lastOpenedCollections.getAll(); + + if (lastOpened && lastOpened.length) { + for (let collectionPath of lastOpened) { + if (isDirectory(collectionPath)) { + await openCollection(mainWindow, watcher, collectionPath, { + dontSendDisplayErrors: true + }); + } + } + } + }); + + ipcMain.handle('renderer:save-preferences', async (event, preferences) => { + try { + await savePreferences(preferences); + } catch (error) { + return Promise.reject(error); + } + }); +}; + +module.exports = registerPreferencesIpc; diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index f1b86b0f36..fcf514f533 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -1,26 +1,111 @@ +const Yup = require('yup'); +const Store = require('electron-store'); +const { get } = require('lodash'); + /** - * The preferences are stored in the browser local storage. - * When the app is started, an IPC message is published from the renderer process to set the preferences. + * The preferences are stored in the electron store 'preferences.json'. * The electron process uses this module to get the preferences. * - * { - * request: { - * sslVerification: boolean - * } - * } */ -let preferences = {}; +const defaultPreferences = { + request: { + sslVerification: true, + timeout: 0 + }, + font: { + codeFont: 'default' + }, + proxy: { + enabled: false, + protocol: 'http', + hostname: '', + port: '', + auth: { + enabled: false, + username: '', + password: '' + }, + bypassProxy: '' + } +}; + +const preferencesSchema = Yup.object().shape({ + request: Yup.object().shape({ + sslVerification: Yup.boolean(), + timeout: Yup.number() + }), + font: Yup.object().shape({ + codeFont: Yup.string().nullable() + }), + proxy: Yup.object({ + enabled: Yup.boolean(), + protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']), + hostname: Yup.string().max(1024), + port: Yup.number().min(1).max(65535).nullable(), + auth: Yup.object({ + enabled: Yup.boolean(), + username: Yup.string().max(1024), + password: Yup.string().max(1024) + }).optional(), + bypassProxy: Yup.string().optional().max(1024) + }) +}); + +class PreferencesStore { + constructor() { + this.store = new Store({ + name: 'preferences', + clearInvalidConfig: true + }); + } + + getPreferences() { + return { + ...defaultPreferences, + ...this.store.get('preferences') + }; + } + + savePreferences(newPreferences) { + return this.store.set('preferences', newPreferences); + } +} + +const preferencesStore = new PreferencesStore(); const getPreferences = () => { - return preferences; + return preferencesStore.getPreferences(); +}; + +const savePreferences = async (newPreferences) => { + return new Promise((resolve, reject) => { + preferencesSchema + .validate(newPreferences, { abortEarly: true }) + .then((validatedPreferences) => { + preferencesStore.savePreferences(validatedPreferences); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); }; -const setPreferences = (newPreferences) => { - preferences = newPreferences; +const preferencesUtil = { + shouldVerifyTls: () => { + return get(getPreferences(), 'request.sslVerification', true); + }, + getRequestTimeout: () => { + return get(getPreferences(), 'request.timeout', 0); + }, + getGlobalProxyConfig: () => { + return get(getPreferences(), 'proxy', {}); + } }; module.exports = { getPreferences, - setPreferences + savePreferences, + preferencesUtil }; diff --git a/packages/bruno-electron/src/store/window-state.js b/packages/bruno-electron/src/store/window-state.js index bb0a61b647..425aef87e8 100644 --- a/packages/bruno-electron/src/store/window-state.js +++ b/packages/bruno-electron/src/store/window-state.js @@ -1,9 +1,10 @@ -const _ = require('lodash'); const Store = require('electron-store'); const DEFAULT_WINDOW_WIDTH = 1280; const DEFAULT_WINDOW_HEIGHT = 768; +const DEFAULT_MAXIMIZED = false; + class WindowStateStore { constructor() { this.store = new Store({ @@ -26,6 +27,14 @@ class WindowStateStore { setBounds(bounds) { this.store.set('window-bounds', bounds); } + + getMaximized() { + return this.store.get('maximized') || DEFAULT_MAXIMIZED; + } + + setMaximized(isMaximized) { + this.store.set('maximized', isMaximized); + } } module.exports = WindowStateStore; diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js new file mode 100644 index 0000000000..a9fdc1b9aa --- /dev/null +++ b/packages/bruno-electron/src/utils/proxy-util.js @@ -0,0 +1,66 @@ +const parseUrl = require('url').parse; +const { isEmpty } = require('lodash'); + +const DEFAULT_PORTS = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443 +}; +/** + * check for proxy bypass, copied form 'proxy-from-env' + */ +const shouldUseProxy = (url, proxyBypass) => { + if (proxyBypass === '*') { + return false; // Never proxy if wildcard is set. + } + + // use proxy if no proxyBypass is set + if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) { + return true; + } + + const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {}; + let proto = parsedUrl.protocol; + let hostname = parsedUrl.host; + let port = parsedUrl.port; + if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') { + return false; // Don't proxy URLs without a valid scheme or host. + } + + proto = proto.split(':', 1)[0]; + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, ''); + port = parseInt(port) || DEFAULT_PORTS[proto] || 0; + + return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) { + if (!dontProxyFor) { + return true; // Skip zero-length hosts. + } + const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/); + let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor; + const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0; + if (parsedProxyPort && parsedProxyPort !== port) { + return true; // Skip if ports don't match. + } + + if (!/^[.*]/.test(parsedProxyHostname)) { + // No wildcards, so stop proxying if there is an exact match. + return hostname !== parsedProxyHostname; + } + + if (parsedProxyHostname.charAt(0) === '*') { + // Remove leading wildcard. + parsedProxyHostname = parsedProxyHostname.slice(1); + } + // Stop proxying if the hostname ends with the no_proxy host. + return !hostname.endsWith(parsedProxyHostname); + }); +}; + +module.exports = { + shouldUseProxy +}; diff --git a/packages/bruno-electron/src/utils/window.js b/packages/bruno-electron/src/utils/window.js index d824141d30..949a2aaa2c 100644 --- a/packages/bruno-electron/src/utils/window.js +++ b/packages/bruno-electron/src/utils/window.js @@ -7,12 +7,14 @@ const DEFAULT_WINDOW_WIDTH = 1280; const DEFAULT_WINDOW_HEIGHT = 768; const loadWindowState = () => { + const maximized = windowStateStore.getMaximized(); const bounds = windowStateStore.getBounds(); const positionValid = isPositionValid(bounds); const sizeValid = isSizeValid(bounds); return { + maximized, x: bounds.x && positionValid ? bounds.x : undefined, y: bounds.y && positionValid ? bounds.y : undefined, width: bounds.width && sizeValid ? bounds.width : DEFAULT_WINDOW_WIDTH, @@ -20,12 +22,16 @@ const loadWindowState = () => { }; }; -const saveWindowState = (window) => { +const saveBounds = (window) => { const bounds = window.getBounds(); windowStateStore.setBounds(bounds); }; +const saveMaximized = (isMaximized) => { + windowStateStore.setMaximized(isMaximized); +}; + const isPositionValid = (bounds) => { const area = getArea(bounds); @@ -49,5 +55,6 @@ const getArea = (bounds) => { module.exports = { loadWindowState, - saveWindowState + saveBounds, + saveMaximized }; diff --git a/packages/bruno-electron/tests/utils/proxy-util.spec.js b/packages/bruno-electron/tests/utils/proxy-util.spec.js new file mode 100644 index 0000000000..ac1d5a9407 --- /dev/null +++ b/packages/bruno-electron/tests/utils/proxy-util.spec.js @@ -0,0 +1,50 @@ +const { shouldUseProxy } = require('../../src/utils/proxy-util'); + +test('no proxy necessary - star', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = '*'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('no proxy necessary - no noProxy bypass', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = ''; + + expect(shouldUseProxy(url, noProxy)).toEqual(true); +}); + +test('no proxy necessary - wildcard match', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = '*example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('no proxy necessary - direct proxy', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = 'wwww.example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('no proxy necessary - multiple proxy', () => { + const url = 'http://wwww.example.org/test'; + const noProxy = 'www.example.com,wwww.example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(false); +}); + +test('proxy necessary - no proxy match multiple', () => { + const url = 'https://wwww.example.test/test'; + const noProxy = 'www.example.com,wwww.example.org'; + + expect(shouldUseProxy(url, noProxy)).toEqual(true); +}); + +test('proxy necessary - no proxy match', () => { + const url = 'https://wwww.example.test/test'; + const noProxy = 'www.example.com'; + + expect(shouldUseProxy(url, noProxy)).toEqual(true); +}); diff --git a/packages/bruno-js/src/bruno-request.js b/packages/bruno-js/src/bruno-request.js index 099d351115..afbf978731 100644 --- a/packages/bruno-js/src/bruno-request.js +++ b/packages/bruno-js/src/bruno-request.js @@ -5,6 +5,7 @@ class BrunoRequest { this.method = req.method; this.headers = req.headers; this.body = req.data; + this.timeout = req.timeout; } getUrl() { @@ -50,6 +51,14 @@ class BrunoRequest { setMaxRedirects(maxRedirects) { this.req.maxRedirects = maxRedirects; } + + getTimeout() { + return this.req.timeout; + } + + setTimeout(timeout) { + this.req.timeout = timeout; + } } module.exports = BrunoRequest; diff --git a/packages/bruno-js/src/interpolate-string.js b/packages/bruno-js/src/interpolate-string.js new file mode 100644 index 0000000000..22910c1a35 --- /dev/null +++ b/packages/bruno-js/src/interpolate-string.js @@ -0,0 +1,55 @@ +const Handlebars = require('handlebars'); +const { forOwn, cloneDeep } = require('lodash'); + +const interpolateEnvVars = (str, processEnvVars) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + const template = Handlebars.compile(str, { noEscape: true }); + + return template({ + process: { + env: { + ...processEnvVars + } + } + }); +}; + +const interpolateString = (str, { envVariables, collectionVariables, processEnvVars }) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + processEnvVars = processEnvVars || {}; + collectionVariables = collectionVariables || {}; + + // we clone envVariables because we don't want to modify the original object + envVariables = envVariables ? cloneDeep(envVariables) : {}; + + // envVariables can inturn have values as {{process.env.VAR_NAME}} + // so we need to interpolate envVariables first with processEnvVars + forOwn(envVariables, (value, key) => { + envVariables[key] = interpolateEnvVars(value, processEnvVars); + }); + + const template = Handlebars.compile(str, { noEscape: true }); + + // collectionVariables take precedence over envVariables + const combinedVars = { + ...envVariables, + ...collectionVariables, + process: { + env: { + ...processEnvVars + } + } + }; + + return template(combinedVars); +}; + +module.exports = { + interpolateString +}; diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index 06f6adab41..36fd7c9f28 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -4,6 +4,7 @@ const { nanoid } = require('nanoid'); const Bru = require('../bru'); const BrunoRequest = require('../bruno-request'); const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils'); +const { interpolateString } = require('../interpolate-string'); const { expect } = chai; chai.use(require('chai-string')); @@ -161,17 +162,27 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => { return; } + const interpolationContext = { + collectionVariables: context.bru.collectionVariables, + envVariables: context.bru.envVariables, + processEnvVars: context.bru.processEnvVars + }; + // gracefully allow both a,b as well as [a, b] if (operator === 'in' || operator === 'notIn') { if (rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) { rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1); } - return rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context)); + return rhsOperand + .split(',') + .map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context)); } if (operator === 'between') { - const [lhs, rhs] = rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context)); + const [lhs, rhs] = rhsOperand + .split(',') + .map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context)); return [lhs, rhs]; } @@ -181,10 +192,10 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => { rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1); } - return rhsOperand; + return interpolateString(rhsOperand, interpolationContext); } - return evaluateJsTemplateLiteral(rhsOperand, context); + return evaluateJsTemplateLiteral(interpolateString(rhsOperand, interpolationContext), context); }; class AssertRuntime { diff --git a/readme.md b/readme.md index 8021a5b7af..cf3d2dc61b 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ [![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) -**English** | [Русский](/readme_ru.md) +**English** | [Українська](/readme_ua.md) | [Русский](/readme_ru.md) Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there. diff --git a/readme_ru.md b/readme_ru.md index 8c25e5c574..d8b8255f73 100644 --- a/readme_ru.md +++ b/readme_ru.md @@ -10,7 +10,7 @@ [![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) -[English](/readme.md) | **Русский** +[English](/readme.md) | [Українська](/readme_ua.md) | **Русский** Bruno - новый и инновационный клиент API, направленный на революцию в установившейся ситуации, представленной Postman и подобными инструментами. diff --git a/readme_ua.md b/readme_ua.md new file mode 100644 index 0000000000..793e11a8ed --- /dev/null +++ b/readme_ua.md @@ -0,0 +1,80 @@ +
+ + +### Bruno - IDE із відкритим кодом для тестування та дослідження API + +[![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) +[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse) +[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno) +[![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) + +[English](/readme.md) | **Українська** | [Русский](/readme_ru.md) + +Bruno це новий та іноваційний API клієнт, націлений на революційну зміну статус кво, запровадженого інструментами на кшталт Postman. + +Bruno зберігає ваші колекції напряму у теці на вашому диску. Він використовує текстову мову розмітки Bru для збереження інформації про ваші API запити. + +Ви можете використовувати git або будь-яку іншу систему контролю версій щоб спільно працювати над вашими колекціями API запитів. + +Bruno є повністю автономним. Немає жодних планів додавати будь-які синхронізації через хмару, ніколи. Ми цінуємо приватність ваших даних, і вважаєм, що вони мають залишитись лише на вашому комп'ютері. Взнати більше про наше бачення у довготривалій перспективі можна [тут](https://github.com/usebruno/bruno/discussions/269) + +![bruno](assets/images/landing-2.png)

+ +### Кросплатформенність 🖥️ + +![bruno](assets/images/run-anywhere.png)

+ +### Спільна робота через Git 👩‍💻🧑‍💻 + +Або будь-яку іншу систему контролю версій на ваш вибір + +![bruno](assets/images/version-control.png)

+ +### Важливі посилання 📌 + +- [Наше бачення довготривалої перспективи проекту](https://github.com/usebruno/bruno/discussions/269) +- [Дорожня карта проекту](https://github.com/usebruno/bruno/discussions/384) +- [Документація](https://docs.usebruno.com) +- [Сайт](https://www.usebruno.com) +- [Завантаження](https://www.usebruno.com/downloads) + +### Вітрина 🎥 + +- [Відгуки](https://github.com/usebruno/bruno/discussions/343) +- [Хаб знань](https://github.com/usebruno/bruno/discussions/386) +- [Scriptmania](https://github.com/usebruno/bruno/discussions/385) + +### Підтримка ❤️ + +Гав! Якщо вам сподобався проект, тисніть на ⭐ !! + +### Поділитись відгуками 📣 + +Якщо Bruno допоміг вам у вашій роботі і вашим командам, будь ласка не забудьте поділитись вашими [відгуками у github дискусії](https://github.com/usebruno/bruno/discussions/343) + +### Зробити свій внесок 👩‍💻🧑‍💻 + +Я радий що ви бажаєте покращити Bruno. Будь ласка переглянте [інструкцію по контрибуції](contributing_ua.md) + +Навіть якщо ви не можете зробити свій внесок пишучи програмний код, будь ласка не соромтесь рапортувати про помилки і писати запити на новий функціонал, який потрібен вам у вашій роботі. + +### Автори + + + +### Залишайтесь на зв'язку 🌐 + +[Twitter](https://twitter.com/use_bruno)
+[Сайт](https://www.usebruno.com)
+[Discord](https://discord.com/invite/KgcZUncpjq) +[LinkedIn](https://www.linkedin.com/company/usebruno) + +### Ліцензія 📄 + +[MIT](license.md) diff --git a/scripts/build-electron.js b/scripts/build-electron.js index 363fdf4d22..9825c3a09b 100644 --- a/scripts/build-electron.js +++ b/scripts/build-electron.js @@ -1,8 +1,7 @@ const os = require('os'); const fs = require('fs-extra'); const util = require('util'); -const exec = util.promisify(require('child_process').exec); - +const spawn = util.promisify(require('child_process').spawn); async function deleteFileIfExists(filePath) { try { @@ -47,6 +46,25 @@ async function removeSourceMapFiles(directory) { } } +async function execCommandWithOutput(command) { + return new Promise(async (resolve, reject) => { + const childProcess = await spawn(command, { + stdio: 'inherit', + shell: true + }); + childProcess.on('error', (error) => { + reject(error); + }); + childProcess.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command exited with code ${code}.`)); + } + }); + }); +} + async function main() { try { // Remove out directory @@ -67,13 +85,13 @@ async function main() { for (const file of files) { if (file.endsWith('.html')) { let content = await fs.readFile(`packages/bruno-electron/web/${file}`, 'utf8'); - content = content.replace(/\/_next\//g, '/_next/'); + content = content.replace(/\/_next\//g, '_next/'); await fs.writeFile(`packages/bruno-electron/web/${file}`, content); } } // Remove sourcemaps - await removeSourceMapFiles('packages/bruno-electron/web') + await removeSourceMapFiles('packages/bruno-electron/web'); // Run npm dist command console.log('Building the Electron distribution'); @@ -88,8 +106,7 @@ async function main() { osArg = 'linux'; } - await exec(`npm run dist-${osArg} --workspace=packages/bruno-electron`); - + await execCommandWithOutput(`npm run dist:${osArg} --workspace=packages/bruno-electron`); } catch (error) { console.error('An error occurred:', error); } diff --git a/tests/home.spec.js b/tests/home.spec.js index 6c10905fbe..0ab8a5652b 100644 --- a/tests/home.spec.js +++ b/tests/home.spec.js @@ -1,6 +1,6 @@ const { test, expect } = require('@playwright/test'); const { HomePage } = require('../tests/pages/home.page'); -import * as faker from './utils/data-faker'; +const { faker } = require('./utils/data-faker'); test.describe('bruno e2e test', () => { let homePage;