Merge remote-tracking branch 'origin/main' into oauth2_additional_params

This commit is contained in:
lohit-bruno
2025-08-20 20:00:58 +05:30
129 changed files with 8618 additions and 913 deletions

View File

@@ -2,4 +2,4 @@ import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});
});

View File

@@ -0,0 +1,43 @@
import { test, expect } from '../../playwright';
test('should persist cookies across app restarts', async ({ createTmpDir, launchElectronApp }) => {
// Create a temporary user-data directory so we control where the cookies store file is written.
const userDataPath = await createTmpDir('cookie-persistence');
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.waitForSelector('[data-trigger="cookies"]');
// Open Cookies modal via the status-bar button.
await page1.click('[data-trigger="cookies"]');
// When no cookies are present the modal shows a centred "Add Cookie" button.
await page1.getByRole('button', { name: /Add Cookie/i }).click();
// Fill out the form.
await page1.fill('input[name="domain"]', 'example.com');
await page1.fill('input[name="path"]', '/');
await page1.fill('input[name="key"]', 'session');
await page1.fill('input[name="value"]', 'abc123');
await page1.check('input[name="secure"]');
await page1.check('input[name="httpOnly"]');
await page1.getByRole('button', { name: 'Save' }).click();
await expect(page1.getByText('example.com')).toBeVisible();
await app1.close();
// Second launch verify the cookie was persisted and re-loaded
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
// Open the Cookies modal again.
await page2.waitForSelector('[data-trigger="cookies"]');
await page2.click('[data-trigger="cookies"]');
// The domain we added earlier should still be present.
await expect(page2.getByText('example.com')).toBeVisible();
await app2.close();
});

View File

@@ -0,0 +1,47 @@
import { test, expect } from '../../playwright';
import * as path from 'path';
import * as fs from 'fs/promises';
test('should handle corrupted passkey and still display saved cookie list', async ({ createTmpDir, launchElectronApp }) => {
const userDataPath = await createTmpDir('corrupted-passkey');
const app1 = await launchElectronApp({ userDataPath });
// 1. First run add a cookie via the UI so `cookies.json` is created.
const page1 = await app1.firstWindow();
await page1.waitForSelector('[data-trigger="cookies"]');
await page1.click('[data-trigger="cookies"]');
await page1.getByRole('button', { name: /Add Cookie/i }).click();
await page1.fill('input[name="domain"]', 'example.com');
await page1.fill('input[name="path"]', '/');
await page1.fill('input[name="key"]', 'session');
await page1.fill('input[name="value"]', 'abc123');
await page1.check('input[name="secure"]');
await page1.check('input[name="httpOnly"]');
await page1.getByRole('button', { name: 'Save' }).click();
await expect(page1.getByText('example.com')).toBeVisible();
await app1.close();
// 2. Corrupt the encryptedPasskey in cookies.json
const cookiesFilePath = path.join(userDataPath, 'cookies.json');
const raw = await fs.readFile(cookiesFilePath, 'utf-8');
const cookiesJson = JSON.parse(raw);
cookiesJson.encryptedPasskey = 'deadbeef'; // clearly invalid value
await fs.writeFile(cookiesFilePath, JSON.stringify(cookiesJson, null, 2));
// 3. Second run Bruno should recover and still list the cookie domain
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.waitForSelector('[data-trigger="cookies"]');
await page2.click('[data-trigger="cookies"]');
// The domain row should still be visible (even if cookie values are blank).
await expect(page2.getByText('example.com')).toBeVisible();
await app2.close();
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('ping', { exact: true }).click();
await page.getByText('No Environment').click();
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -0,0 +1,40 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByText('ping2', { exact: true }).click();
await page.getByText('Env', { exact: true }).click();
await page.getByText('Stage', { exact: true }).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping2', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByText('Stage').click();
await newPage
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "collection",
"type": "collection"
}

View File

@@ -0,0 +1,4 @@
vars {
host: https://testbench-sanity.usebruno.com
persistent-env-test: persistent-env-test-value
}

View File

@@ -0,0 +1,3 @@
vars {
host: https://testbench-sanity.usebruno.com
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping2
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
bru.setEnvVar("persistent-env-test", "persistent-env-test-value");
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
bru.setEnvVar("persistent-env-test", "persistent-env-test-value", { persist: true });
}

View File

@@ -0,0 +1,6 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
]
}

309
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
@@ -4490,6 +4491,37 @@
}
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
"integrity": "sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.2.5",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@headlessui/react": {
"version": "1.7.19",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz",
@@ -5274,6 +5306,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@jsep-plugin/assignment": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz",
@@ -6360,6 +6402,70 @@
"node": ">=16.9"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
@@ -7053,6 +7159,27 @@
}
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -8393,6 +8520,12 @@
"@types/node": "*"
}
},
"node_modules/@types/google-protobuf": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.15.12.tgz",
"integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==",
"license": "MIT"
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -8543,6 +8676,15 @@
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.set": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/@types/lodash.set/-/lodash.set-4.3.9.tgz",
"integrity": "sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/markdown-it": {
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
@@ -8572,7 +8714,6 @@
"version": "22.15.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
"integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -10646,6 +10787,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/builtin-modules": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz",
"integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/builtin-status-codes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
@@ -13723,15 +13877,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/emitter-component": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz",
"integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/emittery": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -15928,6 +16073,12 @@
"csstype": "^3.0.10"
}
},
"node_modules/google-protobuf": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz",
"integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -16042,6 +16193,22 @@
"node": ">= 6"
}
},
"node_modules/grpc-reflection-js": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/grpc-reflection-js/-/grpc-reflection-js-0.3.0.tgz",
"integrity": "sha512-3lhTlQluPxVgbowCXA3tAZC3RJW+GSOUkguLNYl1QffYRiutUB3RDfPkQFTcrCFJgNiIIxx+iJkr8s3uSp3zWA==",
"license": "MIT",
"dependencies": {
"@types/google-protobuf": "^3.7.2",
"@types/lodash.set": "^4.3.6",
"google-protobuf": "^3.12.2",
"lodash.set": "^4.3.2",
"protobufjs": "^7.2.2"
},
"peerDependencies": {
"@grpc/grpc-js": "^1.0.0"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -16144,7 +16311,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -19498,7 +19664,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.curry": {
@@ -19576,6 +19741,12 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
"integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==",
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -19617,6 +19788,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -22958,6 +23135,30 @@
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -25818,16 +26019,6 @@
"node": ">= 0.4"
}
},
"node_modules/stream": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz",
"integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emitter-component": "^1.1.1"
}
},
"node_modules/stream-browserify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
@@ -26932,6 +27123,22 @@
"@popperjs/core": "^2.9.0"
}
},
"node_modules/tldts": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.12.tgz",
"integrity": "sha512-M9ZQBPp6FyqhMcl233vHYyYRkxXOA1SKGlnq13S0mJdUhRSwr2w6I8rlchPL73wBwRlyIZpFvpu2VcdSMWLYXw==",
"dependencies": {
"tldts-core": "^7.0.12"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.12.tgz",
"integrity": "sha512-3K76aXywJFduGRsOYoY5JzINLs/WMlOkeDwPL+8OCPq2Rh39gkSDtWAxdJQlWjpun/xF/LHf29yqCi6VC/rHDA=="
},
"node_modules/tmp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
@@ -27261,7 +27468,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -31097,6 +31303,9 @@
"name": "@usebruno/common",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"tough-cookie": "^6.0.0"
},
"devDependencies": {
"@babel/preset-env": "^7.26.9",
"@babel/preset-typescript": "^7.27.0",
@@ -31663,6 +31872,17 @@
"dev": true,
"license": "MIT"
},
"packages/bruno-common/node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"packages/bruno-common/node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -31790,6 +32010,8 @@
"version": "2.0.0",
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.7.13",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/filestore": "^0.1.0",
@@ -31825,6 +32047,7 @@
"nanoid": "3.3.8",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",
"yup": "^0.32.11"
},
@@ -32907,6 +33130,17 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"packages/bruno-electron/node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"packages/bruno-filestore": {
"name": "@usebruno/filestore",
"version": "0.1.0",
@@ -33098,9 +33332,7 @@
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"rollup": "3.29.5",
"rollup-plugin-terser": "^7.0.2",
"stream": "^0.0.2",
"util": "^0.12.5"
"rollup-plugin-terser": "^7.0.2"
},
"peerDependencies": {
"@usebruno/vm2": "^3.9.13"
@@ -33166,17 +33398,24 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@faker-js/faker": "^9.7.0",
"@grpc/grpc-js": "^1.13.3",
"@grpc/proto-loader": "^0.7.15",
"@types/qs": "^6.9.18",
"axios": "^1.9.0"
"axios": "^1.9.0",
"grpc-reflection-js": "^0.3.0"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@rollup/plugin-alias": "^5.1.1",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"@types/jest": "^29.5.11",
"babel-jest": "^29.7.0",
"builtin-modules": "^5.0.0",
"jest": "^29.2.0",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
@@ -33185,6 +33424,22 @@
"typescript": "^4.8.4"
}
},
"packages/bruno-requests/node_modules/@faker-js/faker": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz",
"integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
},
"packages/bruno-requests/node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",

View File

@@ -22,6 +22,7 @@
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",

View File

@@ -38,6 +38,48 @@ const StyledWrapper = styled.div`
outline: none !important;
}
}
.protocol-placeholder {
height: 100%;
position: relative;
display: inline-block;
width: 60px;
overflow: hidden;
}
.protocol-https,
.protocol-grpcs {
position: absolute;
right: 8px;
top: 0;
bottom: 0;
transition: transform 0.3s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.protocol-https {
animation: slideUpDown 6s infinite;
transform: translateY(0);
}
.protocol-grpcs {
animation: slideUpDown 6s infinite 3s;
transform: translateY(100%);
}
@keyframes slideUpDown {
0%, 45% {
transform: translateY(0);
}
50%, 95% {
transform: translateY(100%);
}
100% {
transform: translateY(0);
}
}
`;
export default StyledWrapper;

View File

@@ -132,7 +132,10 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
</label>
<div className="relative flex items-center">
<div className="absolute left-0 pl-2 text-gray-400 pointer-events-none flex items-center h-full">
https://
<span className="protocol-placeholder">
<span className="protocol-https">https://</span>
<span className="protocol-grpcs">grpcs://</span>
</span>
</div>
<input
id="domain"

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.available-certificates {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
button.remove-certificate {
color: ${(props) => props.theme.colors.text.danger};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,263 @@
import React, { useState, useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons';
import { getRelativePath, getBasename, getDirPath } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import { existsSync, resolvePath } from '../../../utils/filesystem';
const GrpcSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
brunoConfig: { grpc: grpcConfig = {} }
} = collection;
const fileInputRef = useRef(null);
const [protoFileValidity, setProtoFileValidity] = useState({});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
protoFiles: grpcConfig.protoFiles || []
},
onSubmit: (newGrpcConfig) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.grpc = newGrpcConfig;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
toast.success('gRPC settings updated');
}
});
// Get file path using the ipcRenderer
const getProtoFile = (event) => {
const files = event?.files;
if (files && files.length > 0) {
const newProtoFiles = [...formik.values.protoFiles];
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const protoFileObj = {
path: relativePath,
type: 'file'
};
// Check if this path already exists
const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path);
if (!exists) {
newProtoFiles.push(protoFileObj);
}
}
}
formik.setFieldValue('protoFiles', newProtoFiles);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Handler for removing a proto file
const handleRemoveProtoFile = (index) => {
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles.splice(index, 1);
formik.setFieldValue('protoFiles', updatedProtoFiles);
};
// Handle the browse button click
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Check if a proto file path is valid
const isProtoFileValid = async (protoFile) => {
try {
const absolutePath = await resolvePath(protoFile.path, collection.pathname);
return await existsSync(absolutePath);
} catch (error) {
return false;
}
};
// Validate all proto files and update state
useEffect(() => {
const validateProtoFiles = async () => {
const validityMap = {};
for (const file of formik.values.protoFiles) {
validityMap[file.path] = await isProtoFileValid(file);
}
setProtoFileValidity(validityMap);
};
validateProtoFiles();
}, [formik.values.protoFiles, collection.pathname]);
// Handle replacing an invalid proto file
const handleReplaceProtoFile = (index) => {
if (fileInputRef.current) {
fileInputRef.current.click();
// Store the index to replace after file selection
fileInputRef.current.dataset.replaceIndex = index;
}
};
// Handle file input change
const handleFileInputChange = (e) => {
const replaceIndex = e.target.dataset.replaceIndex;
if (replaceIndex !== undefined) {
// Handle replacement
const files = e.target.files;
if (files && files.length > 0) {
const filePath = window?.ipcRenderer?.getFilePath(files[0]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles[replaceIndex] = {
path: relativePath,
type: 'file'
};
formik.setFieldValue('protoFiles', updatedProtoFiles);
}
}
delete e.target.dataset.replaceIndex;
} else {
getProtoFile(e.target);
}
};
return (
<StyledWrapper className="h-full w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3">
<label className="font-semibold text-sm mb-3 flex items-center" htmlFor="protoFiles">
Add Proto Files
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
<div className="flex flex-col">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
<div className="flex flex-col gap-3">
{/* File selection options */}
<div className="flex flex-col space-y-3">
<div className="flex items-center">
<button
type="button"
className="btn btn-sm btn-secondary flex items-center"
onClick={handleBrowseClick}
>
<IconFileImport size={16} strokeWidth={1.5} className="mr-1" />
Browse for proto files
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-neutral-600 my-2"></div>
{/* List of added proto files */}
<div>
<div className="text-sm font-semibold mb-2 flex items-center">
<IconFile size={16} strokeWidth={1.5} className="mr-1" />
Added Proto Files ({formik.values.protoFiles.length})
</div>
{formik.values.protoFiles.length === 0 ? (
<div className="text-neutral-500 text-sm italic">No proto files added yet</div>
) : (
<>
{formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && (
<div className="text-xs text-red-500 mb-2 flex items-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations.
</div>
)}
<ul className="mt-4">
{formik.values.protoFiles.map((file, index) => {
const isValid = protoFileValidity[file.path];
return (
<li key={index} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between">
<div className="flex w-full items-center">
<IconFile className="mr-2" size={18} strokeWidth={1.5} />
<div
className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px] text-sm"
title={file.path}
>
{getBasename(file.path)}
<span className="text-xs text-neutral-500 ml-2">
{getDirPath(file.path)}
</span>
</div>
</div>
<div className="flex w-full items-center justify-end">
{!isValid && (
<div className="flex items-center mr-2">
<IconAlertCircle
size={16}
className="text-red-500"
title="Proto file not found. Click to replace."
/>
<button
type="button"
className="text-xs text-red-500 ml-1 hover:underline"
onClick={() => handleReplaceProtoFile(index)}
>
Replace
</button>
</div>
)}
<button
type="button"
className="remove-certificate ml-2"
onClick={() => handleRemoveProtoFile(index)}
title="Remove file"
>
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
</div>
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default GrpcSettings;

View File

@@ -1,13 +1,15 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const {
brunoConfig: { presets: presets = {} }
} = collection;
@@ -15,10 +17,15 @@ const PresetsSettings = ({ collection }) => {
const formik = useFormik({
enableReinitialize: true,
initialValues: {
requestType: presets.requestType || 'http',
requestType: presets.requestType === 'grpc' && !isGrpcEnabled ? 'http' : presets.requestType || 'http',
requestUrl: presets.requestUrl || ''
},
onSubmit: (newPresets) => {
// If gRPC is disabled but the preset is set to grpc, change it to http
if (!isGrpcEnabled && newPresets.requestType === 'grpc') {
newPresets.requestType = 'http';
}
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.presets = newPresets;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
@@ -62,6 +69,23 @@ const PresetsSettings = ({ collection }) => {
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
{isGrpcEnabled && (
<>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</>
)}
</div>
</div>
<div className="mb-3 flex items-center">
@@ -74,7 +98,7 @@ const PresetsSettings = ({ collection }) => {
id="request-url"
type="text"
name="requestUrl"
placeholder='Request URL'
placeholder="Request URL"
className="block textbox"
autoComplete="off"
autoCorrect="off"
@@ -87,6 +111,7 @@ const PresetsSettings = ({ collection }) => {
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save

View File

@@ -13,13 +13,16 @@ import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Grpc from './Grpc';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const tab = collection.settingsSelectedTab;
const setTab = (tab) => {
dispatch(
@@ -45,7 +48,7 @@ const CollectionSettings = ({ collection }) => {
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const grpcConfig = get(collection, 'brunoConfig.grpc', {});
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
@@ -122,6 +125,9 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'grpc': {
return <Grpc collection={collection} />;
}
}
};
@@ -134,7 +140,7 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
@@ -162,12 +168,18 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
{isGrpcEnabled && (
<div className={getTabClassname('grpc')} role="tab" onClick={() => setTab('grpc')}>
gRPC
{grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && <StatusDot />}
</div>
)}
</div>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>

View File

@@ -16,6 +16,7 @@ const Wrapper = styled.div`
border-radius: 3px;
max-height: 90vh;
overflow-y: auto;
max-width: unset !important;
.tippy-content {
padding-left: 0;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
return (
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
@@ -14,6 +14,7 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
interactive={true}
trigger="click"
appendTo="parent"
{...props}
>
{icon}
</Tippy>

View File

@@ -0,0 +1,93 @@
import React from 'react';
// UNARY - Single request, single response (Blue)
export const IconGrpcUnary = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right */}
<path d="M3 8h18" stroke="#3B82F6" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#3B82F6" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left */}
<path d="M21 16h-18" stroke="#3B82F6" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#3B82F6" strokeWidth={strokeWidth} />
</svg>
);
// CLIENT_STREAMING - Streaming request, single response (Purple)
export const IconGrpcClientStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right with double heads */}
<path d="M3 8h18" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M14 5l3 3l-3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left */}
<path d="M21 16h-18" stroke="#8B5CF6" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#8B5CF6" strokeWidth={strokeWidth} />
</svg>
);
// SERVER_STREAMING - Single request, streaming response (Green)
export const IconGrpcServerStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right */}
<path d="M3 8h18" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#10B981" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left with double heads */}
<path d="M21 16h-18" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#10B981" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#10B981" strokeWidth={strokeWidth} />
</svg>
);
// BIDI_STREAMING - Streaming request, streaming response (Orange)
export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
{/* Request arrow (top) - right with double heads */}
<path d="M3 8h18" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M18 5l3 3l-3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M14 5l3 3l-3 3" stroke="#F97316" strokeWidth={strokeWidth} />
{/* Response arrow (bottom) - left with double heads */}
<path d="M21 16h-18" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
</svg>
);

View File

@@ -0,0 +1,35 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-form {
padding: 1rem;
}
.submit {
margin-top: 1rem;
}
.beta-feature-item {
border-radius: 0.5rem;
border: 1px solid var(--color-gray-200);
background-color: var(--color-gray-50);
margin-bottom: 1rem;
}
.beta-feature-item:hover {
background-color: var(--color-gray-100);
}
.beta-feature-description {
margin-top: 0.25rem;
}
.no-features-message {
text-align: center;
padding: 2rem;
color: var(--color-gray-500);
font-style: italic;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
// Beta features configuration
const BETA_FEATURES = [
{
id: 'grpc',
label: 'gRPC Support',
description: 'Enable gRPC request support for making gRPC calls to services'
}
];
const Beta = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
// Generate validation schema dynamically from beta features
const generateValidationSchema = () => {
const schemaShape = {};
BETA_FEATURES.forEach((feature) => {
schemaShape[feature.id] = Yup.boolean();
});
return Yup.object().shape(schemaShape);
};
// Generate initial values dynamically from beta features
const generateInitialValues = () => {
const initialValues = {};
BETA_FEATURES.forEach((feature) => {
initialValues[feature.id] = get(preferences, `beta.${feature.id}`, false);
});
return initialValues;
};
const betaSchema = generateValidationSchema();
const formik = useFormik({
initialValues: generateInitialValues(),
validationSchema: betaSchema,
onSubmit: async (values) => {
try {
const newPreferences = await betaSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Beta preferences validation error:', error.message);
}
}
});
const handleSave = (newBetaPreferences) => {
dispatch(
savePreferences({
...preferences,
beta: newBetaPreferences
})
)
.then(() => {
toast.success('Beta preferences saved successfully');
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
};
const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-6">
<div className="flex items-center mb-2">
<IconFlask size={20} className="mr-2 text-orange-500" />
<h2 className="text-lg font-semibold">Beta Features</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 text-wrap">
Enable beta features, these features may be unstable or incomplete.
</p>
</div>
<div className="space-y-4">
{BETA_FEATURES.map((feature) => (
<div key={feature.id} className="beta-feature-item">
<div className="flex items-center">
<input
id={feature.id}
type="checkbox"
name={feature.id}
checked={formik.values[feature.id]}
onChange={formik.handleChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none font-medium" htmlFor={feature.id}>
{feature.label}
</label>
</div>
<div className="beta-feature-description ml-6 text-xs text-gray-500 dark:text-gray-400">
{feature.description}
</div>
</div>
))}
</div>
{!hasAnyBetaFeatures && (
<div className="no-features-message">
<p>No beta features are currently available</p>
</div>
)}
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default Beta;

View File

@@ -7,6 +7,7 @@ import General from './General';
import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
@@ -37,6 +38,10 @@ const Preferences = ({ onClose }) => {
return <Keybindings close={onClose} />;
}
case 'beta': {
return <Beta close={onClose} />;
}
case 'support': {
return <Support />;
}
@@ -46,7 +51,7 @@ const Preferences = ({ onClose }) => {
return (
<StyledWrapper>
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
<div className='flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem] py-2'>
<div className="flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem] py-2">
<div className="flex flex-col items-center tabs" role="tablist">
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
General
@@ -63,6 +68,9 @@ const Preferences = ({ onClose }) => {
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
Support
</div>
<div className={getTabClassname('beta')} role="tab" onClick={() => setTab('beta')}>
Beta
</div>
</div>
<section className="flex flex-grow px-2 pt-2 pb-6 tab-panel">{getTabPanel(tab)}</section>
</div>

View File

@@ -0,0 +1,59 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
/* height: 100%; */
position: relative;
.grpc-message-header {
.font-medium {
color: ${(props) => props.theme.text};
}
button {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
#grpc-messages-container {
/* height: 100%; */
position: relative;
}
.add-message-btn-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding-top: 8px;
background: ${(props) => props.theme.bg || '#fff'};
z-index: 15;
border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'};
.add-message-btn {
width: 100%;
}
}
.CodeMirror {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect, useRef } from 'react';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendGrpcMessage, generateGrpcSampleMessage } from 'utils/network/index';
import useLocalStorage from 'hooks/useLocalStorage';
import CodeEditor from 'components/CodeEditor/index';
import StyledWrapper from './StyledWrapper';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
}
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(
methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
}
);
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full"
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ?
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" /> :
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? "Send gRPC message" : "Connection not active"} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "h-80"} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode='application/ld+json'
enableVariableHighlighting={true}
/>
</div>
)}
</div>
)
}
const GrpcBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const dispatch = useDispatch();
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.grpc?.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}, [body?.grpc?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index);
} else {
return [...prev, index];
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
if (!body?.grpc || !Array.isArray(body.grpc)) {
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No gRPC messages available</p>
<ToolHint text="Add the first message to your gRPC request" toolhintId="add-first-msg">
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add First Message</span>
</button>
</ToolHint>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
ref={messagesContainerRef}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "overflow-y-auto"} ${canClientSendMultipleMessages && "pb-16"}`}
>
{body.grpc
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleGrpcMessage
key={index}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
isCollapsed={collapsedMessages.includes(index)}
onToggleCollapse={() => toggleMessageCollapse(index)}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new gRPC message to the request" toolhintId="add-msg-fixed">
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add Message</span>
</button>
</ToolHint>
</div>
)}
</StyledWrapper>
);
};
export default GrpcBody;

View File

@@ -0,0 +1,119 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.3rem;
.method-selector-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.input-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
input {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
outline: none;
box-shadow: none;
&:focus {
outline: none !important;
box-shadow: none !important;
}
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
position: relative;
top: 1px;
}
.infotip {
position: relative;
display: inline-block;
cursor: pointer;
}
.infotip:hover .infotip-text {
visibility: visible;
opacity: 1;
}
.infotip-text {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
padding: 4px 8px;
position: absolute;
z-index: 1;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
white-space: nowrap;
}
.infotip-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
}
.shortcut {
font-size: 0.625rem;
}
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
.connection-status-strip {
animation: pulse 1.5s ease-in-out infinite;
background-color: ${(props) => props.theme.colors.text.green};
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
}
/* Method dropdown styling */
.method-dropdown {
margin-right: 8px;
position: relative;
z-index: 10;
}
.dropdown-item {
padding: 8px 12px;
cursor: pointer;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
}
`;
export default Wrapper;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,125 @@
import React, { useEffect } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import GrpcAuthMode from './GrpcAuthMode';
import BearerAuth from '../../Auth/BearerAuth';
import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
import OAuth2 from '../../Auth/OAuth2/index';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
// List of auth modes supported by gRPC
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
const GrpcAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
const save = () => {
return saveRequest(item.uid, collection.uid);
};
// Reset to 'none' if current auth mode is not supported by gRPC
useEffect(() => {
if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: 'none'
})
);
}
}, [authMode, collection.uid, dispatch, item.uid]);
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return <BasicAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();
// Only show inherited auth if it's one of the supported types
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
} else {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Inherited auth not supported by gRPC. Using no auth instead.</div>
</div>
</>
);
}
}
default: {
return null;
}
}
};
return (
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<GrpcAuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);
};
export default GrpcAuth;

View File

@@ -0,0 +1,34 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text}
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,124 @@
import React from 'react';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import GrpcBody from 'components/RequestPane/GrpcBody';
import GrpcAuth from './GrpcAuth/index';
import StatusDot from 'components/StatusDot/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import { useEffect } from 'react';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
const GrpcRequestPane = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const selectTab = (tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
const getTabPanel = (tab) => {
switch (tab) {
case 'body': {
return <GrpcBody item={item} collection={collection} hideModeSelector={true} hidePrettifyButton={true} handleRun={handleRun}/>;
}
case 'headers': {
return <RequestHeaders item={item} collection={collection} addHeaderText="Add Metadata" />;
}
case 'auth': {
return <GrpcAuth item={item} collection={collection} />;
}
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
const body = getPropertyFromDraftOrRequest(item, 'request.body');
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
const activeHeadersLength = headers.filter((header) => header.enabled).length;
const grpcMessagesCount = body?.grpc?.length || 0;
// Determine if this is a client streaming request
const request = item.draft ? item.draft.request : item.request;
const isClientStreaming = request.methodType === 'client-streaming' || request.methodType === 'bidi-streaming';
useEffect(() => {
// Only set the tab to 'body' if no tab is currently set
if (!focusedTab?.requestPaneTab) {
selectTab('body');
}
}, []);
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Message
{grpcMessagesCount > 0 && (
isClientStreaming ? (
<sup className="ml-[.125rem] font-medium">{grpcMessagesCount}</sup>
) : (
<StatusDot />
)
)}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Metadata
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot type="default" />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot type="default" />}
</div>
</div>
<section
className={classnames('flex w-full flex-1 h-full', {
'mt-2': !isMultipleContentTab
})}
>
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
</section>
</StyledWrapper>
);
};
export default GrpcRequestPane;

View File

@@ -20,6 +20,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
const isGrpc = item.type === 'grpc-request';
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -80,7 +81,14 @@ const QueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper className="flex items-center">
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
{isGrpc ? (
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
</div>
) : (
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
)}
</div>
<div
id="request-url"

View File

@@ -71,81 +71,81 @@ const RequestBodyMode = ({ item, collection }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Form</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('multipartForm');
}}
>
Multipart Form
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('formUrlEncoded');
}}
>
Form URL Encoded
</div>
<div className="label-item font-medium">Raw</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('json');
}}
>
JSON
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('xml');
}}
>
XML
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('text');
}}
>
TEXT
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('sparql');
}}
>
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Body
</div>
<div className="label-item font-medium">Form</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('multipartForm');
}}
>
Multipart Form
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('formUrlEncoded');
}}
>
Form URL Encoded
</div>
<div className="label-item font-medium">Raw</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('json');
}}
>
JSON
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('xml');
}}
>
XML
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('text');
}}
>
TEXT
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('sparql');
}}
>
SPARQL
</div>
<div className="label-item font-medium">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Body
</div>
</Dropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (

View File

@@ -79,4 +79,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -16,7 +16,7 @@ import BulkEditor from '../../BulkEditor';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
const RequestHeaders = ({ item, collection, addHeaderText }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
@@ -181,7 +181,7 @@ const RequestHeaders = ({ item, collection }) => {
</Table>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
+ {addHeaderText || 'Add Header'}
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit

View File

@@ -18,6 +18,7 @@ const StyledWrapper = styled.div`
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
div.dragbar-handle {
display: flex;
@@ -45,6 +46,7 @@ const StyledWrapper = styled.div`
height: 10px;
cursor: row-resize;
padding: 0 1rem;
position: relative;
div.dragbar-handle {
width: 100%;

View File

@@ -4,13 +4,16 @@ import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
import GraphQLRequestPane from 'components/RequestPane/GraphQLRequestPane';
import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
import NetworkError from 'components/ResponsePane/NetworkError';
import RunnerResults from 'components/RunnerResults';
import VariablesEditor from 'components/VariablesEditor';
@@ -180,6 +183,9 @@ const RequestTabPanel = () => {
return <div className="pb-4 px-4">Collection not found!</div>;
}
const item = findItemInCollection(collection, activeTabUid);
const isGrpcRequest = item?.type === 'grpc-request';
if (focusedTab.type === 'collection-runner') {
return <RunnerResults collection={collection} />;
}
@@ -201,7 +207,7 @@ const RequestTabPanel = () => {
if (!folder) {
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;
}
@@ -209,20 +215,32 @@ const RequestTabPanel = () => {
return <SecuritySettings collection={collection} />;
}
const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
return <RequestNotLoaded item={item} collection={collection} />
return <RequestNotLoaded item={item} collection={collection} />;
}
if (item?.loading) {
return <RequestIsLoading item={item} />
return <RequestIsLoading item={item} />;
}
const handleRun = async () => {
const isGrpcRequest = item?.type === 'grpc-request';
const request = item.draft ? item.draft.request : item.request;
if (isGrpcRequest && !request.url) {
toast.error('Please enter a valid gRPC server URL');
return;
}
if (isGrpcRequest && !request.method) {
toast.error('Please select a gRPC method');
return;
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
@@ -233,9 +251,13 @@ const RequestTabPanel = () => {
return (
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<div className="pt-4 pb-3 px-4">
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
{isGrpcRequest ? (
<GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />
) : (
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
)}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative`}>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
<div
className="px-4 h-full"
@@ -260,6 +282,10 @@ const RequestTabPanel = () => {
{item.type === 'http-request' ? (
<HttpRequestPane item={item} collection={collection} />
) : null}
{isGrpcRequest ? (
<GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />
) : null}
</div>
</section>
@@ -268,7 +294,20 @@ const RequestTabPanel = () => {
</div>
<section className="response-pane flex-grow overflow-x-auto">
<ResponsePane item={item} collection={collection} response={item.response} />
{item.type === 'grpc-request' ? (
<GrpcResponsePane
item={item}
collection={collection}
response={item.response}
/>
) : (
<ResponsePane
item={item}
collection={collection}
response={item.response}
/>
)}
</section>
</section>

View File

@@ -22,6 +22,7 @@ import { flattenItems } from 'utils/collections/index';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const [showConfirmClose, setShowConfirmClose] = useState(false);
const dropdownTippyRef = useRef();
@@ -65,10 +66,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const getMethodColor = (method = '') => {
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
return theme.request.methods[method.toLocaleLowerCase()];
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
@@ -107,6 +108,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
}
const isGrpc = item.type === 'grpc-request';
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
return (
@@ -159,8 +161,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
<span className="tab-method uppercase" style={{ color: getMethodColor(method), fontSize: 12 }}>
{method}
<span className="tab-method uppercase" style={{ color: isGrpc ? theme.request.grpc : getMethodColor(method), fontSize: 12 }}>
{isGrpc ? 'gRPC' : method}
</span>
<span className="ml-1 tab-name" title={item.name}>
{item.name}

View File

@@ -0,0 +1,44 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
border-left: 4px solid ${(props) => props.theme.colors.text.danger};
border-top: 1px solid transparent;
border-right: 1px solid transparent;
border-bottom: 1px solid transparent;
border-radius: 0.375rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
max-height: 200px;
min-height: 70px;
overflow-y: auto;
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')};
.close-button {
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
svg {
color: ${(props) => props.theme.text};
}
}
.error-title {
font-weight: 600;
margin-bottom: 0.375rem;
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
font-family: monospace;
font-size: 0.6875rem;
line-height: 1.25rem;
white-space: pre-wrap;
word-break: break-all;
color: ${(props) => props.theme.text};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const GrpcError = ({ error, onClose }) => {
if (!error) return null;
return (
<StyledWrapper className="mt-4 mb-2">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">gRPC Server Error</div>
<div className="error-message">{typeof error === 'string' ? error : JSON.stringify(error, null, 2)}</div>
</div>
<div className="close-button flex-shrink-0 cursor-pointer" onClick={onClose}>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</StyledWrapper>
);
};
export default GrpcError;

View File

@@ -0,0 +1,96 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
.CodeMirror {
height: 100%;
font-family: ${(props) => (props.font === 'default' ? 'monospace' : props.font)};
font-size: ${(props) => (props.fontSize ? props.fontSize : '13px')};
}
.accordion-header {
background-color: ${(props) => props.theme.requestTabPanel.card.bg};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
&.open {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
}
.error-header {
background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(185, 28, 28, 0.1)' : '#fee2e2')};
}
.error-text {
color: ${(props) => props.theme.colors.text.danger};
}
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.stream-status {
display: inline-flex;
align-items: center;
&.complete {
color: ${(props) => props.theme.colors.text.green};
}
&.cancelled {
color: ${(props) => props.theme.colors.text.danger};
}
&.streaming {
color: ${(props) => props.theme.colors.text.blue};
}
}
.message-counter {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
.response-list {
max-height: 500px;
overflow-y: auto;
}
.response-message {
margin-bottom: 8px;
padding: 8px;
border-radius: 4px;
background-color: var(--color-panel-background);
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import Accordion from 'components/Accordion';
import CodeEditor from 'components/CodeEditor';
import { get } from 'lodash';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme/index';
import StyledWrapper from './StyledWrapper';
import { formatISO9075 } from 'date-fns';
import GrpcError from '../GrpcError';
const GrpcQueryResult = ({ item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [showErrorMessage, setShowErrorMessage] = useState(true);
const response = item.response || {};
const responsesList = response?.responses || [];
// Reverse the responses list to show the most recent at the top
const reversedResponsesList = [...responsesList].reverse();
const hasError = response.isError;
const hasResponses = responsesList.length > 0;
const errorMessage = response.error;
// Reset error visibility when a new response is received
useEffect(() => {
if (hasError) {
setShowErrorMessage(true);
}
}, [response, hasError]);
// Format a timestamp to a human-readable format
const formatTimestamp = (timestamp) => {
if (!timestamp) return 'Unknown time';
try {
const date = new Date(timestamp);
return formatISO9075(date);
} catch (e) {
return 'Invalid time';
}
};
// Format JSON for display
const formatJSON = (data) => {
try {
if (typeof data === 'string') {
return JSON.stringify(JSON.parse(data), null, 2);
}
return JSON.stringify(data, null, 2);
} catch (e) {
return typeof data === 'string' ? data : JSON.stringify(data);
}
};
if (!hasResponses && !hasError) {
return (
<StyledWrapper className="w-full h-full relative flex flex-col">
<div className="text-gray-500 dark:text-gray-400 p-4">No messages received</div>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full h-full relative flex flex-col mt-2">
{hasError && showErrorMessage && <GrpcError error={errorMessage} onClose={() => setShowErrorMessage(false)} />}
{hasResponses && (
<div className={`overflow-y-auto ${responsesList.length === 1 ? 'flex-1' : ''}`}>
{responsesList.length === 1 ? (
// Single message - render directly without accordion
<div className="h-full">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(reversedResponsesList[0])}
mode="application/json"
readOnly={true}
/>
</div>
) : (
// Multiple messages - use accordion
<Accordion defaultIndex={0}>
{reversedResponsesList.map((response, index) => {
// Calculate the original response number (for display purposes)
const originalIndex = responsesList.length - index - 1;
return (
<Accordion.Item key={originalIndex} index={index}>
<Accordion.Header index={index} style={{ padding: '8px 12px', minHeight: '40px' }}>
<div className="flex justify-between w-full">
<div className="font-medium">
Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''}
</div>
</div>
</Accordion.Header>
<Accordion.Content index={index} style={{ padding: '0px' }}>
<div className="h-60">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
value={formatJSON(response)}
mode="application/json"
readOnly={true}
/>
</div>
</Accordion.Content>
</Accordion.Item>
);
})}
</Accordion>
)}
</div>
)}
{hasError && !hasResponses && !showErrorMessage && (
<div className="text-gray-500 dark:text-gray-400 p-4">
No messages received. A server error occurred but has been dismissed.
</div>
)}
</StyledWrapper>
);
};
export default GrpcQueryResult;

View File

@@ -0,0 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
thead {
color: #777777;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
td {
padding: 6px 10px;
&.value {
word-break: break-all;
}
}
tbody {
tr:nth-child(odd) {
background-color: ${(props) => props.theme.table.striped};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const GrpcResponseHeaders = ({ metadata }) => {
// Ensure headers is an array
const metadataArray = Array.isArray(metadata) ? metadata : [];
return (
<StyledWrapper className="pb-4 w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{metadataArray && metadataArray.length ? (
metadataArray.map((metadata, index) => (
<tr key={index}>
<td className="key">{metadata.name}</td>
<td className="value">{metadata.value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
No metadata received
</td>
</tr>
)}
</tbody>
</table>
</StyledWrapper>
);
};
export default GrpcResponseHeaders;

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
&.text-ok {
color: ${(props) => props.theme.requestTabPanel.responseOk};
}
&.text-pending {
color: ${(props) => props.theme.requestTabPanel.responsePending};
}
&.text-error {
color: ${(props) => props.theme.requestTabPanel.responseError};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,22 @@
// https://grpc.github.io/grpc/core/md_doc_statuscodes.html
const grpcStatusCodePhraseMap = {
0: 'OK',
1: 'Cancelled',
2: 'Unknown',
3: 'Invalid Argument',
4: 'Deadline Exceeded',
5: 'Not Found',
6: 'Already Exists',
7: 'Permission Denied',
8: 'Resource Exhausted',
9: 'Failed Precondition',
10: 'Aborted',
11: 'Out of Range',
12: 'Unimplemented',
13: 'Internal',
14: 'Unavailable',
15: 'Data Loss',
16: 'Unauthenticated'
};
export default grpcStatusCodePhraseMap;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import classnames from 'classnames';
import grpcStatusCodePhraseMap from './get-grpc-status-code-phrase';
import StyledWrapper from './StyledWrapper';
const GrpcStatusCode = ({ status, text }) => {
// gRPC status codes: 0 is success, anything else is an error
const getTabClassname = (status) => {
const isPending = text === 'PENDING' || text === 'STREAMING';
return classnames('ml-2', {
'text-ok': parseInt(status) === 0,
'text-pending': isPending,
'text-error': parseInt(status) > 0 && !isPending
});
};
const statusText = text || grpcStatusCodePhraseMap[status]
return (
<StyledWrapper className={getTabClassname(status)}>
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
{statusText && <div>{statusText}</div>}
</StyledWrapper>
);
};
export default GrpcStatusCode;

View File

@@ -0,0 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
thead {
color: #777777;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
td {
padding: 6px 10px;
&.value {
word-break: break-all;
}
}
tbody {
tr:nth-child(odd) {
background-color: ${(props) => props.theme.table.striped};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseTrailers = ({ trailers }) => {
const trailersArray = Array.isArray(trailers) ? trailers : [];
return (
<StyledWrapper className="pb-4 w-full">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{trailersArray && trailersArray.length ? (
trailersArray.map((trailer, index) => (
<tr key={index}>
<td className="key">{trailer.name}</td>
<td className="value">{trailer.value}</td>
</tr>
))
) : (
<tr>
<td colSpan="2" className="text-center py-4 text-gray-500">
No trailers received
</td>
</tr>
)}
</tbody>
</table>
</StyledWrapper>
);
};
export default ResponseTrailers;

View File

@@ -0,0 +1,58 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
background: ${(props) => props.theme.bg};
border-radius: 4px;
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.stream-status {
display: inline-flex;
align-items: center;
&.complete {
color: ${(props) => props.theme.colors.text.green};
}
&.cancelled {
color: ${(props) => props.theme.colors.text.danger};
}
&.streaming {
color: ${(props) => props.theme.colors.text.blue};
}
}
.message-counter {
display: inline-flex;
align-items: center;
margin-left: 10px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import Overlay from '../Overlay';
import Placeholder from '../Placeholder';
import GrpcResponseHeaders from './GrpcResponseHeaders';
import GrpcStatusCode from './GrpcStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
import ClearTimeline from '../ClearTimeline';
import ResponseSave from '../ResponseSave';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseTrailers from './ResponseTrailers';
import GrpcQueryResult from './GrpcQueryResult';
import ResponseLayoutToggle from '../ResponseLayoutToggle';
import Tab from 'components/Tab';
const GrpcResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const requestTimeline = [...(collection?.timeline || [])].filter((obj) => {
if (obj.itemUid === item.uid) return true;
});
const selectTab = (tab) => {
dispatch(
updateResponsePaneTab({
uid: item.uid,
responsePaneTab: tab
})
);
};
const response = item.response || {};
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
return <GrpcQueryResult item={item} collection={collection} />;
}
case 'headers': {
return <GrpcResponseHeaders metadata={response.metadata} />;
}
case 'trailers': {
return <ResponseTrailers trailers={response.trailers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} />;
}
default: {
return <div>404 | Not found</div>;
}
}
};
if (isLoading && !item.response) {
return (
<StyledWrapper className="flex flex-col h-full relative">
<Overlay item={item} collection={collection} />
</StyledWrapper>
);
}
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<Placeholder />
</StyledWrapper>
);
}
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const tabConfig = [
{
name: 'response',
label: 'Response',
count: Array.isArray(response.responses) ? response.responses.length : 0
},
{
name: 'headers',
label: 'Metadata',
count: Array.isArray(response.metadata) ? response.metadata.length : 0
},
{
name: 'trailers',
label: 'Trailers',
count: Array.isArray(response.trailers) ? response.trailers.length : 0
},
{
name: 'timeline',
label: 'Timeline'
}
];
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={focusedTab.responsePaneTab === tab.name}
onClick={selectTab}
count={tab.count}
/>
))}
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<GrpcStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null}
</div>
<section
className={`flex flex-col flex-grow pl-3 pr-4 h-0 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</section>
</StyledWrapper>
);
};
export default GrpcResponsePane;

View File

@@ -20,7 +20,7 @@ const ResponseSize = ({ size }) => {
}
return (
<StyledWrapper title={size.toLocaleString() + 'B'} className="ml-4">
<StyledWrapper title={(size?.toLocaleString() || '0') + 'B'} className="ml-4">
{sizeToDisplay}
</StyledWrapper>
);

View File

@@ -1,9 +1,9 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import isNumber from 'lodash/isNumber';
const ResponseTime = ({ duration }) => {
let durationToDisplay = '';
if (duration > 1000) {
// duration greater than a second
let seconds = Math.floor(duration / 1000);
@@ -13,6 +13,10 @@ const ResponseTime = ({ duration }) => {
durationToDisplay = duration + 'ms';
}
if (!isNumber(duration)) {
return null;
}
return <StyledWrapper className="ml-4">{durationToDisplay}</StyledWrapper>;
};
export default ResponseTime;

View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { RelativeTime } from "../TimelineItem/Common/Time/index";
import Status from "../TimelineItem/Common/Status/index";
import {
IconChevronDown,
IconChevronRight,
IconServer,
IconDatabase,
IconAlertCircle,
IconCircleCheck,
IconCircleX,
IconX,
IconSend
} from '@tabler/icons';
// Icons for different event types
const EventTypeIcons = {
metadata: <IconServer size={16} strokeWidth={1.5} className="text-blue-500" />,
response: <IconSend style={{ transform: 'rotate(225deg)' }} size={16} strokeWidth={1.5} className="text-green-500" />,
request: <IconSend style={{ transform: 'rotate(45deg)' }} size={16} strokeWidth={1.5} className="text-orange-500" />,
message: <IconSend style={{ transform: 'rotate(45deg)' }} size={16} strokeWidth={1.5} className="text-orange-500" />,
status: <IconCircleCheck size={16} strokeWidth={1.5} className="text-purple-500" />,
error: <IconAlertCircle size={16} strokeWidth={1.5} className="text-red-500" />,
end: <IconX size={16} strokeWidth={1.5} className="text-gray-500" />,
cancel: <IconCircleX size={16} strokeWidth={1.5} className="text-amber-500" />
};
// Event type display names
const EventTypeNames = {
metadata: "Metadata",
response: "Response Message",
request: "Request",
message: "Message",
status: "Status",
error: "Error",
end: "Stream Ended",
cancel: "Cancelled"
};
// Colors for different event types
const EventTypeColors = {
metadata: "border-blue-500/20",
response: "border-green-500/20",
request: "border-orange-500/20",
message: "border-orange-500/20",
status: "border-purple-500/20",
error: "border-red-500/20",
end: "border-gray-500/20",
cancel: "border-amber-500/20"
};
const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item, collection, width }) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const toggleCollapse = () => setIsCollapsed(prev => !prev);
// Use requestSent if available, otherwise fall back to request
const effectiveRequest = item.requestSent || request || item.request || {};
// Extract relevant data from request and response
const { method, url = '' } = effectiveRequest;
const { statusCode, statusText, duration } = response || {};
// Get event-specific icon and color
const eventIcon = EventTypeIcons[eventType] || <IconDatabase size={16} strokeWidth={1.5} />;
const eventColor = EventTypeColors[eventType] || "border-gray-500/50";
const eventName = EventTypeNames[eventType] || "Event";
// Render appropriate content based on event type
const renderEventContent = () => {
const isClientStreaming = effectiveRequest.methodType === 'client-streaming' || effectiveRequest.methodType === 'bidi-streaming';
switch(eventType) {
case 'request':
return (
<div className="mt-2 bg-orange-50 dark:bg-orange-900/10 rounded p-2">
{effectiveRequest.headers && Object.keys(effectiveRequest.headers).length > 0 && (
<div className="mb-3">
<div className="text-xs font-medium mb-1 text-orange-700 dark:text-orange-400">Metadata</div>
<div className="grid grid-cols-2 gap-1 bg-white dark:bg-gray-800 p-2 rounded">
{Object.entries(effectiveRequest.headers).map(([key, value], idx) => (
<div key={idx} className="contents">
<div className="text-xs font-medium overflow-hidden text-ellipsis">{key}:</div>
<div className="text-xs overflow-hidden text-ellipsis">{value}</div>
</div>
))}
</div>
</div>
)}
{/* gRPC Messages section */}
{!isClientStreaming && effectiveRequest.body?.mode === 'grpc' && effectiveRequest.body?.grpc?.length > 0 && (
<div>
<div className="text-xs font-medium mb-1 text-orange-700 dark:text-orange-400">
Message
</div>
<div className="space-y-2">
{effectiveRequest.body.grpc.filter((_, index) => index === 0).map((message, idx) => (
<div key={idx} className="bg-white dark:bg-gray-800 p-2 rounded">
<pre className="text-xs overflow-auto max-h-[150px]">
{typeof message.content === 'string'
? message.content
: JSON.stringify(message.content, null, 2)}
</pre>
</div>
))}
</div>
</div>
)}
</div>
);
case 'message':
return (
<div className="mt-2 bg-orange-50 dark:bg-orange-900/10 rounded p-2">
<div className="font-semibold mb-1 text-orange-700 dark:text-orange-400">Message</div>
<pre className="text-xs bg-white dark:bg-gray-800 p-2 rounded overflow-auto max-h-[200px]">
{typeof eventData === 'string'
? eventData
: JSON.stringify(eventData, null, 2)}
</pre>
</div>
);
case 'metadata':
return (
<div className="mt-2 bg-blue-50 dark:bg-blue-900/10 rounded p-2">
<div className="font-semibold mb-1 text-blue-700 dark:text-blue-400">Metadata Headers</div>
{response.metadata && response.metadata.length > 0 ? (
<div className="grid grid-cols-2 gap-1">
{response.metadata.map((header, idx) => (
<div key={idx} className="contents">
<div className="text-xs font-medium">{header.name}:</div>
<div className="text-xs">{header.value}</div>
</div>
))}
</div>
) : (
<div className="text-sm italic text-gray-500">No metadata headers</div>
)}
</div>
);
case 'response':
// For message responses, show the response data
return (
<div className="mt-2 bg-green-50 dark:bg-green-900/10 rounded p-2">
<div className="font-semibold mb-1 text-green-700 dark:text-green-400">
Response Message #{(response.responses.length || 0)}
</div>
{response.responses && response.responses.length > 0 ? (
<pre className="text-xs bg-white dark:bg-gray-800 p-2 rounded overflow-auto max-h-[200px]">
{JSON.stringify(response.responses[response.responses.length - 1], null, 2)}
</pre>
) : (
<div className="text-sm italic text-gray-500">Empty message</div>
)}
</div>
);
case 'status':
// For status events, show status and trailers
return (
<div className="mt-2 bg-purple-50 dark:bg-purple-900/10 rounded p-2">
<div className="flex items-center gap-2 mb-1">
<Status statusCode={statusCode} statusText={statusText} />
</div>
{response.statusDescription && (
<div className="text-sm mb-2">{response.statusDescription}</div>
)}
{response.trailers && response.trailers.length > 0 && (
<>
<div className="font-semibold text-sm mt-2 mb-1 text-purple-700 dark:text-purple-400">Trailers</div>
<div className="grid grid-cols-2 gap-1">
{response.trailers.map((trailer, idx) => (
<div key={idx} className="contents">
<div className="text-xs font-medium">{trailer.name}:</div>
<div className="text-xs">{trailer.value || ''}</div>
</div>
))}
</div>
</>
)}
</div>
);
case 'error':
// For error events, show error details
return (
<div className="mt-2 bg-red-50 dark:bg-red-900/10 rounded p-2">
<div className="font-semibold mb-1 text-red-700 dark:text-red-400">Error</div>
<div className="text-sm mb-2">{response.error || "Unknown error"}</div>
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
</div>
{response.trailers && response.trailers.length > 0 && (
<>
<div className="font-semibold text-sm mt-2 mb-1 text-red-700 dark:text-red-400">Error Metadata</div>
<div className="grid grid-cols-2 gap-1">
{response.trailers.map((trailer, idx) => (
<div key={idx} className="contents">
<div className="text-xs font-medium">{trailer.name}:</div>
<div className="text-xs">{trailer.value}</div>
</div>
))}
</div>
</>
)}
</div>
);
case 'end':
// For end events, show summary
return (
<div className="mt-2 bg-gray-50 dark:bg-gray-700/30 rounded p-2">
<div className="font-semibold mb-1">Stream Ended</div>
<div className="text-sm">
Total messages: {response.responses.length || 0}
</div>
</div>
);
case 'cancel':
// For cancel events, show cancellation info
return (
<div className="mt-2 bg-amber-50 dark:bg-amber-900/10 rounded p-2">
<div className="font-semibold mb-1 text-amber-700 dark:text-amber-400">Stream Cancelled</div>
<div className="text-sm">{response.statusDescription || "The gRPC stream was cancelled"}</div>
</div>
);
default:
return null;
}
};
return (
<div className={`border-l-4 ${eventColor} pl-3 py-2 mb-3`}>
<div className={`flex items-center gap-2 cursor-pointer`} onClick={toggleCollapse}>
{isCollapsed ? <IconChevronRight size={16} strokeWidth={1.5} /> : <IconChevronDown size={16} strokeWidth={1.5} />}
{eventIcon}
<span className="font-medium text-sm">{eventName}</span>
{eventType === 'request' && effectiveRequest.methodType && (
<span className="px-2 py-0.5 text-xs rounded bg-orange-100 dark:bg-orange-800/30 text-orange-700 dark:text-orange-300">
{effectiveRequest.methodType}
</span>
)}
{eventType === 'status' && (
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
</div>
)}
<pre className="text-xs opacity-70">[{new Date(timestamp).toISOString()}]</pre>
<span className="text-xs text-gray-500 ml-auto">
<RelativeTime timestamp={timestamp} />
</span>
</div>
{/* Always show the URL */}
<div className="text-xs text-gray-500 mt-1 ml-6">{url}</div>
{/* Expanded content - only show for non-status items */}
{!isCollapsed && renderEventContent()}
</div>
);
};
export default GrpcTimelineItem;

View File

@@ -1,6 +1,15 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
overflow-y: auto;
height: 100%;
flex: 1;
.timeline-container {
flex: 1;
}
.timeline-event {
padding: 8px 0 0 0;
cursor: pointer;

View File

@@ -16,7 +16,7 @@ const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2
return (
<div className={`border-b-2 ${isOauth2 ? 'border-indigo-700/50' : 'border-amber-700/50' } py-2`}>
<div className="oauth-request-item-header cursor-pointer" onClick={toggleCollapse}>
<div className="oauth-request-item-header relative cursor-pointer" onClick={toggleCollapse}>
<div className="flex justify-between items-center min-w-0">
<div className="flex items-center space-x-2 min-w-0">
<Status statusCode={responseStatus || responseStatusCode} statusText={responseStatusText} />

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import React from 'react';
import StyledWrapper from './StyledWrapper';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
import { get } from 'lodash';
import TimelineItem from './TimelineItem/index';
import GrpcTimelineItem from './GrpcTimelineItem/index';
const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
@@ -44,9 +45,10 @@ const getEffectiveAuthSource = (collection, item) => {
const Timeline = ({ collection, item }) => {
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
const isGrpcRequest = item.type === 'grpc-request';
// Filter timeline entries based on new rules
const combinedTimeline = ([...(collection.timeline || [])]).filter(obj => {
const combinedTimeline = ([...(collection?.timeline || [])]).filter(obj => {
// Always show entries for this item
if (obj.itemUid === item.uid) return true;
@@ -57,43 +59,68 @@ const Timeline = ({ collection, item }) => {
}
return false;
}).sort((a, b) => b.timestamp - a.timestamp);
}).sort((a, b) => b.timestamp - a.timestamp)
return (
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
>
{combinedTimeline.map((event, index) => {
if (event.type === 'request') {
const { data, timestamp } = event;
const { request, response } = data;
return (
<div key={index} className="timeline-event mb-2">
<TimelineItem
timestamp={timestamp}
request={request}
response={response}
item={item}
collection={collection}
/>
</div>
);
} else if (event.type === 'oauth2') {
const { data, timestamp } = event;
const { debugInfo } = data;
return (
<div key={index} className="timeline-event">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
{/* Timeline container with scrollbar */}
<div
className="timeline-container"
>
{combinedTimeline.map((event, index) => {
// Handle regular requests
if (event.type === 'request') {
const { data, timestamp, eventType } = event;
const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data;
if (isGrpcRequest) {
return (
<div key={index} className="timeline-event mb-2">
<GrpcTimelineItem
timestamp={eventTimestamp}
request={request}
response={response}
eventType={eventType}
eventData={eventData}
item={item}
collection={collection}
/>
</div>
);
}
// Regular HTTP request
return (
<div key={index} className="timeline-event mb-2">
<TimelineItem
timestamp={timestamp}
request={request}
response={response}
item={item}
collection={collection}
/>
</div>
);
}
// Handle OAuth2 events
else if (event.type === 'oauth2') {
const { data, timestamp } = event;
const { debugInfo } = data;
return (
<div key={index} className="timeline-event">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div className='ml-4'>
<div className='ml-4' key={idx}>
<TimelineItem
key={idx}
timestamp={timestamp}
request={data?.request}
response={data?.response}
@@ -107,12 +134,13 @@ const Timeline = ({ collection, item }) => {
<div>No debug information available.</div>
)}
</div>
</div>
);
}
return null;
})}
</div>
);
}
return null;
})}
</div>
</StyledWrapper>
);
};

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import get from 'lodash/get';
import classnames from 'classnames';
import { safeStringifyJSON } from 'utils/common';
import QueryResult from 'components/ResponsePane/QueryResult';
import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import Modal from 'components/Modal';
import { IconDownload, IconLoader2 } from '@tabler/icons';
import { IconDownload, IconLoader2, IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Bruno from 'components/Bruno';
import exportBrunoCollection from 'utils/collections/export';
@@ -11,9 +11,22 @@ import { useSelector } from 'react-redux';
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const isCollectionLoading = areItemsLoading(collection);
const hasGrpcRequests = useMemo(() => {
const checkItem = (item) => {
if (item.type === 'grpc-request') {
return true;
}
if (item.items) {
return item.items.some(checkItem);
}
return false;
};
return collection?.items?.some(checkItem) || false;
}, [collection]);
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
@@ -36,38 +49,39 @@ const ShareCollection = ({ onClose, collectionUid }) => {
hideCancel
>
<StyledWrapper className="flex flex-col h-full w-[500px]">
<div className="space-y-2">
<div
className={`flex border border-gray-200 dark:border-gray-600 items-center p-3 rounded-lg transition-colors ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<Bruno width={28} />
)}
</div>
<div className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">
{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}
</div>
</div>
<div className="space-y-2">
<div
className={`flex border border-gray-200 dark:border-gray-600 items-center p-3 rounded-lg transition-colors ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? <IconLoader2 size={28} className="animate-spin" /> : <Bruno width={28} />}
</div>
<div
className={`flex border border-gray-200 dark:border-gray-600 items-center p-3 rounded-lg transition-colors ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>
<div className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}</div>
</div>
</div>
<div
className={`flex flex-col border border-gray-200 dark:border-gray-600 items-center rounded-lg transition-colors ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>
{hasGrpcRequests && (
<div className="px-3 py-2 bg-yellow-50 w-full dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 text-xs border-b border-yellow-100 dark:border-yellow-800/20 flex items-center">
<IconAlertTriangle size={16} className="mr-2 flex-shrink-0" />
<span>Note: gRPC requests in this collection will not be exported</span>
</div>
)}
<div className="flex items-center p-3 w-full">
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
@@ -83,6 +97,7 @@ const ShareCollection = ({ onClose, collectionUid }) => {
</div>
</div>
</div>
</div>
</StyledWrapper>
</Modal>
);

View File

@@ -34,6 +34,9 @@ const Wrapper = styled.div`
.method-head {
color: ${(props) => props.theme.request.methods.head};
}
.method-grpc {
color: ${(props) => props.theme.request.grpc};
}
`;
export default Wrapper;

View File

@@ -3,10 +3,12 @@ import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
const RequestMethod = ({ item }) => {
if (!['http-request', 'graphql-request'].includes(item.type)) {
if (!['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
return null;
}
const isGrpc = item.type === 'grpc-request';
const getClassname = (method = '') => {
method = method.toLocaleLowerCase();
return classnames('mr-1', {
@@ -16,7 +18,8 @@ const RequestMethod = ({ item }) => {
'method-delete': method === 'delete',
'method-patch': method === 'patch',
'method-head': method === 'head',
'method-options': method == 'options'
'method-options': method === 'options',
'method-grpc': isGrpc,
});
};
@@ -24,7 +27,7 @@ const RequestMethod = ({ item }) => {
<StyledWrapper>
<div className={getClassname(item.request.method)}>
<span className="uppercase">
{item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
{isGrpc ? 'grpc' : item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
</span>
</div>
</StyledWrapper>

View File

@@ -7,7 +7,7 @@ import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { newHttpRequest, newGrpcRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
@@ -21,14 +21,16 @@ import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useTheme } from 'styled-components';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const storedTheme = useTheme();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
@@ -44,7 +46,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{curlRequestTypeDetected === 'http-request' ? "HTTP" : "GraphQL"}
{curlRequestTypeDetected === 'http-request' ? 'HTTP' : 'GraphQL'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
@@ -89,6 +91,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
return 'graphql-request';
}
if (collectionPresets.requestType === 'grpc') {
// If gRPC is disabled in beta features, fall back to http-request
if (!isGrpcEnabled) {
return 'http-request';
}
return 'grpc-request';
}
return 'http-request';
};
@@ -113,11 +123,15 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('filename is required')
.test('is-valid-filename', function(value) {
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value)),
.test(
'not-reserved',
`The file names "collection" and "folder" are reserved in bruno`,
(value) => !['collection', 'folder'].includes(value)
),
curlCommand: Yup.string().when('requestType', {
is: (requestType) => requestType === 'from-curl',
then: Yup.string()
@@ -131,7 +145,27 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
})
}),
onSubmit: (values) => {
if (isEphemeral) {
const isGrpcRequest = values.requestType === 'grpc-request';
if (isGrpcRequest) {
dispatch(
newGrpcRequest({
requestName: values.requestName,
filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
collectionUid: collection.uid,
itemUid: item ? item.uid : null
})
)
.then(() => {
toast.success('New request created!');
onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
// will need to handle import from grpcurl command when we support it, now it is just for creating new requests
} else if (isEphemeral) {
const uid = uuid();
dispatch(
newEphemeralHttpRequest({
@@ -176,7 +210,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
)
.then(() => {
toast.success('New request created!');
onClose()
onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else {
@@ -193,7 +227,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
)
.then(() => {
toast.success('New request created!');
onClose()
onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}
@@ -248,13 +282,10 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const AdvancedOptions = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
<button
className="btn-advanced"
type="button"
>
<button className="btn-advanced" type="button">
Options
</button>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2}/>
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
@@ -308,6 +339,26 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
GraphQL
</label>
{isGrpcEnabled && (
<>
<input
id="grpc-request"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={(event) => {
formik.setFieldValue('requestMethod', 'POST');
formik.handleChange(event);
}}
value="grpc-request"
checked={formik.values.requestType === 'grpc-request'}
/>
<label htmlFor="grpc-request" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</>
)}
<input
id="from-curl"
className="cursor-pointer ml-auto"
@@ -338,7 +389,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={e => {
onChange={(e) => {
formik.setFieldValue('requestName', e.target.value);
!isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
}}
@@ -352,34 +403,33 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
<div className="mt-4">
<div className="flex items-center justify-between">
<label htmlFor="filename" className="flex items-center font-semibold">
File Name <small className='font-normal text-muted ml-1'>(on filesystem)</small>
File Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width="300">
<p>
Bruno saves each request as a file in your collection's folder.
</p>
<p>Bruno saves each request as a file in your collection's folder.</p>
<p className="mt-2">
You can choose a file name different from your request's name or one compatible with filesystem rules.
You can choose a file name different from your request's name or one compatible with filesystem
rules.
</p>
</Help>
</label>
{isEditing ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
/>
)}
</div>
{isEditing ? (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
id="file-name"
type="text"
@@ -393,13 +443,11 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
<span className='absolute right-2 top-4 flex justify-center items-center file-extension'>.bru</span>
<span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>
</div>
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/>
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay baseName={formik.values.filename ? `${formik.values.filename}.bru` : ''} />
</div>
)}
{formik.touched.filename && formik.errors.filename ? (
@@ -414,12 +462,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
URL
</label>
<div className="flex items-center mt-2 ">
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector
method={formik.values.requestMethod}
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
/>
</div>
{formik.values.requestType !== 'grpc-request' ? (
<div className="flex items-center h-full method-selector-container">
<HttpMethodSelector
method={formik.values.requestMethod}
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}
/>
</div>
) : null}
<div id="new-request-url" className="flex px-2 items-center flex-grow input-container h-full">
<SingleLineEditor
onPaste={handlePaste}
@@ -429,7 +479,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
onChange={(value) => {
formik.handleChange({
target: {
name: "requestUrl",
name: 'requestUrl',
value: value
}
});
@@ -484,9 +534,9 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
</div>
)}
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
<div className='flex advanced-options'>
<div className="flex advanced-options">
<Dropdown onCreate={onAdvancedDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
<div
<div
className="dropdown-item"
key="show-filesystem-name"
onClick={(e) => {
@@ -498,17 +548,14 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
</div>
</Dropdown>
</div>
<div className='flex justify-end'>
<span className='mr-2'>
<div className="flex justify-end">
<span className="mr-2">
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
>
<button type="submit" className="submit btn btn-md btn-secondary">
Create
</button>
</span>

View File

@@ -0,0 +1,22 @@
import React from 'react';
import classnames from 'classnames';
const Tab = ({ name, label, isActive, onClick, count = 0, className = '', ...props }) => {
const tabClassName = classnames("tab select-none", {
active: isActive
}, className);
return (
<div
className={tabClassName}
role="tab"
onClick={() => onClick(name)}
{...props}
>
{label}
{count > 0 && <sup className="ml-1 font-medium">{count}</sup>}
</div>
);
};
export default Tab;

View File

@@ -2,8 +2,8 @@ import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', ...props }) => {
return (
<Switch size={size} {...props}>
<Checkbox checked={isOn} onChange={handleToggle} id="toggle-switch" type="checkbox" size={size} />
<Switch size={size} {...props} onClick={handleToggle}>
<Checkbox checked={isOn} id="toggle-switch" type="checkbox" size={size} onChange={() => {}} />
<Label htmlFor="toggle-switch">
<Inner size={size} />
<SwitchButton size={size} />

View File

@@ -5,6 +5,7 @@ import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
import useGrpcEventListeners from 'utils/network/grpc-event-listeners';
import StyledWrapper from './StyledWrapper';
import { version } from '../../../package.json';
@@ -13,7 +14,7 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
useGrpcEventListeners();
const dispatch = useDispatch();
useEffect(() => {

View File

@@ -19,7 +19,7 @@ import {
runRequestEvent,
scriptEnvironmentUpdateEvent
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot } from 'providers/ReduxStore/slices/collections/actions';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -112,6 +112,10 @@ const useIpcEvents = () => {
dispatch(scriptEnvironmentUpdateEvent(val));
});
const removePersistentEnvVariablesUpdateListener = ipcRenderer.on('main:persistent-env-variables-update', (val) => {
dispatch(mergeAndPersistEnvironment(val));
});
const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => {
dispatch(globalEnvironmentsUpdateEvent(val));
});
@@ -204,6 +208,7 @@ const useIpcEvents = () => {
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
};
}, [isElectron]);
};

View File

@@ -78,6 +78,19 @@ export const HotkeysProvider = (props) => {
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item) {
if(item.type === 'grpc-request') {
const request = item.draft ? item.draft.request : item.request;
if(!request.url) {
toast.error('Please enter a valid gRPC server URL');
return;
}
if(!request.method) {
toast.error('Please select a gRPC method');
return;
}
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000

View File

@@ -23,6 +23,9 @@ const initialState = {
},
font: {
codeFont: 'default'
},
beta: {
grpc: false
}
},
generateCode: {

View File

@@ -23,11 +23,48 @@ import mime from 'mime-types';
import path from 'utils/common/path';
import { getUniqueTagsFromItems } from 'utils/collections/index';
// gRPC status code meanings
const grpcStatusCodes = {
0: 'OK',
1: 'CANCELLED',
2: 'UNKNOWN',
3: 'INVALID_ARGUMENT',
4: 'DEADLINE_EXCEEDED',
5: 'NOT_FOUND',
6: 'ALREADY_EXISTS',
7: 'PERMISSION_DENIED',
8: 'RESOURCE_EXHAUSTED',
9: 'FAILED_PRECONDITION',
10: 'ABORTED',
11: 'OUT_OF_RANGE',
12: 'UNIMPLEMENTED',
13: 'INTERNAL',
14: 'UNAVAILABLE',
15: 'DATA_LOSS',
16: 'UNAUTHENTICATED'
};
const initialState = {
collections: [],
collectionSortOrder: 'default'
collectionSortOrder: 'default',
activeConnections: []
};
const initiatedGrpcResponse = {
statusCode: null,
statusText: 'STREAMING',
statusDescription: null,
headers: [],
metadata: null,
trailers: null,
statusDetails: null,
error: null,
isError: false,
duration: 0,
responses: [],
timestamp: Date.now(),
}
export const collectionsSlice = createSlice({
name: 'collections',
initialState,
@@ -326,6 +363,167 @@ export const collectionsSlice = createSlice({
}
}
},
runGrpcRequestEvent: (state, action) => {
const { itemUid, collectionUid, eventType, eventData } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
const request = item.draft ? item.draft.request : item.request;
const isUnary = request.methodType === 'unary';
if (eventType === 'request') {
item.requestSent = eventData;
item.requestSent.timestamp = Date.now();
item.response = {
initiatedGrpcResponse,
statusText: isUnary ? 'PENDING' : 'STREAMING'
};
}
if (!collection.timeline) {
collection.timeline = [];
}
collection.timeline.push({
type: "request",
eventType: eventType, // Add the specific gRPC event type
collectionUid: collection.uid,
folderUid: null,
itemUid: item.uid,
timestamp: Date.now(),
data: {
request: eventData || item.requestSent || item.request,
timestamp: Date.now(),
eventData: eventData,
}
});
},
grpcResponseReceived: (state, action) => {
const { itemUid, collectionUid, eventType, eventData } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
// Get current response state or create initial state
const currentResponse = item.response || initiatedGrpcResponse
const timestamp = item?.requestSent?.timestamp;
let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) };
// Process based on event type
switch (eventType) {
case 'response':
const { error, res } = eventData;
// Handle error if present
if (error) {
const errorCode = error.code || 2; // Default to UNKNOWN if no code
updatedResponse.error = error.details || 'gRPC error occurred';
updatedResponse.statusCode = errorCode;
updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN';
updatedResponse.errorDetails = error;
updatedResponse.isError = true;
}
// Add response to list
updatedResponse.responses = res
? [...(currentResponse?.responses || []), res]
: [...(currentResponse?.responses || [])];
break;
case 'metadata':
updatedResponse.headers = eventData.metadata;
updatedResponse.metadata = eventData.metadata;
break;
case 'status':
// Extract status info
const statusCode = eventData.status?.code;
const statusDetails = eventData.status?.details;
const statusMetadata = eventData.status?.metadata;
// Set status based on actual code and details
updatedResponse.statusCode = statusCode;
updatedResponse.statusText = grpcStatusCodes[statusCode] || 'UNKNOWN';
updatedResponse.statusDescription = statusDetails;
updatedResponse.statusDetails = eventData.status;
// Store trailers (status metadata)
if (statusMetadata) {
updatedResponse.trailers = statusMetadata;
}
// Handle error status (non-zero code)
if (statusCode !== 0) {
updatedResponse.isError = true;
updatedResponse.error = statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`;
}
break;
case 'error':
// Extract error details
const errorCode = eventData.error?.code || 2; // Default to UNKNOWN if no code
const errorDetails = eventData.error?.details || eventData.error?.message;
const errorMetadata = eventData.error?.metadata;
updatedResponse.isError = true;
updatedResponse.error = errorDetails || 'Unknown gRPC error';
updatedResponse.statusCode = errorCode;
updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN';
updatedResponse.statusDescription = errorDetails;
// Store error metadata as trailers if present
if (errorMetadata) {
updatedResponse.trailers = errorMetadata;
}
break;
case 'end':
state.activeConnections = state.activeConnections.filter(id => id !== itemUid);
break;
case 'cancel':
updatedResponse.statusCode = 1; // CANCELLED
updatedResponse.statusText = 'CANCELLED';
updatedResponse.statusDescription = 'Stream cancelled by client or server';
state.activeConnections = state.activeConnections.filter(id => id !== itemUid);
break;
}
item.requestState = 'received';
item.response = updatedResponse;
// Update the timeline
if (!collection?.timeline) {
collection.timeline = [];
}
// Append the new timeline entry with specific gRPC event type
collection.timeline.push({
type: "request",
eventType: eventType, // Add the specific gRPC event type
collectionUid: collection.uid,
folderUid: null,
itemUid: item.uid,
timestamp: Date.now(),
data: {
request: item.requestSent || item.request,
response: updatedResponse,
eventData: eventData, // Store the original event data
timestamp: Date.now(),
}
});
},
responseCleared: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1183,6 +1381,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
switch (item.draft.request.body.mode) {
case 'json': {
item.draft.request.body.json = action.payload.content;
@@ -1212,6 +1411,10 @@ export const collectionsSlice = createSlice({
item.draft.request.body.multipartForm = action.payload.content;
break;
}
case 'grpc': {
item.draft.request.body.grpc = action.payload.content;
break;
}
}
}
}
@@ -1303,6 +1506,21 @@ export const collectionsSlice = createSlice({
item.draft = cloneDeep(item);
}
item.draft.request.method = action.payload.method;
item.draft.request.methodType = action.payload.methodType;
}
}
},
updateRequestProtoPath: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.protoPath = action.payload.protoPath;
}
}
},
@@ -2461,7 +2679,10 @@ export const collectionsSlice = createSlice({
if (collection) {
collection.allTags = getUniqueTagsFromItems(collection.items);
}
}
},
updateActiveConnections: (state, action) => {
state.activeConnections = [...action.payload.activeConnectionIds];
},
}
});
@@ -2488,6 +2709,8 @@ export const {
processEnvUpdateEvent,
requestCancelled,
responseReceived,
runGrpcRequestEvent,
grpcResponseReceived,
responseCleared,
clearTimeline,
clearRequestTimeline,
@@ -2532,6 +2755,7 @@ export const {
updateResponseScript,
updateRequestTests,
updateRequestMethod,
updateRequestProtoPath,
addAssertion,
updateAssertion,
deleteAssertion,
@@ -2585,7 +2809,8 @@ export const {
updateFolderAuthMode,
addRequestTag,
deleteRequestTag,
updateCollectionTagsList
updateCollectionTagsList,
updateActiveConnections
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -41,13 +41,21 @@ export const tabsSlice = createSlice({
}
}
// Determine the default requestPaneTab based on request type
let defaultRequestPaneTab = 'params';
if (type === 'grpc-request') {
defaultRequestPaneTab = 'body';
} else if (type === 'graphql-request') {
defaultRequestPaneTab = 'query';
}
const lastTab = state.tabs[state.tabs.length - 1];
if (state.tabs.length > 0 && lastTab.preview) {
state.tabs[state.tabs.length - 1] = {
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: requestPaneTab || 'params',
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
responsePaneTab: 'response',
type: type || 'request',
preview: preview !== undefined
@@ -64,7 +72,7 @@ export const tabsSlice = createSlice({
uid,
collectionUid,
requestPaneWidth: null,
requestPaneTab: requestPaneTab || 'params',
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
responsePaneTab: 'response',
type: type || 'request',
...(uid ? { folderUid: uid } : {}),

View File

@@ -94,8 +94,9 @@ const darkTheme = {
// customize these colors if needed
patch: '#d69956',
options: '#d69956',
head: '#d69956'
}
head: '#d69956',
},
grpc: '#6366f1'
},
requestTabPanel: {
@@ -114,6 +115,7 @@ const darkTheme = {
responseStatus: '#ccc',
responseOk: '#8cd656',
responseError: '#f06f57',
responsePending: '#569cd6',
responseOverlayBg: 'rgba(30, 30, 30, 0.6)',
card: {

View File

@@ -95,7 +95,8 @@ const lightTheme = {
patch: '#ca7811',
options: '#ca7811',
head: '#ca7811'
}
},
grpc: '#6366f1'
},
requestTabPanel: {
@@ -114,6 +115,7 @@ const lightTheme = {
responseStatus: 'rgb(117 117 117)',
responseOk: '#047857',
responseError: 'rgb(185, 28, 28)',
responsePending: '#1663bb',
responseOverlayBg: 'rgba(255, 255, 255, 0.6)',
card: {
bg: '#fff',

View File

@@ -0,0 +1,19 @@
import { useSelector } from 'react-redux';
/**
* Beta features configuration object
* Contains all available beta feature keys
*/
export const BETA_FEATURES = Object.freeze({
GRPC: 'grpc'
});
/**
* Hook to check if a beta feature is enabled
* @param {string} featureName - The name of the beta feature
* @returns {boolean} - Whether the feature is enabled
*/
export const useBetaFeature = (featureName) => {
const preferences = useSelector((state) => state.app.preferences);
return preferences?.beta?.[featureName] || false;
};

View File

@@ -60,7 +60,8 @@ const STATIC_API_HINTS = {
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)',
'bru.setEnvVar(key, value)',
'bru.setEnvVar(key, value, options)',
'bru.deleteEnvVar(key)',
'bru.hasVar(key)',
'bru.getVar(key)',

View File

@@ -6,7 +6,7 @@ export const deleteUidsInItems = (items) => {
each(items, (item) => {
delete item.uid;
if (['http-request', 'graphql-request'].includes(item.type)) {
if (['http-request', 'graphql-request', 'grpc-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.vars.req'), (v) => delete v.uid);
@@ -29,7 +29,7 @@ export const deleteUidsInItems = (items) => {
*/
export const transformItem = (items = []) => {
each(items, (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
if (item.type === 'graphql-request') {
item.type = 'graphql';
}
@@ -37,6 +37,10 @@ export const transformItem = (items = []) => {
if (item.type === 'http-request') {
item.type = 'http';
}
if (item.type === 'grpc-request') {
item.type = 'grpc';
}
}
if (item.items && item.items.length) {

View File

@@ -246,6 +246,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
di.request = {
url: si.request.url,
method: si.request.method,
methodType: si.request.methodType,
protoPath: si.request.protoPath,
headers: copyHeaders(si.request.headers),
params: copyParams(si.request.params),
body: {
@@ -257,7 +259,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
sparql: si.request.body.sparql,
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm),
file: copyFileParams(si.request.body.file)
file: copyFileParams(si.request.body.file),
grpc: si.request.body.grpc
},
script: si.request.script,
vars: si.request.vars,
@@ -402,6 +405,13 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (di.request.body.mode === 'json') {
di.request.body.json = replaceTabsWithSpaces(di.request.body.json);
}
if (di.request.body.mode === 'grpc') {
di.request.body.grpc = di.request.body.grpc.map(({name, content}, index) => ({
name: name ? name : `message ${index + 1}`,
content: replaceTabsWithSpaces(content)
}))
}
}
if (si.type == 'folder' && si?.root) {
@@ -554,6 +564,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
export const transformRequestToSaveToFilesystem = (item) => {
const _item = item.draft ? item.draft : item;
const itemToSave = {
uid: _item.uid,
type: _item.type,
@@ -576,16 +587,25 @@ export const transformRequestToSaveToFilesystem = (item) => {
}
};
each(_item.request.params, (param) => {
itemToSave.request.params.push({
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
type: param.type,
enabled: param.enabled
if (_item.type === 'grpc-request') {
itemToSave.request.methodType = _item.request.methodType;
itemToSave.request.protoPath = _item.request.protoPath;
delete itemToSave.request.params
}
// Only process params for non-gRPC requests
if (_item.type !== 'grpc-request') {
each(_item.request.params, (param) => {
itemToSave.request.params.push({
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
type: param.type,
enabled: param.enabled
});
});
});
}
each(_item.request.headers, (header) => {
itemToSave.request.headers.push({
@@ -603,6 +623,17 @@ export const transformRequestToSaveToFilesystem = (item) => {
json: replaceTabsWithSpaces(itemToSave.request.body.json)
};
}
if (itemToSave.request.body.mode === 'grpc') {
itemToSave.request.body = {
...itemToSave.request.body,
grpc: itemToSave.request.body.grpc.map(({name, content}, index) => ({
name: name ? name : `message ${index + 1}`,
content: replaceTabsWithSpaces(content)
}))
};
}
return itemToSave;
};
@@ -630,7 +661,7 @@ export const deleteItemInCollectionByPathname = (pathname, collection) => {
};
export const isItemARequest = (item) => {
return item.hasOwnProperty('request') && ['http-request', 'graphql-request'].includes(item.type) && !item.items;
return item.hasOwnProperty('request') && ['http-request', 'graphql-request', 'grpc-request'].includes(item.type) && !item.items;
};
export const isItemAFolder = (item) => {
@@ -812,6 +843,10 @@ export const getDefaultRequestPaneTab = (item) => {
if (item.type === 'graphql-request') {
return 'query';
}
if (item.type === 'grpc-request') {
return 'body';
}
};
export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobalEnvironmentUid }) => {
@@ -1157,4 +1192,8 @@ export const getRequestItemsForCollectionRun = ({ recursive, items = [], tags })
}
return requestItems;
};
export const getPropertyFromDraftOrRequest = (item, propertyKey, defaultValue = null) => {
return item.draft ? get(item, `draft.${propertyKey}`, defaultValue) : get(item, propertyKey, defaultValue);
};

View File

@@ -9,4 +9,32 @@ const isWindowsOS = () => {
const brunoPath = isWindowsOS() ? path.win32 : path.posix;
const getRelativePath = (absolutePath, collectionPath) => {
try {
const relativePath = brunoPath.relative(collectionPath, absolutePath);
return relativePath || absolutePath;
} catch (error) {
return absolutePath;
}
};
const getBasename = (filePath) => {
if (!filePath) {
return '';
}
const parts = filePath.split(path.sep);
return parts[parts.length - 1];
};
const getDirPath = (filePath) => {
const parts = filePath.split(path.sep);
parts.pop();
return parts.join(path.sep);
};
const getAbsoluteFilePath = (filePath, collectionPath) => {
return brunoPath.resolve(collectionPath, filePath);
};
export default brunoPath;
export { getRelativePath, getBasename, getDirPath, getAbsoluteFilePath };

View File

@@ -0,0 +1,33 @@
/**
* Filesystem utilities for the renderer process
* These functions communicate with the main process via IPC
*/
/**
* Check if a file exists
* @param {string} filePath - The file path to check
* @returns {Promise<boolean>} - True if file exists, false otherwise
*/
export const existsSync = async (filePath) => {
try {
return await window?.ipcRenderer?.invoke('renderer:exists-sync', filePath);
} catch (error) {
console.error('Error checking if file exists:', error);
return false;
}
};
/**
* Resolve a relative path against a base path
* @param {string} relativePath - The relative path to resolve
* @param {string} basePath - The base path to resolve against
* @returns {Promise<string>} - The resolved absolute path
*/
export const resolvePath = async (relativePath, basePath) => {
try {
return await window?.ipcRenderer?.invoke('renderer:resolve-path', relativePath, basePath);
} catch (error) {
console.error('Error resolving path:', error);
return relativePath;
}
};

View File

@@ -62,8 +62,7 @@ export const updateUidsInCollection = (_collection) => {
export const transformItemsInCollection = (collection) => {
const transformItems = (items = []) => {
each(items, (item) => {
if (['http', 'graphql'].includes(item.type)) {
if (['http', 'graphql', 'grpc'].includes(item.type)) {
item.type = `${item.type}-request`;
if (item.request.query) {

View File

@@ -0,0 +1,127 @@
import { useEffect } from 'react';
import { grpcResponseReceived, runGrpcRequestEvent } from 'providers/ReduxStore/slices/collections/index';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { updateActiveConnectionsInStore } from 'providers/ReduxStore/slices/collections/actions';
const useGrpcEventListeners = () => {
const { ipcRenderer } = window;
const dispatch = useDispatch();
useEffect(() => {
if (!isElectron()) {
return () => {};
}
ipcRenderer.invoke('renderer:ready');
// Handle gRPC requestSent event
const removeGrpcRequestSentListener = ipcRenderer.on('grpc:request', (requestId, collectionUid, eventData) => {
dispatch(runGrpcRequestEvent({
eventType: "request",
itemUid: requestId,
collectionUid: collectionUid,
requestUid: requestId,
eventData
}));
});
const removeGrpcMessageSentListener = ipcRenderer.on('grpc:message', (requestId, collectionUid, eventData) => {
dispatch(runGrpcRequestEvent({
eventType: "message",
itemUid: requestId,
collectionUid: collectionUid,
requestUid: requestId,
eventData
}));
});
// Handle gRPC response event (for unary calls and streaming)
const removeGrpcResponseListener = ipcRenderer.on(`grpc:response`, (requestId, collectionUid, data) => {
dispatch(grpcResponseReceived({
itemUid: requestId,
collectionUid: collectionUid,
eventType: 'response',
eventData: data
}));
});
// Handle gRPC metadata
const removeGrpcMetadataListener = ipcRenderer.on(`grpc:metadata`, (requestId, collectionUid, data) => {
dispatch(grpcResponseReceived({
itemUid: requestId,
collectionUid: collectionUid,
eventType: 'metadata',
eventData: data
}));
});
// Handle gRPC status updates
const removeGrpcStatusListener = ipcRenderer.on(`grpc:status`, (requestId, collectionUid, data) => {
dispatch(grpcResponseReceived({
itemUid: requestId,
collectionUid: collectionUid,
eventType: 'status',
eventData: data
}));
});
// Handle gRPC errors
const removeGrpcErrorListener = ipcRenderer.on(`grpc:error`, (requestId, collectionUid, data) => {
dispatch(grpcResponseReceived({
itemUid: requestId,
collectionUid: collectionUid,
eventType: 'error',
eventData: data
}));
});
// Handle gRPC end event
const removeGrpcEndListener = ipcRenderer.on(`grpc:server-end-stream`, (requestId, collectionUid, data) => {
dispatch(grpcResponseReceived({
itemUid: requestId,
collectionUid: collectionUid,
eventType: 'end',
eventData: data
}));
});
// Handle gRPC cancel event
const removeGrpcCancelListener = ipcRenderer.on(`grpc:server-cancel-stream`, (requestId, collectionUid, data) => {
dispatch(grpcResponseReceived({
itemUid: requestId,
collectionUid: collectionUid,
eventType: 'cancel',
eventData: data
}));
});
const removeGrpcConnectionsChangedListener = ipcRenderer.on(`grpc:connections-changed`, (data) => {
dispatch(updateActiveConnectionsInStore(data));
});
return () => {
removeGrpcRequestSentListener();
removeGrpcMessageSentListener();
removeGrpcResponseListener();
removeGrpcMetadataListener();
removeGrpcStatusListener();
removeGrpcErrorListener();
removeGrpcEndListener();
removeGrpcCancelListener();
removeGrpcConnectionsChangedListener();
};
}, [isElectron]);
};
export default useGrpcEventListeners;

View File

@@ -1,5 +1,3 @@
import { safeStringifyJSON } from 'utils/common';
export const sendNetworkRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
@@ -27,6 +25,23 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
});
};
export const sendGrpcRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
startGrpcRequest(item, collection, environment, runtimeVariables)
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState,
timeline: []
});
})
.catch((err) => reject(err));
});
}
const sendHttpRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
@@ -60,3 +75,141 @@ export const cancelNetworkRequest = async (cancelTokenUid) => {
ipcRenderer.invoke('cancel-http-request', cancelTokenUid).then(resolve).catch(reject);
});
};
export const startGrpcRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const request = item.draft ? item.draft : item;
ipcRenderer.invoke('grpc:start-connection', {
request,
collection,
environment,
runtimeVariables
})
.then(() => {
resolve();
})
.catch(err => {
reject(err);
});
});
};
/**
* Sends a message to an existing gRPC stream
* @param {string} requestId - The request ID to send a message to
* @param {Object} message - The message to send
* @returns {Promise<Object>} - The result of the send operation
*/
export const sendGrpcMessage = async (item, collectionUid, message) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:send-message', item.uid, collectionUid, message)
.then(resolve)
.catch(reject);
});
};
/**
* Cancels a running gRPC request
* @param {string} requestId - The request ID to cancel
* @returns {Promise<Object>} - The result of the cancel operation
*/
export const cancelGrpcRequest = async (requestId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:cancel', requestId)
.then(resolve)
.catch(reject);
});
};
/**
* Ends a gRPC streaming request (client-streaming or bidirectional)
* @param {string} requestId - The request ID to end
* @returns {Promise<Object>} - The result of the end operation
*/
export const endGrpcStream = async (requestId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:end', requestId)
.then(resolve)
.catch(reject);
});
};
export const loadGrpcMethodsFromProtoFile = async (filePath, includeDirs = []) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:load-methods-proto', { filePath, includeDirs }).then(resolve).catch(reject);
});
};
// export const getGrpcMethodsFromReflection = async (request, collection, environment, runtimeVariables) => {
// return new Promise((resolve, reject) => {
// const { ipcRenderer } = window;
// ipcRenderer.invoke('grpc:load-methods-reflection', { request, collection, environment, runtimeVariables }).then(resolve).catch(reject);
// });
// };
export const cancelGrpcConnection = async (connectionId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:cancel-request', { requestId: connectionId }).then(resolve).catch(reject);
});
};
export const endGrpcConnection = async (connectionId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:end-request', { requestId: connectionId }).then(resolve).catch(reject);
});
};
/**
* Check if a gRPC connection is active
* @param {string} connectionId - The connection ID to check
* @returns {Promise<boolean>} - Whether the connection is active
*/
export const isGrpcConnectionActive = async (connectionId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:is-connection-active', connectionId)
.then(response => {
if (response.success) {
resolve(response.isActive);
} else {
// If there was an error, assume the connection is not active
console.error('Error checking connection status:', response.error);
resolve(false);
}
})
.catch(err => {
console.error('Failed to check connection status:', err);
// On error, assume the connection is not active
resolve(false);
});
});
};
/**
* Generates a sample gRPC message for a method
* @param {string} methodPath - The full gRPC method path
* @param {string|null} existingMessage - Optional existing message JSON string to use as a template
* @param {Object} options - Additional options for message generation
* @returns {Promise<Object>} The generated sample message or error
*/
export const generateGrpcSampleMessage = async (methodPath, existingMessage = null, options = {}) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:generate-sample-message', {
methodPath,
existingMessage,
options
})
.then(resolve)
.catch(reject);
});
};

View File

@@ -1,7 +1,7 @@
import find from 'lodash/find';
export const isItemARequest = (item) => {
return item.hasOwnProperty('request') && ['http-request', 'graphql-request'].includes(item.type);
return item.hasOwnProperty('request') && ['http-request', 'graphql-request', 'grpc-request'].includes(item.type);
};
export const isItemAFolder = (item) => {

View File

@@ -52,16 +52,22 @@ const bruToJson = (bru) => {
const json = _parseRequest(bru);
let requestType = _.get(json, 'meta.type');
if (requestType === 'http') {
requestType = 'http-request';
} else if (requestType === 'graphql') {
requestType = 'graphql-request';
} else {
requestType = 'http';
switch (requestType) {
case 'http':
requestType = 'http-request';
break;
case 'graphql':
requestType = 'graphql-request';
break;
case 'grpc':
requestType = 'grpc-request';
break;
default:
requestType = 'http-request';
}
const sequence = _.get(json, 'meta.seq');
const transformedJson = {
type: requestType,
name: _.get(json, 'meta.name'),
@@ -69,12 +75,10 @@ const bruToJson = (bru) => {
settings: _.get(json, 'settings', {}),
tags: _.get(json, 'meta.tags', []),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'),
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
body: _.get(json, 'body', {}),
vars: _.get(json, 'vars', []),
assertions: _.get(json, 'assertions', []),
script: _.get(json, 'script', {}),
@@ -82,8 +86,29 @@ const bruToJson = (bru) => {
}
};
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
if (requestType === 'grpc-request') {
const selectedMethod = _.get(json, 'grpc.method');
if(selectedMethod) transformedJson.request.method = selectedMethod;
const selectedMethodType = _.get(json, 'grpc.methodType');
if(selectedMethodType) transformedJson.request.methodType = selectedMethodType;
const protoPath = _.get(json, 'grpc.protoPath');
if(protoPath) transformedJson.request.protoPath = protoPath;
transformedJson.request.auth.mode = _.get(json, 'grpc.auth', 'none');
transformedJson.request.body = _.get(json, 'body', {
mode: 'grpc',
grpc: [{
name: 'message 1',
content: '{}'
}]
});
} else {
transformedJson.request.method = _.upperCase(_.get(json, 'http.method'));
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
transformedJson.request.body = _.get(json, 'body', {});
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
}
return transformedJson;
} catch (err) {

View File

@@ -56,5 +56,8 @@
},
"overrides": {
"rollup": "3.29.5"
},
"dependencies": {
"tough-cookie": "^6.0.0"
}
}

View File

@@ -15,8 +15,8 @@ const addCookieToJar = (setCookieHeader: string, requestUrl: string): void => {
const getCookiesForUrl = (url: string) => {
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
secure: isPotentiallyTrustworthyOrigin(url),
} as any);
};
const getCookieStringForUrl = (url: string): string => {
@@ -195,18 +195,20 @@ const cookieJarWrapper = () => {
if (callback) {
// Callback mode
return cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
return cookieJar.getCookies(url, (err: Error | null, cookies?: Cookie[]) => {
if (err) return callback(err);
const cookie = cookies.find((c) => c.key === cookieName);
const cookieList = cookies || [];
const cookie = cookieList.find((c) => c.key === cookieName);
callback(null, cookie || null);
});
}
// Promise mode
return new Promise<Cookie | null>((resolve, reject) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
cookieJar.getCookies(url, (err: Error | null, cookies?: Cookie[]) => {
if (err) return reject(err);
const cookie = cookies.find((c) => c.key === cookieName);
const cookieList = cookies || [];
const cookie = cookieList.find((c) => c.key === cookieName);
resolve(cookie || null);
});
});
@@ -222,14 +224,14 @@ const cookieJarWrapper = () => {
if (callback) {
// Callback mode
return cookieJar.getCookies(url, callback);
return cookieJar.getCookies(url, callback as any);
}
// Promise mode
return new Promise<Cookie[]>((resolve, reject) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
cookieJar.getCookies(url, (err: Error | null, cookies?: Cookie[]) => {
if (err) return reject(err);
resolve(cookies);
resolve(cookies || []);
});
});
},
@@ -388,11 +390,12 @@ const cookieJarWrapper = () => {
if (callback) {
// Callback mode
return cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
return cookieJar.getCookies(url, (err: Error | null, cookies?: Cookie[]) => {
if (err) return callback(err);
if (!cookies || !cookies.length) return callback(undefined);
const cookieList = cookies || [];
if (!cookieList.length) return callback(undefined);
let pending = cookies.length;
let pending = cookieList.length;
const done = (removeErr?: Error) => {
if (removeErr) return callback(removeErr);
if (--pending === 0) {
@@ -400,7 +403,7 @@ const cookieJarWrapper = () => {
}
};
cookies.forEach((cookie) => {
cookieList.forEach((cookie) => {
(cookieJar as any).store.removeCookie(cookie.domain, cookie.path, cookie.key, done);
});
});
@@ -408,11 +411,12 @@ const cookieJarWrapper = () => {
// Promise mode
return new Promise<void>((resolve, reject) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
cookieJar.getCookies(url, (err: Error | null, cookies?: Cookie[]) => {
if (err) return reject(err);
if (!cookies || !cookies.length) return resolve();
const cookieList = cookies || [];
if (!cookieList.length) return resolve();
let pending = cookies.length;
let pending = cookieList.length;
const done = (removeErr?: Error) => {
if (removeErr) return reject(removeErr);
if (--pending === 0) {
@@ -420,7 +424,7 @@ const cookieJarWrapper = () => {
}
};
cookies.forEach((cookie) => {
cookieList.forEach((cookie) => {
(cookieJar as any).store.removeCookie(cookie.domain, cookie.path, cookie.key, done);
});
});
@@ -435,11 +439,12 @@ const cookieJarWrapper = () => {
}
const executeDelete = (callback: (err?: Error) => void) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
cookieJar.getCookies(url, (err: Error | null, cookies?: Cookie[]) => {
if (err) return callback(err);
// Filter cookies matching key
const matchingCookies = (cookies || []).filter((c) => c.key === cookieName);
const cookieList = cookies || [];
const matchingCookies = cookieList.filter((c) => c.key === cookieName);
if (!matchingCookies.length) return callback(undefined);
const urlPath = new URL(url).pathname || '/';

View File

@@ -155,7 +155,7 @@ export const deleteUidsInItems = (items) => {
each(items, (item) => {
delete item.uid;
if (['http-request', 'graphql-request'].includes(item.type)) {
if (['http-request', 'graphql-request', 'grpc-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.vars.req'), (v) => delete v.uid);

View File

@@ -342,6 +342,10 @@ export const brunoToPostman = (collection) => {
if (!item) {
return null;
}
if (item.type === 'grpc-request') {
return null;
}
if (item.type === 'folder') {
return {

View File

@@ -30,6 +30,8 @@
},
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.7.13",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/filestore": "^0.1.0",
@@ -65,6 +67,7 @@
"nanoid": "3.3.8",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^9.0.0",
"yup": "^0.32.11"
},
@@ -76,4 +79,4 @@
"electron-builder": "25.1.8",
"electron-devtools-installer": "^4.0.0"
}
}
}

View File

@@ -26,12 +26,15 @@ const { openCollection } = require('./app/collections');
const LastOpenedCollections = require('./store/last-opened-collections');
const registerNetworkIpc = require('./ipc/network');
const registerCollectionsIpc = require('./ipc/collection');
const registerFilesystemIpc = require('./ipc/filesystem');
const registerPreferencesIpc = require('./ipc/preferences');
const collectionWatcher = require('./app/collection-watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const { getDomainsWithCookies } = require('./utils/cookies');
const { cookiesStore } = require('./store/cookies');
const lastOpenedCollections = new LastOpenedCollections();
@@ -179,7 +182,7 @@ app.on('ready', async () => {
mainWindow.minimize();
});
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.on('did-finish-load', async () => {
let ogSend = mainWindow.webContents.send;
mainWindow.webContents.send = function(channel, ...args) {
return ogSend.apply(this, [channel, ...args?.map(_ => {
@@ -187,6 +190,14 @@ app.on('ready', async () => {
return safeParseJSON(safeStringifyJSON(_));
})]);
}
// Send cookies list after renderer is ready
try {
cookiesStore.initializeCookies();
const cookiesList = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', cookiesList);
} catch (err) {
console.error('Failed to load cookies for renderer', err);
}
});
// register all ipc handlers
@@ -195,9 +206,18 @@ app.on('ready', async () => {
registerCollectionsIpc(mainWindow, collectionWatcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, collectionWatcher, lastOpenedCollections);
registerNotificationsIpc(mainWindow, collectionWatcher);
registerFilesystemIpc(mainWindow);
});
// Quit the app once all windows are closed
app.on('before-quit', () => {
try {
cookiesStore.saveCookieJar(true);
} catch (err) {
console.warn('Failed to flush cookies on quit', err);
}
});
app.on('window-all-closed', app.quit);
// Open collection from Recent menu (#1521)

View File

@@ -19,6 +19,7 @@ const {
} = require('@usebruno/filestore');
const brunoConverters = require('@usebruno/converters');
const { postmanToBruno } = brunoConverters;
const { cookiesStore } = require('../store/cookies');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const {
@@ -53,7 +54,7 @@ const interpolateVars = require('./network/interpolate-vars');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2');
const { getCertsAndProxyConfig } = require('./network');
const { getCertsAndProxyConfig } = require('./network/cert-utils');
const collectionWatcher = require('../app/collection-watcher');
const environmentSecretsStore = new EnvironmentSecretsStore();
@@ -90,24 +91,6 @@ const validatePathIsInsideCollection = (filePath, lastOpenedCollections) => {
}
const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
// browse directory
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
try {
return await browseDirectory(mainWindow);
} catch (error) {
return Promise.reject(error);
}
});
// browse directory for file
ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
try {
return await browseFiles(mainWindow, filters, properties);
} catch (error) {
throw error;
}
});
// create collection
ipcMain.handle(
'renderer:create-collection',
@@ -572,7 +555,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
fs.rmSync(pathname, { recursive: true, force: true });
} else if (['http-request', 'graphql-request'].includes(type)) {
} else if (['http-request', 'graphql-request', 'grpc-request'].includes(type)) {
if (!fs.existsSync(pathname)) {
return Promise.reject(new Error('The file does not exist'));
}
@@ -618,7 +601,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
const content = await stringifyRequestViaWorker(item);
const filePath = path.join(currentPath, sanitizedFilename);
@@ -890,12 +873,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
const updateCookiesAndNotify = async () => {
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send(
'main:cookies-update',
safeParseJSON(safeStringifyJSON(domainsWithCookies))
);
cookiesStore.saveCookieJar();
};
// Delete all cookies for a domain
ipcMain.handle('renderer:delete-cookies-for-domain', async (event, domain) => {
try {
await deleteCookiesForDomain(domain);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}
@@ -904,8 +895,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => {
try {
await deleteCookie(domain, path, cookieKey);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}
@@ -915,8 +905,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => {
try {
await addCookieForDomain(domain, cookie);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}
@@ -926,8 +915,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => {
try {
await modifyCookieForDomain(domain, oldCookie, cookie);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}

View File

@@ -0,0 +1,53 @@
const { ipcMain } = require('electron');
const fs = require('fs');
const fsPromises = require('fs/promises');
const path = require('node:path');
const {
browseDirectory,
browseFiles,
normalizeAndResolvePath,
isFile
} = require('../utils/filesystem');
const registerFilesystemIpc = (mainWindow) => {
// Browse directory
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
try {
return await browseDirectory(mainWindow);
} catch (error) {
return Promise.reject(error);
}
});
// Browse files
ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
try {
return await browseFiles(mainWindow, filters, properties);
} catch (error) {
throw error;
}
});
// Check if file exists
ipcMain.handle('renderer:exists-sync', async (_, filePath) => {
try {
const normalizedPath = normalizeAndResolvePath(filePath);
return isFile(normalizedPath);
} catch (error) {
return false;
}
});
// Resolve path
ipcMain.handle('renderer:resolve-path', async (_, relativePath, basePath) => {
try {
const resolvedPath = path.resolve(basePath, relativePath);
return normalizeAndResolvePath(resolvedPath);
} catch (error) {
return relativePath;
}
});
};
module.exports = registerFilesystemIpc;

View File

@@ -0,0 +1,114 @@
const fs = require('fs');
const tls = require('tls');
const path = require('path');
const { get } = require('lodash');
const { preferencesUtil } = require('../../store/preferences');
const { getBrunoConfig } = require('../../store/bruno-config');
const { interpolateString } = require('./interpolate-string');
/**
* Gets certificates and proxy configuration for a request
*/
const getCertsAndProxyConfig = async ({
collectionUid,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
}) => {
/**
* @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors
* @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+
*/
const httpsAgentRequestFields = { keepAlive: true };
if (!preferencesUtil.shouldVerifyTls()) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
}
if (preferencesUtil.shouldUseCustomCaCertificate()) {
const caCertFilePath = preferencesUtil.getCustomCaCertificateFilePath();
if (caCertFilePath) {
let caCertBuffer = fs.readFileSync(caCertFilePath);
if (preferencesUtil.shouldKeepDefaultCaCertificates()) {
caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
}
httpsAgentRequestFields['ca'] = caCertBuffer;
}
}
const brunoConfig = getBrunoConfig(collectionUid);
const interpolationOptions = {
envVars,
runtimeVariables,
processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
const domain = interpolateString(clientCert?.domain, interpolationOptions);
const type = clientCert?.type || 'cert';
if (domain) {
const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)?' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
const requestUrl = interpolateString(request.url, interpolationOptions);
if (requestUrl.match(hostRegex)) {
if (type === 'cert') {
try {
let certFilePath = interpolateString(clientCert?.certFilePath, interpolationOptions);
certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
let keyFilePath = interpolateString(clientCert?.keyFilePath, interpolationOptions);
keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
} catch (err) {
console.error('Error reading cert/key file', err);
throw new Error('Error reading cert/key file' + err);
}
} else if (type === 'pfx') {
try {
let pfxFilePath = interpolateString(clientCert?.pfxFilePath, interpolationOptions);
pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath);
httpsAgentRequestFields['pfx'] = fs.readFileSync(pfxFilePath);
} catch (err) {
console.error('Error reading pfx file', err);
throw new Error('Error reading pfx file' + err);
}
}
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
}
}
}
/**
* Proxy configuration
*
* Preferences proxyMode has three possible values: on, off, system
* Collection proxyMode has three possible values: true, false, global
*
* When collection proxyMode is true, it overrides the app-level proxy settings
* When collection proxyMode is false, it ignores the app-level proxy settings
* When collection proxyMode is global, it uses the app-level proxy settings
*
* Below logic calculates the proxyMode and proxyConfig to be used for the request
*/
let proxyMode = 'off';
let proxyConfig = {};
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', 'global');
if (collectionProxyEnabled === true) {
proxyConfig = collectionProxyConfig;
proxyMode = 'on';
} else if (collectionProxyEnabled === 'global') {
proxyConfig = preferencesUtil.getGlobalProxyConfig();
proxyMode = get(proxyConfig, 'mode', 'off');
}
return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
}
module.exports = { getCertsAndProxyConfig };

View File

@@ -0,0 +1,574 @@
// To implement grpc event handlers
const { ipcMain, app } = require('electron');
const { GrpcClient } = require("@usebruno/requests")
const { safeParseJSON, safeStringifyJSON } = require('../../utils/common');
const { cloneDeep, each, get } = require('lodash');
const interpolateVars = require('./interpolate-vars');
const { preferencesUtil } = require('../../store/preferences');
const { getCertsAndProxyConfig } = require('./cert-utils');
const { getEnvVars, getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, mergeAuth, getFormattedCollectionOauth2Credentials } = require('../../utils/collection');
const { getProcessEnvVars } = require('../../store/process-env');
const { getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingAuthorizationCode } = require('../../utils/oauth2');
const { interpolateString } = require('./interpolate-string');
const path = require('node:path');
const setGrpcAuthHeaders = (grpcRequest, request, collectionRoot) => {
const collectionAuth = get(collectionRoot, 'request.auth');
if (collectionAuth && request.auth?.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
grpcRequest.basicAuth = {
username: get(collectionAuth, 'basic.username'),
password: get(collectionAuth, 'basic.password')
};
}
if (collectionAuth.mode === 'bearer') {
grpcRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
}
if (collectionAuth.mode === 'apikey') {
grpcRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value;
}
if (collectionAuth.mode === 'oauth2') {
const grantType = get(collectionAuth, 'oauth2.grantType');
if (grantType === 'client_credentials') {
grpcRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
};
} else if (grantType === 'password') {
grpcRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
username: get(collectionAuth, 'oauth2.username'),
password: get(collectionAuth, 'oauth2.password'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
};
}
}
}
if (request.auth && request.auth.mode !== 'inherit') {
if (request.auth.mode === 'basic') {
grpcRequest.basicAuth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
}
if (request.auth.mode === 'bearer') {
grpcRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
}
if (request.auth.mode === 'oauth2') {
const grantType = get(request, 'auth.oauth2.grantType');
if (grantType === 'client_credentials') {
grpcRequest.oauth2 = {
grantType,
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
};
} else if (grantType === 'password') {
grpcRequest.oauth2 = {
grantType,
username: get(request, 'auth.oauth2.username'),
password: get(request, 'auth.oauth2.password'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
};
} else if (grantType === 'authorization_code') {
grpcRequest.oauth2 = {
grantType,
...get(request, 'auth.oauth2')
};
}
}
if (request.auth.mode === 'apikey') {
grpcRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
}
}
return grpcRequest;
}
const prepareRequest = async (item, collection, environment, runtimeVariables, certsAndProxyConfig = {}) => {
const request = item.draft ? item.draft.request : item.request;
const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {});
const headers = {};
const url = request.url;
each(get(collectionRoot, 'request.headers', []), (h) => {
if (h.enabled && h.name?.toLowerCase() === 'content-type') {
contentTypeDefined = true;
return false;
}
});
const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) {
mergeAuth(collection, request, requestTreePath);
mergeHeaders(collection, request, requestTreePath);
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
}
each(get(request, 'headers', []), (h) => {
if (h.enabled && h.name.length > 0) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
contentTypeDefined = true;
}
}
});
const processEnvVars = getProcessEnvVars(collection.uid);
const envVars = getEnvVars(environment);
let grpcRequest = {
uid: item.uid,
mode: request.body.mode,
method: request.method,
methodType: request.methodType,
url,
headers,
processEnvVars,
envVars,
runtimeVariables,
body: request.body,
protoPath: request.protoPath,
// Add variable properties for interpolation
vars: request.vars,
collectionVariables: request.collectionVariables,
folderVariables: request.folderVariables,
requestVariables: request.requestVariables,
globalEnvironmentVariables: request.globalEnvironmentVariables,
oauth2CredentialVariables: request.oauth2CredentialVariables,
}
grpcRequest = setGrpcAuthHeaders(grpcRequest, request, collectionRoot);
if (grpcRequest.oauth2) {
let requestCopy = cloneDeep(grpcRequest);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
let credentials, credentialsId;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
grpcRequest.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
}
catch(error) {}
}
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
grpcRequest.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
}
catch(error) {}
}
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
grpcRequest.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
}
catch(error) {}
}
break;
}
}
interpolateVars(grpcRequest, envVars, runtimeVariables, processEnvVars);
return grpcRequest;
}
// Creating grpcClient at module level so it can be accessed from window-all-closed event
let grpcClient;
/**
* Register IPC handlers for gRPC
*/
const registerGrpcEventHandlers = (window) => {
const sendEvent = (eventName, ...args) => {
if (window && window.webContents) {
window.webContents.send(eventName, ...args);
} else {
console.warn(`Unable to send message "${eventName}": Window not available`);
}
};
grpcClient = new GrpcClient(sendEvent);
ipcMain.handle('connections-changed', (event) => {
sendEvent('grpc:connections-changed', event);
});
// Start a new gRPC connection
ipcMain.handle('grpc:start-connection', async (event, { request, collection, environment, runtimeVariables }) => {
try {
const requestCopy = cloneDeep(request);
const preparedRequest = await prepareRequest(requestCopy, collection, environment, runtimeVariables, {});
// Get certificates and proxy configuration
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid: collection.uid,
request: requestCopy.request,
envVars: preparedRequest.envVars,
runtimeVariables,
processEnvVars: preparedRequest.processEnvVars,
collectionPath: collection.pathname
});
// Extract certificate information from the config
const { httpsAgentRequestFields } = certsAndProxyConfig;
// Configure verify options
const verifyOptions = {
rejectUnauthorized: preferencesUtil.shouldVerifyTls()
};
// Extract certificate information
const rootCertificate = httpsAgentRequestFields.ca;
const privateKey = httpsAgentRequestFields.key;
const certificateChain = httpsAgentRequestFields.cert;
const passphrase = httpsAgentRequestFields.passphrase;
const pfx = httpsAgentRequestFields.pfx;
const requestSent = {
type: "request",
url: preparedRequest.url,
method: preparedRequest.method,
methodType: preparedRequest.methodType,
headers: preparedRequest.headers,
body: preparedRequest.body,
timestamp: Date.now()
}
// Start gRPC connection with the processed request and certificates
await grpcClient.startConnection({
request: preparedRequest,
collection,
rootCertificate,
privateKey,
certificateChain,
passphrase,
pfx,
verifyOptions
});
sendEvent('grpc:request', preparedRequest.uid, collection.uid, requestSent);
// Send OAuth credentials update if available
if (preparedRequest?.oauth2Credentials) {
window.webContents.send('main:credentials-update', {
credentials: preparedRequest.oauth2Credentials?.credentials,
url: preparedRequest.oauth2Credentials?.url,
collectionUid: collection.uid,
credentialsId: preparedRequest.oauth2Credentials?.credentialsId,
...(preparedRequest.oauth2Credentials?.folderUid ? { folderUid: preparedRequest.oauth2Credentials.folderUid } : { itemUid: preparedRequest.uid }),
debugInfo: preparedRequest.oauth2Credentials.debugInfo,
});
}
return { success: true };
} catch (error) {
console.error('Error starting gRPC connection:', error);
if (error instanceof Error) {
throw error;
}
sendEvent('grpc:error', request.uid, collection.uid, { error: error.message });
return { success: false, error: error.message };
}
});
// Get all active connection IDs
ipcMain.handle('grpc:get-active-connections', (event) => {
try {
const activeConnectionIds = grpcClient.getActiveConnectionIds();
return { success: true, activeConnectionIds };
} catch (error) {
console.error('Error getting active connections:', error);
return { success: false, error: error.message, activeConnectionIds: [] };
}
});
// Send a message to an existing stream
ipcMain.handle('grpc:send-message', (event, requestId, collectionUid, message) => {
try {
grpcClient.sendMessage(requestId, collectionUid, message);
sendEvent('grpc:message', requestId, collectionUid, message);
return { success: true };
} catch (error) {
console.error('Error sending gRPC message:', error);
return { success: false, error: error.message };
}
});
// End a streaming request
ipcMain.handle('grpc:end-request', (event, params) => {
try {
const { requestId } = params || {};
if (!requestId) {
throw new Error('Request ID is required');
}
grpcClient.end(requestId);
return { success: true };
} catch (error) {
console.error('Error ending gRPC stream:', error);
return { success: false, error: error.message };
}
});
// Cancel a request
ipcMain.handle('grpc:cancel-request', (event, params) => {
try {
const { requestId } = params || {};
if (!requestId) {
throw new Error('Request ID is required');
}
grpcClient.cancel(requestId);
return { success: true };
} catch (error) {
console.error('Error cancelling gRPC request:', error);
return { success: false, error: error.message };
}
});
// Load methods from server reflection
ipcMain.handle('grpc:load-methods-reflection', async (event, { request, collection, environment, runtimeVariables }) => {
try {
const requestCopy = cloneDeep(request);
const preparedRequest = await prepareRequest(requestCopy, collection, environment, runtimeVariables);
// Get certificates and proxy configuration
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid: collection.uid,
request: requestCopy.request,
envVars: preparedRequest.envVars,
runtimeVariables,
processEnvVars: preparedRequest.processEnvVars,
collectionPath: collection.pathname
});
// Extract certificate information from the config
const { httpsAgentRequestFields } = certsAndProxyConfig;
// Configure verify options
const verifyOptions = {
rejectUnauthorized: preferencesUtil.shouldVerifyTls()
};
// Extract certificate information
const rootCertificate = httpsAgentRequestFields.ca;
const privateKey = httpsAgentRequestFields.key;
const certificateChain = httpsAgentRequestFields.cert;
const passphrase = httpsAgentRequestFields.passphrase;
const pfx = httpsAgentRequestFields.pfx;
// Send OAuth credentials update if available
if (preparedRequest?.oauth2Credentials) {
window.webContents.send('main:credentials-update', {
credentials: preparedRequest.oauth2Credentials?.credentials,
url: preparedRequest.oauth2Credentials?.url,
collectionUid: collection.uid,
credentialsId: preparedRequest.oauth2Credentials?.credentialsId,
...(preparedRequest.oauth2Credentials?.folderUid ? { folderUid: preparedRequest.oauth2Credentials.folderUid } : { itemUid: preparedRequest.uid }),
debugInfo: preparedRequest.oauth2Credentials.debugInfo,
});
}
const methods = await grpcClient.loadMethodsFromReflection({
request: preparedRequest,
collectionUid: collection.uid,
rootCertificate,
privateKey,
certificateChain,
passphrase,
pfx,
verifyOptions,
sendEvent
});
return { success: true, methods: safeParseJSON(safeStringifyJSON(methods))};
} catch (error) {
console.error('Error loading gRPC methods from reflection:', error);
return { success: false, error: error.message };
}
});
// Load methods from proto file
ipcMain.handle('grpc:load-methods-proto', async (event, { filePath, includeDirs }) => {
try {
const methods = await grpcClient.loadMethodsFromProtoFile(filePath, includeDirs);
return { success: true, methods: safeParseJSON(safeStringifyJSON(methods))};
} catch (error) {
console.error('Error loading gRPC methods from proto file:', error);
return { success: false, error: error.message };
}
});
// Generate a sample gRPC message based on method path
ipcMain.handle('grpc:generate-sample-message', async (event, { methodPath, existingMessage, options = {} }) => {
try {
// Generate the sample message
const result = grpcClient.generateSampleMessage(methodPath, {
...options,
// Parse existing message if provided
existingMessage: existingMessage ? safeParseJSON(existingMessage) : null
});
if (!result.success) {
return {
success: false,
error: result.error || 'Failed to generate sample message'
};
}
// Convert the message to a JSON string for safe transfer through IPC
return {
success: true,
message: JSON.stringify(result.message, null, 2)
};
} catch (error) {
console.error('Error generating gRPC sample message:', error);
return {
success: false,
error: error.message || 'Failed to generate sample message'
};
}
});
// Generate grpcurl command for a request
ipcMain.handle('grpc:generate-grpcurl', async (event, { request, collection, environment, runtimeVariables }) => {
try {
const requestCopy = cloneDeep(request);
const preparedRequest = await prepareRequest(requestCopy, collection, environment, runtimeVariables, {});
const interpolationOptions = {
envVars: preparedRequest.envVars,
runtimeVariables,
processEnvVars: preparedRequest.processEnvVars
};
let caCertFilePath, certFilePath, keyFilePath;
if(preferencesUtil.shouldUseCustomCaCertificate()) {
caCertFilePath = preferencesUtil.getCustomCaCertificateFilePath();
}
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
const domain = interpolateString(clientCert?.domain, interpolationOptions);
const type = clientCert?.type || 'cert';
if (domain) {
const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
const requestUrl = interpolateString(preparedRequest.url, interpolationOptions);
if (requestUrl.match(hostRegex)) {
if (type === 'cert') {
certFilePath = interpolateString(clientCert?.certFilePath, interpolationOptions);
certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collection.pathname, certFilePath);
keyFilePath = interpolateString(clientCert?.keyFilePath, interpolationOptions);
keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collection.pathname, keyFilePath);
}
}
}
}
// Generate the grpcurl command
const command = grpcClient.generateGrpcurlCommand({
request: preparedRequest,
collectionPath: collection.pathname,
certificates: {
ca: caCertFilePath,
cert: certFilePath,
key: keyFilePath
}
});
return { success: true, command };
} catch (error) {
console.error('Error generating grpcurl command:', error);
return { success: false, error: error.message };
}
});
};
// Clean up gRPC connections when all windows are closed
if (app && typeof app.on === 'function') {
app.on('window-all-closed', () => {
if (grpcClient && typeof grpcClient.clearAllConnections === 'function') {
try {
grpcClient.clearAllConnections();
} catch (error) {
console.error('Error clearing gRPC connections:', error);
}
}
});
}
module.exports = registerGrpcEventHandlers

View File

@@ -3,8 +3,6 @@ const https = require('https');
const axios = require('axios');
const path = require('path');
const decomment = require('decomment');
const fs = require('fs');
const tls = require('tls');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
const FormData = require('form-data');
@@ -32,6 +30,9 @@ const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const Oauth2Store = require('../../store/oauth2');
const { isRequestTagsIncluded } = require('@usebruno/common');
const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { getCertsAndProxyConfig } = require('./cert-utils');
const saveCookies = (url, headers) => {
if (preferencesUtil.shouldStoreCookies()) {
@@ -54,107 +55,6 @@ const getJsSandboxRuntime = (collection) => {
return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2';
};
const getCertsAndProxyConfig = async ({
collectionUid,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath
}) => {
/**
* @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors
* @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+
*/
const httpsAgentRequestFields = { keepAlive: true };
if (!preferencesUtil.shouldVerifyTls()) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
}
if (preferencesUtil.shouldUseCustomCaCertificate()) {
const caCertFilePath = preferencesUtil.getCustomCaCertificateFilePath();
if (caCertFilePath) {
let caCertBuffer = fs.readFileSync(caCertFilePath);
if (preferencesUtil.shouldKeepDefaultCaCertificates()) {
caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
}
httpsAgentRequestFields['ca'] = caCertBuffer;
}
}
const brunoConfig = getBrunoConfig(collectionUid);
const interpolationOptions = {
envVars,
runtimeVariables,
processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
const domain = interpolateString(clientCert?.domain, interpolationOptions);
const type = clientCert?.type || 'cert';
if (domain) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
if (request.url.match(hostRegex)) {
if (type === 'cert') {
try {
let certFilePath = interpolateString(clientCert?.certFilePath, interpolationOptions);
certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
let keyFilePath = interpolateString(clientCert?.keyFilePath, interpolationOptions);
keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
} catch (err) {
console.error('Error reading cert/key file', err);
throw new Error('Error reading cert/key file' + err);
}
} else if (type === 'pfx') {
try {
let pfxFilePath = interpolateString(clientCert?.pfxFilePath, interpolationOptions);
pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath);
httpsAgentRequestFields['pfx'] = fs.readFileSync(pfxFilePath);
} catch (err) {
console.error('Error reading pfx file', err);
throw new Error('Error reading pfx file' + err);
}
}
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
}
}
}
/**
* Proxy configuration
*
* Preferences proxyMode has three possible values: on, off, system
* Collection proxyMode has three possible values: true, false, global
*
* When collection proxyMode is true, it overrides the app-level proxy settings
* When collection proxyMode is false, it ignores the app-level proxy settings
* When collection proxyMode is global, it uses the app-level proxy settings
*
* Below logic calculates the proxyMode and proxyConfig to be used for the request
*/
let proxyMode = 'off';
let proxyConfig = {};
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', 'global');
if (collectionProxyEnabled === true) {
proxyConfig = collectionProxyConfig;
proxyMode = 'on';
} else if (collectionProxyEnabled === 'global') {
proxyConfig = preferencesUtil.getGlobalProxyConfig();
proxyMode = get(proxyConfig, 'mode', 'off');
}
return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
}
const configureRequest = async (
collectionUid,
request,
@@ -467,6 +367,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
@@ -541,6 +446,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: result.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: result.globalEnvironmentVariables
});
@@ -582,6 +492,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
@@ -771,6 +686,7 @@ const registerNetworkIpc = (mainWindow) => {
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
let postResponseScriptResult = null;
let postResponseError = null;
@@ -885,6 +801,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: testResults.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
@@ -900,6 +821,7 @@ const registerNetworkIpc = (mainWindow) => {
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
cookiesStore.saveCookieJar();
}
return {
@@ -1062,6 +984,24 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
// Skip gRPC requests
if (item.type === 'grpc-request') {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'gRPC requests are skipped in folder/collection runs',
responseReceived: {
status: 'skipped',
statusText: 'gRPC request skipped',
data: null,
responseTime: 0,
headers: null
},
...eventData
});
currentRequestIndex++;
continue;
}
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
@@ -1547,7 +1487,13 @@ const executeRequestOnFailHandler = async (request, error) => {
}
};
module.exports = registerNetworkIpc;
const registerAllNetworkIpc = (mainWindow) => {
registerNetworkIpc(mainWindow);
registerGrpcEventHandlers(mainWindow);
}
module.exports = registerAllNetworkIpc
module.exports.configureRequest = configureRequest;
module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig;
module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler;

View File

@@ -28,7 +28,6 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
envVariables[key] = interpolate(value, {
process: {
env: {
...processEnvVars
}
}
});
@@ -68,6 +67,15 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
const contentType = getContentType(request.headers);
const isGrpcBody = request.mode === 'grpc';
if (isGrpcBody) {
const jsonDoc = JSON.stringify(request.body);
const parsed = _interpolate(jsonDoc, {
escapeJSONStrings: true
});
request.body = JSON.parse(parsed);
}
if (typeof contentType === 'string') {
/*

Some files were not shown because too many files have changed in this diff Show More