Compare commits

...

28 Commits

Author SHA1 Message Date
game5413
2e71e605ea feat: path parameters (#484)
* add path parameters on bruno-app

* add path parameters on bruno-cli

* fix bruno-schema testing

* fix generate request code not replace path parameter value

---------

Co-authored-by: game5413 <febryanph10@gmail.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-05-13 15:44:27 +05:30
Anoop M D
e149c8dc9a release: v1.17.0 2024-05-07 23:17:24 +05:30
Anoop M D
411289daa8 chore: bumped cli version 2024-05-06 16:24:00 +05:30
Mateusz Pietryga
60cf012cbe Fix: CLI Test Runner ignores auth:"inherit" in requests (#2206) (#2243)
#1667 - None means None, but inherit means inherit
2024-05-06 16:18:53 +05:30
Anoop M D
392cc80db2 release: v1.16.1 2024-04-30 20:59:12 +05:30
Anoop M D
d7c0f489f6 release: v1.16.0 2024-04-29 19:46:31 +05:30
Anoop M D
2569a1389d feat(#1667): None means None 2024-04-29 19:40:40 +05:30
Anoop M D
fe5816eb9a chore: improved the oauth help message 2024-04-29 19:37:31 +05:30
Rinku Chaudhari
23704a0800 fix: save button on confirm close popup not working (#2156) 2024-04-29 19:12:59 +05:30
Angela Yuan
c17e4effe7 Prevent losing unsaved environment variable data when attempting to change env (#2034)
* trying to begin changes

* Env bug fixed with only switching env when saved

* dialog box working, formik in EnvironmentSettings to pass props, selectedEnvironment in EnvrionmentSettings to pass props

* Removing some uneccessary comments

* no immediate following dialog pop up after warning dialog

* Wrapping commit warning moidal in CreatePortal, removing unnecessary isModified state, removing comments

* modifying dialog and adding formik back to EnvironmentVariables

* Removing unnecessary comments

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-04-26 14:11:05 +05:30
Anoop M D
16861c9889 chore: bumped versions 2024-04-22 23:41:14 +05:30
Anoop M D
87404132ae chore: bump to v1.14.0 2024-04-22 20:23:24 +05:30
Bassam Mejlaoui
7019a77ec6 Update readme.md (#1642)
Adding Arabic
2024-04-22 00:21:33 +05:30
Bassam Mejlaoui
ad9169d78a Create readme_ar.md (#1640)
* Create readme_ar.md
Translation of README and Additional Sections to Arabic
2024-04-22 00:20:47 +05:30
Baptiste Poulain
54e99cbbd7 fix(tailwindcss-forms): messes up input fields everywhere, removed it (#1817)
* fix(tailwindcss-forms): messes up input fields everywhere, removed it
* fix(tailwindcss-form): update package-lock.json
---------
Co-authored-by: bpoulaindev <bpoulainpro@gmail.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-04-22 00:17:50 +05:30
Max Bauer
eb9862b8f5 bugfix: hide autocomplete on escape keyup (#2091) 2024-04-22 00:14:34 +05:30
Bijin A B
7a3cc4e040 fix: error boundary adding return-to-app and force-quit options (#2131)
* fix: error boundary adding return-to-app and force-quit options

* fix: method context

* fix: method context

* chore: increased print width to 200 in prettier

* chore: reverted the prettier printWidth increase

* feat: remove box-shadow from error boundary message layout

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-04-22 00:11:51 +05:30
Adam Rashid
60cb9da83e flx: add unique key prop to ImportCollection options (#2133)
This PR uses the key of the options object in import collection to suppress the unique key prop warning.

Fixes issue #2078
2024-04-22 00:00:21 +05:30
Gustavo Ferreira da Silva
28e4159c21 feat: toggle password visibility (#2127)
* Toggle password in ProxySettings

Input 'password' in ProxySettings (Preferences) can be toggled to be visible or not.

* Solving button overlap

- Button to toggle password won't cover the text;
- Added toggle password feature in CollectionSettings (ClientCertSettings and ProxySettings).
2024-04-21 23:50:16 +05:30
Anoop M D
59ffb0166f chore: reverted the prettier printWidth increase 2024-04-19 00:34:04 +05:30
Anoop M D
e8ec74107d chore: increased print width to 200 in prettier 2024-04-18 18:25:48 +05:30
Anoop M D
d027d90ed5 fix(#2079): test cases to catch getEnvName() error in the future 2024-04-18 17:52:36 +05:30
Anoop M D
7bbc4727be fix: fixed ui error when ignores setting is not found in bruno.json 2024-04-18 17:43:32 +05:30
Anoop M D
386a8df151 feat: bumped node version to use to v20 2024-04-18 17:42:49 +05:30
Anoop M D
2ed35108d7 chore: moved codemirror autocomplete util to its own file 2024-04-17 14:30:19 +05:30
Sanjai Kumar
a0860febee Refactor Bruno language parser to support multiline text blocks (#2086)
* Refactor Bruno language parser to support multiline text blocks

* Refactor SingleLineEditor component to allow newlines and adjust height dynamically

* Refactor SingleLineEditor component to fix overflow issue

* Fix overflow issue in SingleLineEditor component

* Refactor SingleLineEditor and MultiLineEditor components to fix overflow issues and allow newlines

* Fix overflow and scrolling issues in MultiLineEditor component

---------

Co-authored-by: Sanjai Kumar <sk@sk.local>
2024-04-17 14:19:44 +05:30
Anoop M D
bc70bba0b6 release: v1.13.1 2024-04-16 02:33:30 +05:30
Bijin A B
d0a1621734 fix: handle null values (#2090)
Co-authored-by: Bijin AB <bijin.balakrishnan@allegiantair.com>
2024-04-16 01:32:34 +05:30
74 changed files with 1974 additions and 539 deletions

View File

@@ -1,51 +0,0 @@
name: Publish to Snapcraft
on:
workflow_dispatch:
inputs:
build:
description: 'Build and publish to Snapcraft'
required: true
default: 'true'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Check package-lock.json
run: npm ci --legacy-peer-deps
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Build Electron app
run: |
npm run build:bruno-common
npm run build:bruno-query
npm run build:graphql-docs
npm run build:web
npm run build:electron:snap
- name: Install Snapcraft
run: |
sudo snap install snapcraft --classic
continue-on-error: true
- name: Configure Snapcraft
run: snapcraft whoami
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_API_KEY }}
- name: Publish to Snapcraft
run: |
snapcraft upload --release=stable packages/bruno-electron/out/*.snap
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_API_KEY }}

2
.nvmrc
View File

@@ -1 +1 @@
v18.13.0
v20.9.0

View File

@@ -23,7 +23,6 @@ Bruno 基于 NextJs 和 React 构建。我们使用 Electron 来封装桌面版
您需要 [Node v18.x 或最新的 LTS 版本](https://nodejs.org/en/) 和 npm 8.x。我们在这个项目中也使用 npm 工作区_npm workspaces_
## 开发
Bruno 是作为一个 _client lourd重客户端_ 应用程序开发的。您需要在一个终端中启动 nextjs 来加载应用程序,然后在另一个终端中启动 Electron 应用程序。
@@ -68,7 +67,6 @@ done
find . -type f -name "package-lock.json" -delete
```
### 测试
```bash
@@ -79,7 +77,6 @@ npm test --workspace=packages/bruno-schema
npm test --workspace=packages/bruno-lang
```
### 提交 Pull Request
- 请保持 PR 精简并专注于单一目标
@@ -87,4 +84,4 @@ npm test --workspace=packages/bruno-lang
- feature/[feature name]:该分支应包含特定功能
- 例如feature/dark-mode
- bugfix/[bug name]:该分支应仅包含特定 bug 的修复
- 例如bugfix/bug-1
- 例如bugfix/bug-1

View File

@@ -27,7 +27,6 @@ Vous aurez besoin de [Node v18.x ou la dernière version LTS](https://nodejs.org
Bruno est développé comme une application _client lourd_. Vous devrez charger l'application en démarrant nextjs dans un premier terminal, puis démarre l'application Electron dans un second.
### Dépendances
- NodeJS v18
@@ -68,7 +67,6 @@ done
find . -type f -name "package-lock.json" -delete
```
### Tests
```bash
@@ -79,7 +77,6 @@ npm test --workspace=packages/bruno-schema
npm test --workspace=packages/bruno-lang
```
### Ouvrir une Pull Request
- Merci de conserver les PR minimes et focalisées sur un seul objectif
@@ -87,4 +84,4 @@ npm test --workspace=packages/bruno-lang
- feature/[feature name]: Cette branche doit contenir une fonctionnalité spécifique
- Exemple : feature/dark-mode
- bugfix/[bug name]: Cette branche doit contenir seulement une solution pour un bug spécifique
- Exemple : bugfix/bug-1
- Exemple : bugfix/bug-1

View File

@@ -33,7 +33,7 @@ Bruno jest rozwijane jako aplikacja desktopowa. Musisz załadować aplikację, u
### Lokalny Rozwój
```bash
````bash
# użyj wersji nodejs 18
nvm use
@@ -66,7 +66,7 @@ done
# Usuń package-lock w podkatalogach
find . -type f -name "package-lock.json" -delete
```
````
### Testowanie

View File

@@ -1,4 +1,5 @@
[English](../../contributing.md) | [Українська](docs/contributing/contributing_ua.md) | [Русский](docs/contributing/contributing_ru.md) | **Türkçe** | [Deutsch](docs/contributing/contributing_de.md) | [Français](docs/contributing/contributing_fr.md) | [Português (BR)](docs/contributing/contributing_pt_br.md) | [বাংলা](docs/contributing/contributing_bn.md) | [Español](docs/contributing/contributing_es.md) | [Română](docs/contributing/contributing_ro.md) | [Polski](docs/contributing/contributing_pl.md) | [简体中文](docs/contributing/contributing_cn.md) | [正體中文](docs/contributing/contributing_zhtw.md)
[English](../../contributing.md) | [Українська](docs/contributing/contributing_ua.md) | [Русский](docs/contributing/contributing_ru.md) | **Türkçe** | [Deutsch](docs/contributing/contributing_de.md) | [Français](docs/contributing/contributing_fr.md) | [Português (BR)](docs/contributing/contributing_pt_br.md) | [বাংলা](docs/contributing/contributing_bn.md) | [Español](docs/contributing/contributing_es.md) | [Română](docs/contributing/contributing_ro.md) | [Polski](docs/contributing/contributing_pl.md)
| [简体中文](docs/contributing/contributing_cn.md) | [正體中文](docs/contributing/contributing_zhtw.md)
## Bruno'yu birlikte daha iyi hale getirelim!!!
@@ -57,7 +58,6 @@ npm run dev:electron
`npm install`'ı çalıştırdığınızda `Unsupported platform` hatası ile karşılaşabilirsiniz. Bunu düzeltmek için `node_modules` ve `package-lock.json` dosyalarını silmeniz ve `npm install` dosyasını çalıştırmanız gerekecektir. Bu, uygulamayı çalıştırmak için gereken tüm gerekli paketleri yüklemelidir.
```shell
# Alt dizinlerdeki node_modules öğelerini silme
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do

View File

@@ -23,12 +23,10 @@ Bruno 使用 Next.js 和 React 構建。我們使用 Electron 來封裝及發佈
您需要使用 [Node v18.x 或最新的 LTS 版本](https://nodejs.org/en/) 和 npm 8.x。我們在這個專案中使用 npm 工作區_npm workspaces_
## 開發
Bruno 正以桌面應用程式的形式開發。您需要在一個終端機中執行 Next.js 來載入應用程式,然後在另一個終端機中執行 electron 應用程式。
### 開發依賴
- NodeJS v18
@@ -69,7 +67,6 @@ done
find . -type f -name "package-lock.json" -delete
```
### 測試
```bash
@@ -80,7 +77,6 @@ npm test --workspace=packages/bruno-schema
npm test --workspace=packages/bruno-lang
```
### 發送 Pull Request
- 請保持 PR 精簡並專注於一個目標

139
docs/readme/readme_ar.md Normal file
View File

@@ -0,0 +1,139 @@
<br />
<img src="assets/images/logo-transparent.png" width="80"/>
### برونو - بيئة تطوير مفتوحة المصدر لاستكشاف واختبار واجهات برمجة التطبيقات (APIs).
[![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** | [Українська](docs/readme/readme_ua.md) | [Русский](docs/readme/readme_ru.md) | [Türkçe](docs/readme/readme_tr.md) | [Deutsch](docs/readme/readme_de.md) | [Français](docs/readme/readme_fr.md) | [Português (BR)](docs/readme/readme_pt_br.md) | [한국어](docs/readme/readme_kr.md) | [বাংলা](docs/readme/readme_bn.md) | [Español](docs/readme/readme_es.md) | [Italiano](docs/readme/readme_it.md) | [Română](docs/readme/readme_ro.md) | [Polski](docs/readme/readme_pl.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md) | [العربية](docs/readme/readme_ar.md)
برونو هو عميل API جديد ومبتكر، يهدف إلى ثورة الحالة الحالية التي يمثلها برنامج Postman وأدوات مماثلة هناك.
يقوم برونو بتخزين مجموعاتك مباشرة في مجلد على نظام الملفات الخاص بك. نحن نستخدم لغة ترميز النص العادية، Bru، لحفظ معلومات حول طلبات واجهة برمجة التطبيقات (API).
يمكنك استخدام Git أو أي نظام تحكم في الإصدار الذي تفضله للتعاون على مجموعات API الخاصة بك.
برونو هو خاص بالاستخدام دون اتصال بالإنترنت. ليس هناك خطط لإضافة مزامنة السحابة إلى برونو أبدًا. نحن نقدر خصوصية بياناتك ونعتقد أنه يجب أن تظل على جهازك. اقرأ رؤيتنا على المدى الطويل [هنا](https://github.com/usebruno/bruno/discussions/269)
📢 شاهد حديثنا الأخير في مؤتمر India FOSS 3.0 [هنا](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](https://github.com/usebruno/bruno/blob/main/assets/images/landing-2.png) <br /><br />
### الطبعة الذهبية ✨
غالبية ميزاتنا مجانية ومفتوحة المصدر.
نحن نسعى لتحقيق توازن متناغم بين [مبادئ الشفافية والاستدامة](https://github.com/usebruno/bruno/discussions/269)
طلبات الشراء لـ [الطبعة الذهبية](https://www.usebruno.com/pricing) ستطلق قريبًا بسعر ~~$19~~ **$9** ! <br/>
[اشترك هنا](https://usebruno.ck.page/4c65576bd4) لتصلك إشعارات عند الإطلاق.
### التثبيت
برونو متاح كتنزيل ثنائي [على موقعنا على الويب](https://www.usebruno.com/downloads) لأنظمة التشغيل Mac و Windows و Linux.
يمكنك أيضًا تثبيت برونو عبر مديري الحزم مثل Homebrew و Chocolatey و Scoop و Snap و Flatpak و Apt.
```sh
# على نظام Mac عبر Homebrew
brew install bruno
# على نظام Windows عبر Chocolatey
choco install bruno
# على نظام Windows عبر Scoop
scoop bucket add extras
scoop install bruno
# على نظام Linux عبر Snap
snap install bruno
# على نظام Linux عبر Flatpak
flatpak install com.usebruno.Bruno
# على نظام Linux عبر Apt
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt
```
### التشغيل عبر منصات متعددة 🖥️
![bruno](https://github.com/usebruno/bruno/blob/main/assets/images/run-anywhere.png) <br /><br />
### التعاون عبر Git 👩‍💻🧑‍💻
أو أي نظام تحكم في الإصدار الذي تفضله
![bruno](https://github.com/usebruno/bruno/blob/main/assets/images/version-control.png) <br /><br />
### الروابط المهمة 📌
- [رؤيتنا على المدى الطويل](https://github.com/usebruno/bruno/discussions/269)
- [خارطة الطريق](https://github.com/usebruno/bruno/discussions/384)
- [التوثيق](https://docs.usebruno.com)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
- [الموقع الإلكتروني](https://www.usebruno.com)
- [التسعير](https://www.usebruno.com/pricing)
- [التنزيل](https://www.usebruno.com/downloads)
- [Github Sponsors](https://github.com/sponsors/helloanoop).
### عروض 🎥
- [الشهادات](https://github.com/usebruno/bruno/discussions/343)
- [مركز المعرفة](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
### الدعم ❤️
إذا كنت تحب برونو وترغب في دعم عملنا مفتوح المصدر، فكر في رعايتنا عبر [Github Sponsors](https://github.com/sponsors/helloanoop).
### شارك الشهادات 📣
إذا كان برونو قد ساعدك في العمل وفرقك، فلا تنسى مشاركة [شهاداتك في مناقشتنا على GitHub](https://github.com/usebruno/bruno/discussions/343)
### نشر إلى مديري الحزم الجديدة
يرجى الرجوع [هنا](publishing.md) لمزيد من المعلومات.
### تواصل معنا 🌐
[𝕏 (تويتر)](https://twitter.com/use_bruno) <br />
[الموقع الإلكتروني](https://www.usebruno.com) <br />
[ديسكورد](https://discord.com/invite/KgcZUncpjq) <br />
[لينكدإن](https://www.linkedin.com/company/usebruno)
### علامة تجارية
**الاسم**
`برونو` هو علامة تجارية تمتلكها [أنوب إم دي](https://www.helloanoop.com/)
**الشعار**
الشعار من [OpenMoji](https://openmoji.org/library/emoji-1F436/). الترخيص: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
### المساهمة 👩‍💻🧑‍💻
يسعدني أنك تتطلع لتحسين برونو. يرجى الاطلاع على [دليل المساهمة](contributing.md)
حتى إذا لم تكن قادرًا على التساهم بشكل مباشر من خلال الشيفرة، فلا تتردد في الإبلاغ عن الأخطاء وطلب الميزات التي يجب تنفيذها لحل حالتك.
### الكتّاب
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### الرخصة 📄
[MIT](license.md)

View File

@@ -1,7 +1,7 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### Bruno - 开源IDE用于探索和测试API。
### 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)
@@ -12,16 +12,14 @@
[English](../../readme.md) | [Українська](./readme_ua.md) | [Русский](./readme_ru.md) | [Türkçe](./readme_tr.md) | [Deutsch](./readme_de.md) | [Français](./readme_fr.md) | [Português (BR)](./readme_pt_br.md) | [한국어](./readme_kr.md) | [বাংলা](./readme_bn.md) | [Español](./readme_es.md) | [Italiano](./readme_it.md) | [Română](./readme_ro.md) | [Polski](./readme_pl.md) | **简体中文** | [正體中文](docs/readme/readme_zhtw.md)
Bruno 是一款全新且创新的 API 客户端,旨在颠覆 Postman 和其他类似工具。
Bruno 直接在您的电脑文件夹中存储您的 API 信息。我们使用纯文本标记语言 Bru 来保存有关 API 的信息。
您可以使用 Git 或您选择的任何版本控制系统来对您的API信息进行版本控制和协作。
您可以使用 Git 或您选择的任何版本控制系统来对您的 API 信息进行版本控制和协作。
Bruno 仅限离线使用。我们计划永不向 Bruno 添加云同步功能。我们重视您的数据隐私,并认为它应该留在您的设备上。阅读我们的长期愿景 [点击查看](https://github.com/usebruno/bruno/discussions/269)
📢 观看我们在印度 FOSS 3.0 会议上的最新演讲 [点击查看](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](../../assets/images/landing-2.png) <br /><br />
@@ -99,7 +97,7 @@ sudo apt install bruno
我很高兴您希望改进bruno。请查看 [贡献指南](../../contributing_cn.md)。
即使您无法通过代码做出贡献我们仍然欢迎您提出BUG和新的功能需求。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
### 作者

View File

@@ -10,7 +10,6 @@
[![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) | [Українська](docs/readme/readme_ua.md) | [Русский](docs/readme/readme_ru.md) | [Türkçe](docs/readme/readme_tr.md) | [Deutsch](docs/readme/readme_de.md) | **Français** | [Português (BR)](docs/readme/readme_pt_br.md) | [한국어](docs/readme/readme_kr.md) | [বাংলা](docs/readme/readme_bn.md) | [Español](docs/readme/readme_es.md) | [Italiano](docs/readme/readme_it.md) | [Română](docs/readme/readme_ro.md) | [Polski](docs/readme/readme_pl.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md)
Bruno est un nouveau client API, innovant, qui a pour but de révolutionner le _statu quo_ que représente Postman et les autres outils.
@@ -21,9 +20,7 @@ Vous pouvez utiliser git ou tout autre gestionnaire de version pour travailler d
Bruno ne fonctionne qu'en mode déconnecté. Il n'y a pas d'abonnement ou de synchronisation avec le cloud Bruno, il n'y en aura jamais. Nous sommes conscients de la confidentialité de vos données et nous sommes convaincus qu'elles doivent rester sur vos appareils. Vous pouvez lire notre vision à long terme [ici (en anglais)](https://github.com/usebruno/bruno/discussions/269).
📢 Regarder notre présentation récente lors de la conférence India FOSS 3.0 (en anglais) [ici](https://www.youtube.com/watch?v=7bSMFpbcPiY)
📢 Regarder notre présentation récente lors de la conférence India FOSS 3.0 (en anglais) [ici](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](/assets/images/landing-2.png) <br /><br />
@@ -31,7 +28,7 @@ Bruno ne fonctionne qu'en mode déconnecté. Il n'y a pas d'abonnement ou de syn
Bruno est disponible au téléchargement [sur notre site web](https://www.usebruno.com/downloads), pour Mac, Windows et Linux.
Vous pouvez aussi installer Bruno via un gestionnaire de paquets, comme Homebrew, Chocolatey, Scoop, Snap et Apt.
Vous pouvez aussi installer Bruno via un gestionnaire de paquets, comme Homebrew, Chocolatey, Scoop, Snap et Apt.
```sh
# Mac via Homebrew

View File

@@ -10,7 +10,6 @@
[![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_ua.md) | **Русский** | [Türkçe](/readme_tr.md) | [Deutsch](/readme_de.md) | [Français](/readme_fr.md) | [বাংলা](docs/readme/readme_bn.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md)
Bruno - новый и инновационный клиент API, направленный на революцию в установившейся ситуации, представленной Postman и подобными инструментами.

484
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,6 @@
"@fortawesome/react-fontawesome": "^0.1.16",
"@reduxjs/toolkit": "^1.8.0",
"@tabler/icons": "^1.46.0",
"@tailwindcss/forms": "^0.5.7",
"@tippyjs/react": "^4.2.6",
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",

View File

@@ -232,7 +232,7 @@ export default class CodeEditor extends React.Component {
let curWord = start != end && currentLine.slice(start, end);
//Qualify if autocomplete will be shown
if (
/^(?!Shift|Tab|Enter|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|\s)\w*/.test(event.key) &&
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|\s)\w*/.test(event.key) &&
curWord.length > 0 &&
!/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
/(?<!\d)[a-zA-Z\._]$/.test(curWord)

View File

@@ -9,7 +9,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.yellow};
}
input {
.non-passphrase-input {
width: 300px;
}

View File

@@ -3,6 +3,8 @@ import { IconCertificate, IconTrash, IconWorld } from '@tabler/icons';
import { useFormik } from 'formik';
import { uuid } from 'utils/common';
import * as Yup from 'yup';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
@@ -29,6 +31,8 @@ const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => {
formik.values[e.name] = e.files[0].path;
};
const [passwordVisible, setPasswordVisible] = useState(false);
return (
<StyledWrapper className="w-full h-full">
<div className="text-xs mb-4 text-muted">Add client certificates to be used for specific domains.</div>
@@ -63,7 +67,7 @@ const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => {
type="text"
name="domain"
placeholder="*.example.org"
className="block textbox"
className="block textbox non-passphrase-input"
onChange={formik.handleChange}
value={formik.values.domain || ''}
/>
@@ -79,7 +83,7 @@ const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => {
id="certFilePath"
type="file"
name="certFilePath"
className="block"
className="block non-passphrase-input"
onChange={(e) => getFile(e.target)}
/>
{formik.touched.certFilePath && formik.errors.certFilePath ? (
@@ -94,7 +98,7 @@ const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => {
id="keyFilePath"
type="file"
name="keyFilePath"
className="block"
className="block non-passphrase-input"
onChange={(e) => getFile(e.target)}
/>
{formik.touched.keyFilePath && formik.errors.keyFilePath ? (
@@ -105,14 +109,23 @@ const ClientCertSettings = ({ clientCertConfig, onUpdate, onRemove }) => {
<label className="settings-label" htmlFor="passphrase">
Passphrase
</label>
<input
id="passphrase"
type="password"
name="passphrase"
className="block textbox"
onChange={formik.handleChange}
value={formik.values.passphrase || ''}
/>
<div className="textbox flex flex-row items-center w-[300px] h-[1.70rem] relative">
<input
id="passphrase"
type={passwordVisible ? 'text' : 'password'}
name="passphrase"
className="outline-none w-64 bg-transparent"
onChange={formik.handleChange}
value={formik.values.passphrase || ''}
/>
<button
type="button"
className="btn btn-sm absolute right-0 l"
onClick={() => setPasswordVisible(!passwordVisible)}
>
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
</button>
</div>
{formik.touched.passphrase && formik.errors.passphrase ? (
<div className="ml-1 text-red-500">{formik.errors.passphrase}</div>
) : null}

View File

@@ -36,7 +36,7 @@ const Info = ({ collection }) => {
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Ignored files&nbsp;:</td>
<td className="py-2 px-2 break-all">{collection.brunoConfig.ignore.map((x) => `'${x}'`).join(', ')}</td>
<td className="py-2 px-2 break-all">{collection.brunoConfig?.ignore?.map((x) => `'${x}'`).join(', ')}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Environments&nbsp;:</td>

View File

@@ -4,6 +4,8 @@ import Tooltip from 'components/Tooltip';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
const ProxySettings = ({ proxyConfig, onUpdate }) => {
const proxySchema = Yup.object({
@@ -78,6 +80,7 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
});
}
});
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
formik.setValues({
@@ -277,18 +280,27 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
<label className="settings-label" htmlFor="auth.password">
Password
</label>
<input
id="auth.password"
type="password"
name="auth.password"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
onChange={formik.handleChange}
/>
<div className="textbox flex flex-row items-center w-[13.2rem] h-[1.70rem] relative">
<input
id="auth.password"
type={passwordVisible ? 'text' : 'password'}
name="auth.password"
className="outline-none bg-transparent w-[10.5rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
onChange={formik.handleChange}
/>
<button
type="button"
className="btn btn-sm absolute right-0"
onClick={() => setPasswordVisible(!passwordVisible)}
>
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
</button>
</div>
{formik.touched.auth?.password && formik.errors.auth?.password ? (
<div className="ml-3 text-red-500">{formik.errors.auth.password}</div>
) : null}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
return createPortal(
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>,
document.body
);
};
export default ConfirmSwitchEnv;

View File

@@ -1,19 +1,19 @@
import React from 'react';
import toast from 'react-hot-toast';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { maskInputValue } from 'utils/collections';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { uuid } from 'utils/common';
import { variableNameRegex } from 'utils/common/regex';
import { maskInputValue } from 'utils/collections';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import toast from 'react-hot-toast';
const EnvironmentVariables = ({ environment, collection }) => {
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
@@ -46,11 +46,17 @@ const EnvironmentVariables = ({ environment, collection }) => {
.then(() => {
toast.success('Changes saved successfully');
formik.resetForm({ values });
setIsModified(false);
})
.catch(() => toast.error('An error occurred while saving the changes'));
}
});
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
}, [formik.dirty]);
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
if (!meta.error) {
@@ -80,6 +86,10 @@ const EnvironmentVariables = ({ environment, collection }) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables });
};
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
@@ -162,6 +172,9 @@ const EnvironmentVariables = ({ environment, collection }) => {
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
Reset
</button>
</div>
</StyledWrapper>
);

View File

@@ -5,7 +5,7 @@ import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, collection }) => {
const EnvironmentDetails = ({ environment, collection, setIsModified }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -38,7 +38,7 @@ const EnvironmentDetails = ({ environment, collection }) => {
</div>
<div>
<EnvironmentVariables key={environment.uid} environment={environment} collection={collection} />
<EnvironmentVariables environment={environment} collection={collection} setIsModified={setIsModified} />
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, forwardRef, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import { findEnvironmentInCollection } from 'utils/collections';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
@@ -7,19 +7,23 @@ import { IconDownload, IconShieldLock } from '@tabler/icons';
import ImportEnvironment from '../ImportEnvironment';
import ManageSecrets from '../ManageSecrets';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
const EnvironmentList = ({ collection }) => {
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified }) => {
const { environments } = collection;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (selectedEnvironment) {
setOriginalEnvironmentVariables(selectedEnvironment.variables);
return;
}
@@ -32,7 +36,6 @@ const EnvironmentList = ({ collection }) => {
}, [collection, environments, selectedEnvironment]);
useEffect(() => {
// check env add
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if (newEnv) {
@@ -40,23 +43,62 @@ const EnvironmentList = ({ collection }) => {
}
}
// check env delete
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [envUids, environments, prevEnvUids]);
const handleEnvironmentClick = (env) => {
if (!isModified) {
setSelectedEnvironment(env);
} else {
setSwitchEnvConfirmClose(true);
}
};
if (!selectedEnvironment) {
return null;
}
const handleCreateEnvClick = () => {
if (!isModified) {
setOpenCreateModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleSecretsClick = () => {
setOpenManageSecretsModal(true);
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
}
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment collection={collection} onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
<div>
{switchEnvConfirmClose && (
<div className="flex items-center justify-between tab-container px-1">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="environments-sidebar flex flex-col">
{environments &&
environments.length &&
@@ -64,28 +106,33 @@ const EnvironmentList = ({ collection }) => {
<div
key={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => setSelectedEnvironment(env)}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
>
<span className="break-all">{env.name}</span>
</div>
))}
<div className="btn-create-environment" onClick={() => setOpenCreateModal(true)}>
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>
</div>
<div className="mt-auto btn-import-environment">
<div className="flex items-center" onClick={() => setOpenImportModal(true)}>
<div className="flex items-center" onClick={() => handleImportClick()}>
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => setOpenManageSecretsModal(true)}>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
</div>
</div>
</div>
</div>
<EnvironmentDetails environment={selectedEnvironment} collection={collection} />
<EnvironmentDetails
environment={selectedEnvironment}
collection={collection}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
/>
</div>
</StyledWrapper>
);

View File

@@ -6,9 +6,11 @@ import StyledWrapper from './StyledWrapper';
import ImportEnvironment from './ImportEnvironment';
const EnvironmentSettings = ({ collection, onClose }) => {
const [isModified, setIsModified] = useState(false);
const { environments } = collection;
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
if (!environments || !environments.length) {
return (
@@ -48,7 +50,13 @@ const EnvironmentSettings = ({ collection, onClose }) => {
return (
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList collection={collection} />
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
/>
</Modal>
);
};

View File

@@ -0,0 +1,57 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
width: 100%;
height: fit-content;
max-height: 200px;
overflow: auto;
.CodeMirror {
background: transparent;
height: fit-content;
font-size: 14px;
line-height: 30px;
overflow: hidden;
.CodeMirror-scroll {
overflow: hidden !important;
${'' /* padding-bottom: 50px !important; */}
position: relative;
display: contents;
margin: 0px;
padding: 0px;
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler {
display: none;
}
.CodeMirror-lines {
padding: 0;
}
.CodeMirror-cursor {
height: 20px !important;
margin-top: 5px !important;
border-left: 1px solid ${(props) => props.theme.text} !important;
}
pre {
font-family: Inter, sans-serif !important;
font-weight: 400;
}
.CodeMirror-line {
color: ${(props) => props.theme.text};
padding: 0;
}
.CodeMirror-selected {
background-color: rgba(212, 125, 59, 0.3);
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,140 @@
import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
class MultiLineEditor extends Component {
constructor(props) {
super(props);
// Keep a cached version of the value, this cache will be updated when the
// editor is updated, which can later be used to protect the editor from
// unnecessary updates during the update lifecycle.
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
mode: 'brunovariables',
brunoVarInfo: {
variables: getAllVariables(this.props.collection)
},
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Alt-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Shift-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
// Tabbing disabled to make tabindex work
Tab: false,
'Shift-Tab': false
}
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay();
}
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
if (this.props.onChange) {
this.props.onChange(this.cachedValue);
}
}
};
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
// event loop.
this.ignoreChangeEvent = true;
let variables = getAllVariables(this.props.collection);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
this.addOverlay();
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
}
if (this.editorRef?.current) {
this.editorRef.current.scrollTo(0, 10000);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
this.editor.getWrapperElement().remove();
}
addOverlay = () => {
let variables = getAllVariables(this.props.collection);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
this.editor.setOption('mode', 'brunovariables');
};
render() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
}
}
export default MultiLineEditor;

View File

@@ -6,6 +6,8 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { useDispatch, useSelector } from 'react-redux';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
const ProxySettings = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -88,6 +90,8 @@ const ProxySettings = ({ close }) => {
});
};
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
formik.setValues({
enabled: preferences.proxy.enabled || false,
@@ -164,6 +168,7 @@ const ProxySettings = ({ close }) => {
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="hostname">
Hostname
@@ -240,18 +245,27 @@ const ProxySettings = ({ close }) => {
<label className="settings-label" htmlFor="auth.password">
Password
</label>
<input
id="auth.password"
type="password"
name="auth.password"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
onChange={formik.handleChange}
/>
<div className="textbox flex flex-row items-center w-[13.2rem] h-[2.25rem] relative">
<input
id="auth.password"
type={passwordVisible ? `text` : 'password'}
name="auth.password"
className="outline-none w-[10.5rem] bg-transparent"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
onChange={formik.handleChange}
/>
<button
type="button"
className="btn btn-sm absolute right-0"
onClick={() => setPasswordVisible(!passwordVisible)}
>
{passwordVisible ? <IconEyeOff size={18} strokeWidth={2} /> : <IconEye size={18} strokeWidth={2} />}
</button>
</div>
{formik.touched.auth?.password && formik.errors.auth?.password ? (
<div className="ml-3 text-red-500">{formik.errors.auth.password}</div>
) : null}

View File

@@ -41,7 +41,9 @@ const Auth = ({ item, collection }) => {
<div>Collection level auth is: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(collectionAuth?.mode)}</div>
</div>
<div className="text-sm opacity-50">Cannot inherit Oauth2 from collection.</div>
<div className="text-sm opacity-50">
Note: You need to use scripting to set the access token in the request headers.
</div>
</div>
) : (
<>

View File

@@ -9,7 +9,7 @@ import {
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@@ -92,7 +92,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -23,7 +23,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const query = item.draft ? get(item, 'draft.request.body.graphql.query') : get(item, 'request.body.graphql.query');
const query = item.draft
? get(item, 'draft.request.body.graphql.query', '')
: get(item, 'request.body.graphql.query', '');
const variables = item.draft
? get(item, 'draft.request.body.graphql.variables')
: get(item, 'request.body.graphql.variables');

View File

@@ -6,7 +6,7 @@ import { findEnvironmentInCollection } from 'utils/collections';
import Dropdown from '../../Dropdown';
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
const url = item.draft ? get(item, 'draft.request.url') : get(item, 'request.url');
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
const request = item.draft ? item.draft.request : item.request;

View File

@@ -1,5 +1,4 @@
import React from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -14,7 +13,7 @@ import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { get } from 'lodash';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
@@ -81,6 +80,8 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
});
};
const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
// get the length of active params, headers, asserts and vars
const params = item.draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []);
const headers = item.draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []);
@@ -99,7 +100,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('params')} role="tab" onClick={() => selectTab('params')}>
Query
Params
{activeParamsLength > 0 && <sup className="ml-1 font-medium">{activeParamsLength}</sup>}
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
@@ -136,9 +137,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
) : null}
</div>
<section
className={`flex w-full ${
['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'
}`}
className={classnames('flex w-full', {
'mt-5': !isMultipleContentTab
})}
>
{getTabPanel(focusedTab.requestPaneTab)}
</section>

View File

@@ -9,7 +9,7 @@ import {
updateMultipartFormParam,
deleteMultipartFormParam
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
@@ -121,7 +121,7 @@ const MultipartFormParams = ({ item, collection }) => {
collection={collection}
/>
) : (
<SingleLineEditor
<MultiLineEditor
onSave={onSave}
theme={storedTheme}
value={param.value}
@@ -137,6 +137,7 @@ const MultipartFormParams = ({ item, collection }) => {
)
}
onRun={handleRun}
allowNewlines={true}
collection={collection}
/>
)}

View File

@@ -1,6 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
div.title {
color: var(--color-tab-inactive);
}
table {
width: 100%;
border-collapse: collapse;

View File

@@ -1,10 +1,16 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import has from 'lodash/has';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addQueryParam, updateQueryParam, deleteQueryParam } from 'providers/ReduxStore/slices/collections';
import {
addQueryParam,
updateQueryParam,
deleteQueryParam,
updatePathParam
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -14,6 +20,7 @@ const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
const paths = item.draft ? get(item, 'draft.request.paths') : get(item, 'request.paths');
const handleAddParam = () => {
dispatch(
@@ -26,24 +33,39 @@ const QueryParams = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleParamChange = (e, _param, type) => {
const param = cloneDeep(_param);
const handleValueChange = (data, type, value) => {
const _data = cloneDeep(data);
if (!has(_data, type)) {
return;
}
_data[type] = value;
return _data;
};
const handleParamChange = (e, data, type) => {
let value;
switch (type) {
case 'name': {
param.name = e.target.value;
value = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
value = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
value = e.target.checked;
break;
}
}
const param = handleValueChange(data, type, value);
dispatch(
updateQueryParam({
param,
@@ -53,6 +75,20 @@ const QueryParams = ({ item, collection }) => {
);
};
const handlePathChange = (e, data) => {
let value = e.target.value;
const path = handleValueChange(data, 'value', value);
dispatch(
updatePathParam({
path,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const handleRemoveParam = (param) => {
dispatch(
deleteQueryParam({
@@ -64,75 +100,128 @@ const QueryParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
/>
</td>
<td>
<SingleLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
<td></td>
</tr>
</thead>
<tbody>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid}>
<td>
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
/>
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddParam}>
+&nbsp;<span>Add Param</span>
</button>
</td>
<td>
<SingleLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handleParamChange(
{
target: {
value: newValue
}
},
param,
'value'
)
}
onRun={handleRun}
collection={collection}
/>
</td>
<td>
<div className="flex items-center">
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>
</td>
</tr>
);
})
: null}
</tbody>
</table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddParam}>
+&nbsp;<span>Add Param</span>
</button>
<div className="mb-1 title text-xs">Path</div>
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{paths && paths.length
? paths.map((path, index) => {
return (
<tr key={path.uid}>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={path.name}
className="mousetrap"
readOnly={true}
/>
</td>
<td>
<SingleLineEditor
value={path.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) =>
handlePathChange(
{
target: {
value: newValue
}
},
path
)
}
onRun={handleRun}
collection={collection}
/>
</td>
</tr>
);
})
: null}
</tbody>
</table>
</div>
</StyledWrapper>
);
};

View File

@@ -14,7 +14,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const { theme, storedTheme } = useTheme();
const dispatch = useDispatch();
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
const url = item.draft ? get(item, 'draft.request.url') : get(item, 'request.url');
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';

View File

@@ -115,6 +115,7 @@ const RequestHeaders = ({ item, collection }) => {
)
}
onRun={handleRun}
allowNewlines={true}
collection={collection}
/>
</td>

View File

@@ -3,7 +3,7 @@ import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url/index';
import get from 'lodash/get';
import { get, find } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
// Todo: Fix this
@@ -27,6 +27,53 @@ const interpolateUrl = ({ url, envVars, collectionVariables, processEnvVars }) =
});
};
const joinPathUrl = (url, paths) => {
let uri = url.slice();
if (uri.indexOf('http') === -1 || uri.indexOf('https') === -1) {
let [base, query = ''] = uri.split('?');
let URL_SEPARATOR;
uri = base.split('/').reduce((acc, path, index) => {
if (index !== 0) {
URL_SEPARATOR = '/';
}
if (path.charAt(0) !== ':') {
acc += URL_SEPARATOR + path;
} else {
path = path.slice(1, path.length);
const data = find(paths, (v) => v.name === path);
if (data) {
acc += URL_SEPARATOR + data.value;
}
}
return acc;
}, '');
return uri + query;
}
uri = new URL(uri);
let uriPaths = url.pathname.split('/');
uriPaths = uriPaths.reduce((acc, path) => {
if (path !== '') {
if (path[0] !== ':') {
acc += '/' + path;
} else {
let name = path.slice(1, path.length);
if (name) {
let existingPath = find(paths, (path) => path.name === name);
if (existingPath) {
acc += '/' + existingPath.value;
}
}
}
}
return acc;
}, '');
return uri.origin + uriPaths + uri.search;
};
const languages = [
{
name: 'HTTP',
@@ -76,7 +123,10 @@ const languages = [
];
const GenerateCodeItem = ({ collection, item, onClose }) => {
const url = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
const url = joinPathUrl(
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url'),
get(item, 'draft.request.paths') !== undefined ? get(item, 'draft.request.paths') : get(item, 'request.paths')
);
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let envVars = {};
if (environment) {

View File

@@ -79,7 +79,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</div>
<div className="flex justify-start w-full mt-4 max-w-[450px]">
{Object.entries(options || {}).map(([key, option]) => (
<div className="relative flex items-start">
<div key={key} className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id="comments"

View File

@@ -129,7 +129,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.13.0</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.17.0</div>
</div>
</div>
</div>

View File

@@ -13,20 +13,15 @@ const StyledWrapper = styled.div`
line-height: 30px;
overflow: hidden;
.CodeMirror-vscrollbar {
display: none !important;
}
.CodeMirror-scroll {
overflow: hidden !important;
padding-bottom: 50px !important;
}
.CodeMirror-hscrollbar {
display: none !important;
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler {
display: none !important;
display: none;
}
.CodeMirror-lines {
@@ -46,8 +41,7 @@ const StyledWrapper = styled.div`
.CodeMirror-line {
color: ${(props) => props.theme.text};
padding-left: 0;
padding-right: 0;
padding: 0;
}
.CodeMirror-selected {

View File

@@ -9,40 +9,6 @@ const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODE
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
CodeMirror.registerHelper('hint', 'anyword', (editor, options) => {
const word = /[\w$-]+/;
const wordlist = (options && options.autocomplete) || [];
let cur = editor.getCursor(),
curLine = editor.getLine(cur.line);
let end = cur.ch,
start = end;
while (start && word.test(curLine.charAt(start - 1))) --start;
let curWord = start != end && curLine.slice(start, end);
// Check if curWord is a valid string before proceeding
if (typeof curWord !== 'string' || curWord.length < 3) {
return null; // Abort the hint
}
const list = (options && options.list) || [];
const re = new RegExp(word.source, 'g');
for (let dir = -1; dir <= 1; dir += 2) {
let line = cur.line,
endLine = Math.min(Math.max(line + dir * 500, editor.firstLine()), editor.lastLine()) + dir;
for (; line != endLine; line += dir) {
let text = editor.getLine(line),
m;
while ((m = re.exec(text))) {
if (line == cur.line && curWord.length < 3) continue;
list.push(...wordlist.filter((el) => el.toLowerCase().startsWith(curWord.toLowerCase())));
}
}
}
return { list: [...new Set(list)], from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) };
});
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
}
class SingleLineEditor extends Component {

View File

@@ -41,6 +41,7 @@ if (!SERVER_RENDERED) {
require('utils/codemirror/brunoVarInfo');
require('utils/codemirror/javascript-lint');
require('utils/codemirror/autocomplete');
}
export default function Main() {

View File

@@ -1,5 +1,7 @@
import React from 'react';
import Bruno from 'components/Bruno/index';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
@@ -14,29 +16,61 @@ class ErrorBoundary extends React.Component {
}
componentDidCatch(error, errorInfo) {
console.log({ error, errorInfo });
this.setState({ hasError: true, error, errorInfo });
}
returnToApp() {
const { ipcRenderer } = window;
ipcRenderer.invoke('open-file');
this.setState({ hasError: false, error: null, errorInfo: null });
}
forceQuit() {
const { ipcRenderer } = window;
ipcRenderer.invoke('main:force-quit');
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center p-10">
<div className="bg-white rounded-lg shadow-lg p-4 w-full">
<div className="flex text-center justify-center p-20 h-full">
<div className="bg-white rounded-lg p-10 w-full">
<div className="m-auto" style={{ width: '256px' }}>
<Bruno width={256} />
</div>
<h1 className="text-2xl font-semibold text-red-600 mb-2">Oops! Something went wrong</h1>
<p className="text-red-600 mb-2">{this.state.error && this.state.error.toString()}</p>
{this.state.error && this.state.error.stack && (
<pre className="bg-gray-100 p-2 rounded-lg overflow-auto">{this.state.error.stack}</pre>
)}
<p className="text-red-500 mb-2">
If you are using an official production build: the above error is most likely a bug!
<br />
Please report this under:
<a
className="text-link hover:underline cursor-pointer ml-2"
href="https://github.com/usebruno/bruno/issues"
target="_blank"
>
https://github.com/usebruno/bruno/issues
</a>
</p>
<button
className="bg-red-500 text-white px-4 py-2 mt-4 rounded hover:bg-red-600 transition"
onClick={() => {
this.setState({ hasError: false, error: null });
}}
onClick={() => this.returnToApp()}
>
Close
Return to App
</button>
<div className="text-red-500 mt-3">
<a href="" className="hover:underline cursor-pointer" onClick={this.forceQuit}>
Force Quit
</a>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
version: '1.13.0'
version: '1.17.0'
}
});
};

View File

@@ -1,29 +1,29 @@
import { createSlice } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep';
import concat from 'lodash/concat';
import each from 'lodash/each';
import filter from 'lodash/filter';
import { uuid } from 'utils/common';
import find from 'lodash/find';
import forOwn from 'lodash/forOwn';
import get from 'lodash/get';
import map from 'lodash/map';
import forOwn from 'lodash/forOwn';
import concat from 'lodash/concat';
import filter from 'lodash/filter';
import each from 'lodash/each';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import set from 'lodash/set';
import { createSlice } from '@reduxjs/toolkit';
import {
findCollectionByUid,
findCollectionByPathname,
findItemInCollection,
findEnvironmentInCollection,
findItemInCollectionByPathname,
addDepth,
areItemsTheSameExceptSeqUpdate,
collapseCollection,
deleteItemInCollection,
deleteItemInCollectionByPathname,
findCollectionByPathname,
findCollectionByUid,
findEnvironmentInCollection,
findItemInCollection,
findItemInCollectionByPathname,
isItemARequest
isItemARequest,
areItemsTheSameExceptSeqUpdate
} from 'utils/collections';
import { uuid } from 'utils/common';
import { PATH_SEPARATOR, getDirectoryName, getSubdirectoriesFromRoot } from 'utils/common/platform';
import { parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
import { parseQueryParams, stringifyQueryParams, splitOnFirst, parsePathParams } from 'utils/url';
import { getSubdirectoriesFromRoot, getDirectoryName, PATH_SEPARATOR } from 'utils/common/platform';
const initialState = {
collections: [],
@@ -307,6 +307,7 @@ export const collectionsSlice = createSlice({
url: action.payload.requestUrl,
method: action.payload.requestMethod,
params,
paths: [],
headers: [],
body: {
mode: null,
@@ -351,8 +352,11 @@ export const collectionsSlice = createSlice({
const parts = splitOnFirst(item.draft.request.url, '?');
const urlParams = parseQueryParams(parts[1]);
const urlPaths = parsePathParams(parts[0]);
const disabledParams = filter(item.draft.request.params, (p) => !p.enabled);
let enabledParams = filter(item.draft.request.params, (p) => p.enabled);
let oldPaths = cloneDeep(item.draft.request.paths);
let newPaths = [];
// try and connect as much as old params uid's as possible
each(urlParams, (urlParam) => {
@@ -366,10 +370,29 @@ export const collectionsSlice = createSlice({
}
});
// filter the newest path param and compare with previous data that already inserted
newPaths = filter(urlPaths, (urlPath) => {
const existingPath = find(oldPaths, (p) => p.name === urlPath.name);
if (existingPath) {
return false;
}
urlPath.uid = uuid();
urlPath.enabled = true;
return true;
});
// remove path param that not used or deleted when typing url
oldPaths = filter(oldPaths, (urlPath) => {
return find(urlPaths, (p) => p.name === urlPath.name);
});
// ultimately params get replaced with params in url + the disabled ones that existed prior
// the query params are the source of truth, the url in the queryurl input gets constructed using these params
// we however are also storing the full url (with params) in the url itself
item.draft.request.params = concat(urlParams, disabledParams);
// join both old and new path param to preserve consistency between url and data
item.draft.request.paths = concat(newPaths, oldPaths);
}
}
},
@@ -495,6 +518,24 @@ export const collectionsSlice = createSlice({
}
}
},
updatePathParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
const path = find(item.draft.request.paths, (h) => h.uid === action.payload.path.uid);
if (path) {
path.name = action.payload.path.name;
path.value = action.payload.path.value;
}
}
}
},
addRequestHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1418,6 +1459,7 @@ export const {
addQueryParam,
updateQueryParam,
deleteQueryParam,
updatePathParam,
addRequestHeader,
updateRequestHeader,
deleteRequestHeader,

View File

@@ -0,0 +1,40 @@
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
CodeMirror.registerHelper('hint', 'anyword', (editor, options) => {
const word = /[\w$-]+/;
const wordlist = (options && options.autocomplete) || [];
let cur = editor.getCursor(),
curLine = editor.getLine(cur.line);
let end = cur.ch,
start = end;
while (start && word.test(curLine.charAt(start - 1))) --start;
let curWord = start != end && curLine.slice(start, end);
// Check if curWord is a valid string before proceeding
if (typeof curWord !== 'string' || curWord.length < 3) {
return null; // Abort the hint
}
const list = (options && options.list) || [];
const re = new RegExp(word.source, 'g');
for (let dir = -1; dir <= 1; dir += 2) {
let line = cur.line,
endLine = Math.min(Math.max(line + dir * 500, editor.firstLine()), editor.lastLine()) + dir;
for (; line != endLine; line += dir) {
let text = editor.getLine(line),
m;
while ((m = re.exec(text))) {
if (line == cur.line && curWord.length < 3) continue;
list.push(...wordlist.filter((el) => el.toLowerCase().startsWith(curWord.toLowerCase())));
}
}
}
return { list: [...new Set(list)], from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) };
});
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
}

View File

@@ -9,6 +9,7 @@ export const deleteUidsInItems = (items) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
each(get(item, 'request.headers'), (header) => delete header.uid);
each(get(item, 'request.params'), (param) => delete param.uid);
each(get(item, 'request.paths'), (path) => delete path.uid);
each(get(item, 'request.vars.req'), (v) => delete v.uid);
each(get(item, 'request.vars.res'), (v) => delete v.uid);
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);

View File

@@ -240,6 +240,17 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
const copyPathParams = (paths) => {
return map(paths, (path) => {
return {
uid: path.uid,
name: path.name,
value: path.value,
enabled: path.enabled
};
});
};
const copyFormUrlEncodedParams = (params = []) => {
return map(params, (param) => {
return {
@@ -285,6 +296,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
method: si.draft.request.method,
headers: copyHeaders(si.draft.request.headers),
params: copyQueryParams(si.draft.request.params),
paths: copyPathParams(si.draft.request.paths),
body: {
mode: si.draft.request.body.mode,
json: si.draft.request.body.json,
@@ -318,6 +330,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
method: si.request.method,
headers: copyHeaders(si.request.headers),
params: copyQueryParams(si.request.params),
paths: copyPathParams(si.request.paths),
body: {
mode: si.request.body.mode,
json: si.request.body.json,
@@ -385,6 +398,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
method: _item.request.method,
url: _item.request.url,
params: [],
paths: [],
headers: [],
auth: _item.request.auth,
body: _item.request.body,
@@ -406,6 +420,14 @@ export const transformRequestToSaveToFilesystem = (item) => {
});
});
each(_item.request.paths, (path) => {
itemToSave.request.paths.push({
uid: path.uid,
name: path.name,
value: path.value
});
});
each(_item.request.headers, (header) => {
itemToSave.request.headers.push({
uid: header.uid,
@@ -417,7 +439,10 @@ export const transformRequestToSaveToFilesystem = (item) => {
});
if (itemToSave.request.body.mode === 'json') {
itemToSave.request.body.json = replaceTabsWithSpaces(itemToSave.request.body.json);
itemToSave.request.body = {
...itemToSave.request.body,
json: replaceTabsWithSpaces(itemToSave.request.body.json)
};
}
return itemToSave;
@@ -543,6 +568,7 @@ export const refreshUidsInItem = (item) => {
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.paths'), (path) => (path.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
@@ -553,11 +579,13 @@ export const deleteUidsInItem = (item) => {
delete item.uid;
const params = get(item, 'request.params', []);
const headers = get(item, 'request.headers', []);
const paths = get(item, 'request.paths', []);
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
params.forEach((param) => delete param.uid);
headers.forEach((header) => delete header.uid);
paths.forEach((path) => delete path.uid);
bodyFormUrlEncoded.forEach((param) => delete param.uid);
bodyMultipartForm.forEach((param) => delete param.uid);

View File

@@ -31,6 +31,7 @@ export const updateUidsInCollection = (_collection) => {
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.query'), (param) => (param.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.paths'), (path) => (path.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));

View File

@@ -2,6 +2,7 @@ import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim';
import each from 'lodash/each';
import filter from 'lodash/filter';
import find from 'lodash/find';
const hasLength = (str) => {
if (!str || !str.length) {
@@ -26,6 +27,41 @@ export const parseQueryParams = (query) => {
return filter(params, (p) => hasLength(p.name));
};
export const parsePathParams = (url) => {
let uri = url.slice();
if (!uri || !uri.length) {
return [];
}
if (uri.indexOf('http://') === -1 || uri.indexOf('https://') === -1) {
uri = `http://${uri}`;
}
if (!isValidUrl(uri)) {
throw 'Invalid URL format';
}
uri = new URL(uri);
let paths = uri.pathname.split('/');
paths = paths.reduce((acc, path) => {
if (path !== '' && path[0] === ':') {
let name = path.slice(1, path.length);
if (name) {
let isExist = find(acc, (path) => path.name === name);
if (!isExist) {
acc.push({ name: path.slice(1, path.length), value: '' });
}
}
}
return acc;
}, []);
return paths;
};
export const stringifyQueryParams = (params) => {
if (!params || isEmpty(params)) {
return '';

View File

@@ -1,4 +1,4 @@
import { parseQueryParams, splitOnFirst } from './index';
import { parseQueryParams, splitOnFirst, parsePathParams } from './index';
describe('Url Utils - parseQueryParams', () => {
it('should parse query - case 1', () => {
@@ -51,6 +51,51 @@ describe('Url Utils - parseQueryParams', () => {
});
});
describe('Url Utils - parsePathParams', () => {
it('should parse path - case 1', () => {
const params = parsePathParams('www.example.com');
expect(params).toEqual([]);
});
it('should parse path - case 2', () => {
const params = parsePathParams('http://www.example.com');
expect(params).toEqual([]);
});
it('should parse path - case 3', () => {
const params = parsePathParams('https://www.example.com');
expect(params).toEqual([]);
});
it('should parse path - case 4', () => {
const params = parsePathParams('https://www.example.com/users/:id');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 5', () => {
const params = parsePathParams('https://www.example.com/users/:id/');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 6', () => {
const params = parsePathParams('https://www.example.com/users/:id/:');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 7', () => {
const params = parsePathParams('https://www.example.com/users/:id/posts/:id');
expect(params).toEqual([{ name: 'id', value: '' }]);
});
it('should parse path - case 8', () => {
const params = parsePathParams('https://www.example.com/users/:id/posts/:postId');
expect(params).toEqual([
{ name: 'id', value: '' },
{ name: 'postId', value: '' }
]);
});
});
describe('Url Utils - splitOnFirst', () => {
it('should split on first - case 1', () => {
const params = splitOnFirst('a', '=');

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/cli",
"version": "1.11.0",
"version": "1.16.0",
"license": "MIT",
"main": "src/index.js",
"bin": {
@@ -26,8 +26,8 @@
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.11.0",
"@usebruno/lang": "0.11.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
"chai": "^4.3.7",

View File

@@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash');
const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -86,6 +86,29 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
param.value = _interpolate(param.value);
});
if (request.paths.length) {
let url = new URL(request.url);
let urlPaths = url.pathname.split('/');
urlPaths = urlPaths.reduce((acc, path) => {
if (path !== '') {
if (path[0] !== ':') {
acc += '/' + path;
} else {
let name = path.slice(1, path.length);
if (name) {
let existingPath = find(request.paths, (path) => path.name === name);
if (existingPath) {
acc += '/' + interpolate(existingPath.value);
}
}
}
}
return acc;
}, '');
request.url = url.origin + urlPaths + url.search;
}
if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname);

View File

@@ -29,22 +29,12 @@ const prepareRequest = (request, collectionRoot) => {
let axiosRequest = {
method: request.method,
url: request.url,
headers: headers
headers: headers,
paths: request.paths
};
/**
* 27 Feb 2024:
* ['inherit', 'none'].includes(request.auth.mode)
* We are mainitaining the old behavior where 'none' used to inherit the collection auth.
*
* Very soon, 'none' will be treated as no auth and 'inherit' will be the only way to inherit collection auth.
* We will request users to update their collection files to use 'inherit' instead of 'none'.
* Don't want to break ongoing CI pipelines.
*
* Hoping to remove this by 1 April 2024.
*/
const collectionAuth = get(collectionRoot, 'request.auth');
if (collectionAuth && ['inherit', 'none'].includes(request.auth.mode)) {
if (collectionAuth && request.auth.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.auth = {
username: get(collectionAuth, 'basic.username'),

View File

@@ -14,6 +14,7 @@ const collectionBruToJson = (bru) => {
const transformedJson = {
request: {
params: _.get(json, 'query', []),
paths: _.get(json, 'path', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
@@ -61,6 +62,7 @@ const bruToJson = (bru) => {
url: _.get(json, 'http.url'),
auth: _.get(json, 'auth', {}),
params: _.get(json, 'query', []),
paths: _.get(json, 'path', []),
headers: _.get(json, 'headers', []),
body: _.get(json, 'body', {}),
vars: _.get(json, 'vars', []),

View File

@@ -1,5 +1,5 @@
{
"version": "v1.13.0",
"version": "v1.17.0",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
@@ -21,8 +21,8 @@
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.11.0",
"@usebruno/lang": "0.11.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/schema": "0.7.0",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",

View File

@@ -48,6 +48,7 @@ const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
const params = _.get(request, 'request.params', []);
const paths = _.get(request, 'request.paths', []);
const headers = _.get(request, 'request.headers', []);
const requestVars = _.get(request, 'request.vars.req', []);
const responseVars = _.get(request, 'request.vars.res', []);
@@ -56,6 +57,7 @@ const hydrateRequestWithUuid = (request, pathname) => {
const bodyMultipartForm = _.get(request, 'request.body.multipartForm', []);
params.forEach((param) => (param.uid = uuid()));
paths.forEach((path) => (path.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
requestVars.forEach((variable) => (variable.uid = uuid()));
responseVars.forEach((variable) => (variable.uid = uuid()));
@@ -68,11 +70,13 @@ const hydrateRequestWithUuid = (request, pathname) => {
const hydrateBruCollectionFileWithUuid = (collectionRoot) => {
const params = _.get(collectionRoot, 'request.params', []);
const paths = _.get(collectionRoot, 'request.paths', []);
const headers = _.get(collectionRoot, 'request.headers', []);
const requestVars = _.get(collectionRoot, 'request.vars.req', []);
const responseVars = _.get(collectionRoot, 'request.vars.res', []);
params.forEach((param) => (param.uid = uuid()));
paths.forEach((path) => (path.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
requestVars.forEach((variable) => (variable.uid = uuid()));
responseVars.forEach((variable) => (variable.uid = uuid()));

View File

@@ -15,6 +15,7 @@ const collectionBruToJson = (bru) => {
const transformedJson = {
request: {
params: _.get(json, 'query', []),
paths: _.get(json, 'path', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
@@ -34,6 +35,7 @@ const jsonToCollectionBru = (json) => {
try {
const collectionBruJson = {
query: _.get(json, 'request.params', []),
path: _.get(json, 'request.paths', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
script: {
@@ -112,6 +114,7 @@ const bruToJson = (bru) => {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
params: _.get(json, 'query', []),
paths: _.get(json, 'path', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
@@ -163,6 +166,7 @@ const jsonToBru = (json) => {
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
path: _.get(json, 'request.paths', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),

View File

@@ -628,6 +628,10 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
ipcMain.handle('main:complete-quit-flow', () => {
mainWindow.destroy();
});
ipcMain.handle('main:force-quit', () => {
process.exit();
});
};
const registerCollectionsIpc = (mainWindow, watcher, lastOpenedCollections) => {

View File

@@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep } = require('lodash');
const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -86,6 +86,29 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
param.value = _interpolate(param.value);
});
if (request.paths.length) {
let url = new URL(request.url);
let urlPaths = url.pathname.split('/');
urlPaths = urlPaths.reduce((acc, path) => {
if (path !== '') {
if (path[0] !== ':') {
acc += '/' + path;
} else {
let name = path.slice(1, path.length);
if (name) {
let existingPath = find(request.paths, (path) => path.name === name);
if (existingPath) {
acc += '/' + interpolate(existingPath.value);
}
}
}
}
return acc;
}, '');
request.url = url.origin + urlPaths + url.search;
}
if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname);

View File

@@ -30,20 +30,9 @@ const parseFormData = (datas, collectionPath) => {
return form;
};
/**
* 27 Feb 2024:
* ['inherit', 'none'].includes(request.auth.mode)
* We are mainitaining the old behavior where 'none' used to inherit the collection auth.
*
* Very soon, 'none' will be treated as no auth and 'inherit' will be the only way to inherit collection auth.
* We will request users to update their collection files to use 'inherit' instead of 'none'.
* Don't want to break ongoing CI pipelines.
*
* Hoping to remove this by 1 April 2024.
*/
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
const collectionAuth = get(collectionRoot, 'request.auth');
if (collectionAuth && ['inherit', 'none'].includes(request.auth.mode)) {
if (collectionAuth && request.auth.mode === 'inherit') {
switch (collectionAuth.mode) {
case 'awsv4':
axiosRequest.awsv4config = {
@@ -172,6 +161,7 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
method: request.method,
url,
headers,
paths: request.paths,
responseType: 'arraybuffer'
};

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/js",
"version": "0.11.0",
"version": "0.12.0",
"license": "MIT",
"main": "src/index.js",
"files": [

View File

@@ -1,6 +1,6 @@
{
"name": "@usebruno/lang",
"version": "0.11.0",
"version": "0.12.0",
"license": "MIT",
"main": "src/index.js",
"files": [

View File

@@ -22,7 +22,7 @@ const { outdentString } = require('../../v1/src/utils');
*
*/
const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
BruFile = (meta | http | query | path | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
@@ -35,12 +35,16 @@ const grammar = ohm.grammar(`Bru {
keychar = ~(tagend | st | nl | ":") any
valuechar = ~(nl | tagend) any
// Multiline text block surrounded by '''
multilinetextblockdelimiter = "'''"
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
pair = st* key st* ":" st* value st*
key = keychar*
value = valuechar*
value = multilinetextblock | valuechar*
// Dictionary for Assert Block
assertdictionary = st* "{" assertpairlist? tagend
@@ -70,6 +74,8 @@ const grammar = ohm.grammar(`Bru {
headers = "headers" dictionary
query = "query" dictionary
path = "path" dictionary
varsandassert = varsreq | varsres | assert
varsreq = "vars:pre-request" dictionary
@@ -186,6 +192,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return chars.sourceString ? chars.sourceString.trim() : '';
},
value(chars) {
try {
let isMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`);
if (isMultiline) {
const multilineString = chars.sourceString?.replace(/^'''|'''$/g, '');
return multilineString
.split('\n')
.map((line) => line.slice(4))
.join('\n');
}
return chars.sourceString ? chars.sourceString.trim() : '';
} catch (err) {
console.error(err);
}
return chars.sourceString ? chars.sourceString.trim() : '';
},
assertdictionary(_1, _2, pairlist, _3) {
@@ -307,6 +326,11 @@ const sem = grammar.createSemantics().addAttribute('ast', {
query: mapPairListToKeyValPairs(dictionary.ast)
};
},
path(_1, dictionary) {
return {
path: mapPairListToKeyValPairs(dictionary.ast)
};
},
headers(_1, dictionary) {
return {
headers: mapPairListToKeyValPairs(dictionary.ast)

View File

@@ -12,8 +12,25 @@ const stripLastLine = (text) => {
return text.replace(/(\r?\n)$/, '');
};
const getValueString = (value) => {
const hasNewLines = value.includes('\n');
if (!hasNewLines) {
return value;
}
// Add one level of indentation to the contents of the multistring
const indentedLines = value
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
// Join the lines back together with newline characters and enclose them in triple single quotes
return `'''\n${indentedLines}\n'''`;
};
const jsonToBru = (json) => {
const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json;
const { meta, http, query, path, headers, auth, body, script, tests, vars, assertions, docs } = json;
let bru = '';
@@ -66,6 +83,14 @@ const jsonToBru = (json) => {
bru += '\n}\n\n';
}
if (path && path.length) {
bru += 'path {';
bru += `\n${indentString(path.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
bru += '\n}\n\n';
}
if (headers && headers.length) {
bru += 'headers {';
if (enabled(headers).length) {
@@ -202,24 +227,23 @@ ${indentString(body.sparql)}
}
if (body && body.formUrlEncoded && body.formUrlEncoded.length) {
bru += `body:form-urlencoded {`;
bru += `body:form-urlencoded {\n`;
if (enabled(body.formUrlEncoded).length) {
bru += `\n${indentString(
enabled(body.formUrlEncoded)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
const enabledValues = enabled(body.formUrlEncoded)
.map((item) => `${item.name}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(enabledValues)}\n`;
}
if (disabled(body.formUrlEncoded).length) {
bru += `\n${indentString(
disabled(body.formUrlEncoded)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
const disabledValues = disabled(body.formUrlEncoded)
.map((item) => `~${item.name}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(disabledValues)}\n`;
}
bru += '\n}\n\n';
bru += '}\n\n';
}
if (body && body.multipartForm && body.multipartForm.length) {

View File

@@ -193,6 +193,7 @@ const requestSchema = Yup.object({
method: requestMethodSchema,
headers: Yup.array().of(keyValueSchema).required('headers are required'),
params: Yup.array().of(keyValueSchema).required('params are required'),
paths: Yup.array().of(keyValueSchema).required('paths are required'),
auth: authSchema,
body: requestBodySchema,
script: Yup.object({

View File

@@ -59,6 +59,7 @@ describe('Collection Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}
@@ -116,6 +117,7 @@ describe('Collection Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}

View File

@@ -9,6 +9,7 @@ describe('Request Schema Validation', () => {
method: 'GET',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}
@@ -24,6 +25,7 @@ describe('Request Schema Validation', () => {
method: 'GET-junk',
headers: [],
params: [],
paths: [],
body: {
mode: 'none'
}

View File

@@ -0,0 +1,139 @@
meta {
name: supermetrics json visualize
type: http
seq: 5
}
post {
url: https://testbench-sanity.usebruno.com/api/echo/json
body: json
auth: none
}
headers {
foo: bar
}
auth:basic {
username: asd
password: j
}
auth:bearer {
token:
}
body:json {
{
"notes": {
"runtime_sec": 5,
"result_rows": 4,
"query_count": 6
},
"fields": [
{
"name": "Count",
"type": "number",
"data_type": "number"
},
{
"name": "Name",
"type": "string",
"data_type": "string"
},
{
"name": "Age",
"type": "number",
"data_type": "number"
},
{
"name": "Email",
"type": "email",
"data_type": "string"
},
{
"name": "Place",
"type": "string",
"data_type": "string"
}
],
"data": [
{
"id": null,
"name": "Name",
"age": "Age",
"email": "Email",
"city": "City"
},
{
"id": 1,
"name": "John Doe",
"age": 30,
"email": "john@example.com",
"city": "New York"
},
{
"id": 2,
"name": "Jane Smith",
"age": 25,
"email": "jane@example.com",
"city": "Los Angeles"
},
{
"id": 3,
"name": "Bob Johnson",
"age": 35,
"email": "bob@example.com",
"city": "Chicago"
},
{
"id": 4,
"name": "Alice Williams",
"age": 28,
"email": "alice@example.com",
"city": "San Francisco"
}
]
}
}
assert {
res.status: eq 200
}
script:pre-request {
const { URL } = require('url');
const reqUrl = new URL(req.url);
reqUrl.searchParams.delete('returnQueryIDafterSeconds');
reqUrl.searchParams.delete('separateFetchQuery');
reqUrl.searchParams.delete('pretty');
reqUrl.searchParams.delete('displayLog');
reqUrl.searchParams.delete('triggerID');
reqUrl.searchParams.delete('hashForQIDFDB');
reqUrl.searchParams.delete('queryType');
reqUrl.searchParams.delete('queryCount');
reqUrl.searchParams.append("cacheBuster", new Date().toISOString());
req.setUrl(reqUrl.href);
}
script:post-response {
const visualizeNotes = require("./lib/notes");
bru.visualize(visualizeNotes(res));
}
tests {
test("should return json", function() {
const data = res.getBody();
expect(res.getBody()).to.eql({
"hello": "bruno"
});
});
}

View File

@@ -0,0 +1,180 @@
const visualizeNotes = (res) => {
let response = res.body;
let notes = response?.notes || {};
let responseRuntime = notes.runtime_sec || 0;
notes.runtime = new Date(responseRuntime * 1000).toISOString().substr(11, 8);
if (typeof response?.data === 'undefined' && typeof response?.rows === 'object') {
response.data = response?.rows?.map(function (data) {
return data?.values;
});
}
const templateScript = `
<script id="template" type="text/x-handlebars-template">
{{#if response.data}}
<div>
<p>Total rows: {{notes.result_rows}}</p>
<p>Query count: {{notes.query_count}}</p>
<p>Duration: {{notes.runtime}}</p>
</div>
<table id="data_table">
{{#each response.data}}
{{#if @first}}
<tr>
{{#each this}}
<th>
{{#with (lookup ../../response.fields @index)~}}
<small>
{{name}} ({{type}})<br>
{{data_type}}<br>
</small>
{{/with}}
{{this}}
</th>
{{/each}}
</tr>
{{else}}
<tr id="row_{{@key}}" class="data_row">
{{#each this}}
<td>{{this}}</td>
{{/each}}
</tr>
{{/if}}
{{/each}}
</table>
{{else if response.results}}
<table id="data_table">
<tr>
{{#each response.results.[0]}}
<th>{{@key}}</th>
{{/each}}
</tr>
{{#each response.results}}
<tr id="row_{{@key}}" class="data_row">
{{#each this}}
<td>{{this}}</td>
{{/each}}
</tr>
{{/each}}
</table>
{{else}}
<div class="error">
<h1>Error</h1>
{{#if response.notes}}
{{response.notes.error}}
{{else}}
No response
{{/if}}
</div>
{{/if}}
</script>
`;
const mainScript = `
<script>
document.addEventListener("DOMContentLoaded", function() {
let data = ${JSON.stringify({
response,
notes
})}
let source = document.getElementById("template").innerHTML;
let template = Handlebars.compile(source);
document.body.innerHTML = template(data);
document.getElementById('data_table').addEventListener('click', function(e) {
var row = e.target.closest('tr.data_row');
if (row) {
row.classList.toggle('marked');
}
});
});
</script>
`;
const style = `
<style type="text/css">
div {
margin-bottom: 8px;
}
div p {
font-family: courier;
font-size: 12px;
line-height: 1.2;
color: #afafaf;
margin: 0;
}
div.error {
padding: 20px;
background-color: #ffcece;
color: #792626;
font-size: 18px;
}
div.error h1 {
color: #dd4545;
line-height: 50px;
font-size: 28px;
font-weight: bold;
text-transform: uppercase;
}
table {
background-color: #454545;
color: #dedede;
font-size: 12px;
width: 100%;
border: 1px solid #cdcdcd;
border-collapse: collapse;
}
table th, table td {
border: 1px solid #797979;
}
table th {
font-size: 14px;
font-weight: bold;
background-color: #565656;
text-align: left;
vertical-align: bottom;
}
table th, table th:first-child, table th:last-child {
padding: 4px;
}
table th small {
font-size: 10px;
color: #afafaf;
}
table tr:hover {
background-color:#505050;
}
table tr.marked:nth-child(even) {
background-color: #707070;
}
table tr.marked:nth-child(odd) {
background-color: #696969;
}
table td {
padding: 2px;
}
table td, table td:first-child, table td:last-child {
padding: 3px;
}
</style>
`;
const htmlString = `
<html>
<head>
${style}
</head>
<body>
<script src="https://rawgit.com/components/handlebars.js/master/handlebars.js"></script>
${templateScript}
${mainScript}
</body>
</html>
`;
return htmlString;
};
module.exports = visualizeNotes;

View File

@@ -0,0 +1,54 @@
meta {
name: get-env-name
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
auth:awsv4 {
accessKeyId: a
secretAccessKey: b
sessionToken: c
service: d
region: e
profileName: f
}
script:pre-request {
const envName = bru.getEnvName();
bru.setVar("testEnvName", envName);
}
tests {
test("should get env name in scripts", function() {
const testEnvName = bru.getVar("testEnvName");
expect(testEnvName).to.equal("Prod");
});
}
docs {
# API Documentation
## Introduction
Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
## Authentication
Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
### API Key
To use API key authentication, include your API key in the request headers as follows:
```http
GET /api/endpoint
Host: api.example.com
Authorization: Bearer YOUR_API_KEY
}

View File

@@ -0,0 +1,49 @@
meta {
name: get-env-var
type: http
seq: 2
}
get {
url: {{host}}/ping
body: none
auth: none
}
auth:awsv4 {
accessKeyId: a
secretAccessKey: b
sessionToken: c
service: d
region: e
profileName: f
}
tests {
test("should get env var in scripts", function() {
const host = bru.getEnvVar("host")
expect(host).to.equal("https://testbench-sanity.usebruno.com");
});
}
docs {
# API Documentation
## Introduction
Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
## Authentication
Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
### API Key
To use API key authentication, include your API key in the request headers as follows:
```http
GET /api/endpoint
Host: api.example.com
Authorization: Bearer YOUR_API_KEY
}

View File

@@ -0,0 +1,54 @@
meta {
name: set-env-var
type: http
seq: 3
}
get {
url: {{host}}/ping
body: none
auth: none
}
auth:awsv4 {
accessKeyId: a
secretAccessKey: b
sessionToken: c
service: d
region: e
profileName: f
}
script:post-response {
bru.setEnvVar("testSetEnvVar", "bruno-29653")
}
tests {
test("should set env var in scripts", function() {
const testSetEnvVar = bru.getEnvVar("testSetEnvVar")
console.log(testSetEnvVar);
expect(testSetEnvVar).to.equal("bruno-29653");
});
}
docs {
# API Documentation
## Introduction
Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
## Authentication
Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
### API Key
To use API key authentication, include your API key in the request headers as follows:
```http
GET /api/endpoint
Host: api.example.com
Authorization: Bearer YOUR_API_KEY
}

View File

@@ -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** | [Українська](docs/readme/readme_ua.md) | [Русский](docs/readme/readme_ru.md) | [Türkçe](docs/readme/readme_tr.md) | [Deutsch](docs/readme/readme_de.md) | [Français](docs/readme/readme_fr.md) | [Português (BR)](docs/readme/readme_pt_br.md) | [한국어](docs/readme/readme_kr.md) | [বাংলা](docs/readme/readme_bn.md) | [Español](docs/readme/readme_es.md) | [Italiano](docs/readme/readme_it.md) | [Română](docs/readme/readme_ro.md) | [Polski](docs/readme/readme_pl.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md)
**English** | [Українська](docs/readme/readme_ua.md) | [Русский](docs/readme/readme_ru.md) | [Türkçe](docs/readme/readme_tr.md) | [Deutsch](docs/readme/readme_de.md) | [Français](docs/readme/readme_fr.md) | [Português (BR)](docs/readme/readme_pt_br.md) | [한국어](docs/readme/readme_kr.md) | [বাংলা](docs/readme/readme_bn.md) | [Español](docs/readme/readme_es.md) | [Italiano](docs/readme/readme_it.md) | [Română](docs/readme/readme_ro.md) | [Polski](docs/readme/readme_pl.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md) | [العربية](docs/readme/readme_ar.md)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.