Feat: support bin header in gRPC (#5869)

* Fix -bin header handling in grpc

* fix: bin-header, tests

rm: tests

rm: unused

fix: bin header

fix: test

fix: test

rm: un-necessarycode

---------

Co-authored-by: Juan Pablo Orsay <jporsay@gmail.com>
This commit is contained in:
sanish chirayath
2025-10-31 17:07:12 +05:30
committed by GitHub
parent f3afb4bf84
commit 08c182a875
14 changed files with 320 additions and 140 deletions

View File

@@ -408,6 +408,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
{(!isConnectionActive || !isStreamingMethod) && (
<div
className="cursor-pointer"
data-testid="grpc-send-request-button"
onClick={(e) => {
e.stopPropagation();
handleRun(e);

View File

@@ -18,8 +18,8 @@ const GrpcStatusCode = ({ status, text }) => {
return (
<StyledWrapper className={getTabClassname(status)}>
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
{statusText && <div>{statusText}</div>}
{Number.isInteger(status) ? <div className="mr-1" data-testid="grpc-response-status-code">{status}</div> : null}
{statusText && <div data-testid="grpc-response-status-text">{statusText}</div>}
</StyledWrapper>
);
};

View File

@@ -83,7 +83,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
{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 className="text-xs overflow-hidden text-ellipsis">{typeof value === 'string' ? value : '[Buffer Buffer]'}</div>
</div>
))}
</div>

View File

@@ -2,137 +2,12 @@
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 { cloneDeep, get } = require('lodash');
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 { setAuthHeaders } = require('./prepare-request');
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;
let contentTypeDefined = false;
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 = setAuthHeaders(grpcRequest, request, collectionRoot);
if (grpcRequest.oauth2) {
let requestCopy = cloneDeep(grpcRequest);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
let credentials, credentialsId, oauth2Url, debugInfo;
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;
}
const prepareGrpcRequest = require('./prepare-grpc-request');
// Creating grpcClient at module level so it can be accessed from window-all-closed event
let grpcClient;
@@ -162,7 +37,7 @@ const registerGrpcEventHandlers = (window) => {
const requestCopy = cloneDeep(request);
const preparedRequest = await prepareRequest(requestCopy, collection, environment, runtimeVariables, {});
const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables, {});
// Get certificates and proxy configuration
const certsAndProxyConfig = await getCertsAndProxyConfig({
@@ -294,7 +169,7 @@ const registerGrpcEventHandlers = (window) => {
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);
const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables);
// Get certificates and proxy configuration
const certsAndProxyConfig = await getCertsAndProxyConfig({
@@ -401,7 +276,7 @@ const registerGrpcEventHandlers = (window) => {
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 preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables, {});
const interpolationOptions = {
envVars: preparedRequest.envVars,
runtimeVariables,

View File

@@ -1,5 +1,5 @@
const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash');
const { each, forOwn, cloneDeep } = require('lodash');
const FormData = require('form-data');
const getContentType = (headers = {}) => {
@@ -65,6 +65,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
};
request.url = _interpolate(request.url);
const isGrpcRequest = request.mode === 'grpc';
forOwn(request.headers, (value, key) => {
delete request.headers[key];
@@ -72,9 +73,8 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
const contentType = getContentType(request.headers);
const isGrpcBody = request.mode === 'grpc';
if (isGrpcBody) {
if (isGrpcRequest) {
const jsonDoc = JSON.stringify(request.body);
const parsed = _interpolate(jsonDoc, {
escapeJSONStrings: true

View File

@@ -0,0 +1,121 @@
const { cloneDeep, each, get } = require('lodash');
const interpolateVars = require('./interpolate-vars');
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 { setAuthHeaders } = require('./prepare-request');
const processHeaders = (headers) => {
Object.entries(headers).forEach(([key, value]) => {
if (key?.toLowerCase().endsWith('-bin')) {
headers[key] = Buffer.from(value, 'base64');
}
});
};
const prepareGrpcRequest = 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;
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;
}
});
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 = setAuthHeaders(grpcRequest, request, collectionRoot);
if (grpcRequest.oauth2) {
let requestCopy = cloneDeep(grpcRequest);
const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
let credentials, credentialsId, oauth2Url, debugInfo;
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);
processHeaders(grpcRequest.headers);
return grpcRequest;
};
module.exports = prepareGrpcRequest;

View File

@@ -198,8 +198,8 @@ describe('interpolate-vars: interpolateVars', () => {
describe('With gRPC requests and all variable types', () => {
it('Should interpolate collection variables, global environment variables, etc. in gRPC requests', async () => {
const request = {
method: '/random.Service/randomMethod',
const request = {
method: '/random.Service/randomMethod',
url: '{{baseUrl}}/{{service}}/{{method}}',
mode: 'grpc',
body: {
@@ -225,8 +225,8 @@ describe('interpolate-vars: interpolateVars', () => {
});
it('Should handle gRPC requests with global environment variables', async () => {
const request = {
method: '/random.Service/randomMethod',
const request = {
method: '/random.Service/randomMethod',
url: '{{globalBaseUrl}}/{{service}}',
mode: 'grpc',
body: {

View File

@@ -0,0 +1,94 @@
const { describe, it, expect, beforeEach } = require('@jest/globals');
// Mock dependencies
jest.mock('../../src/ipc/network/interpolate-vars');
jest.mock('../../src/utils/collection');
jest.mock('../../src/store/process-env');
jest.mock('../../src/utils/oauth2');
jest.mock('../../src/ipc/network/prepare-request');
const prepareGrpcRequest = require('../../src/ipc/network/prepare-grpc-request');
const interpolateVars = require('../../src/ipc/network/interpolate-vars');
const { getEnvVars, getTreePathFromCollectionToItem } = require('../../src/utils/collection');
const { getProcessEnvVars } = require('../../src/store/process-env');
const { setAuthHeaders } = require('../../src/ipc/network/prepare-request');
describe('prepare-grpc-request: prepareGrpcRequest', () => {
let mockItem;
let mockCollection;
let mockEnvironment;
let mockRuntimeVariables;
beforeEach(() => {
jest.clearAllMocks();
getEnvVars.mockReturnValue({});
getTreePathFromCollectionToItem.mockReturnValue([]);
getProcessEnvVars.mockReturnValue({});
setAuthHeaders.mockImplementation((request) => request);
interpolateVars.mockImplementation((request) => request);
mockItem = {
uid: 'test-item-uid',
request: {
method: 'POST',
methodType: 'unary',
url: 'grpc://localhost:50051',
headers: [],
body: {
mode: 'json',
json: '{"test": "data"}'
},
protoPath: '/path/to/proto.proto',
auth: { mode: 'none' }
}
};
mockCollection = {
uid: 'test-collection-uid',
root: {
request: {
headers: []
}
},
brunoConfig: {
scripts: {
flow: 'sandwich'
}
}
};
mockEnvironment = {};
mockRuntimeVariables = {};
});
describe('Header processing', () => {
it('should keep regular headers as strings', async () => {
mockItem.request.headers = [
{ name: 'content-type', value: 'application/grpc', enabled: true },
{ name: 'authorization', value: 'Bearer token123', enabled: true },
{ name: 'user-agent', value: 'bruno-client', enabled: true }
];
const result = await prepareGrpcRequest(mockItem, mockCollection, mockEnvironment, mockRuntimeVariables);
expect(result.headers['content-type']).toBe('application/grpc');
expect(result.headers['authorization']).toBe('Bearer token123');
expect(result.headers['user-agent']).toBe('bruno-client');
expect(typeof result.headers['content-type']).toBe('string');
expect(typeof result.headers['authorization']).toBe('string');
expect(typeof result.headers['user-agent']).toBe('string');
});
it('should skip disabled headers', async () => {
mockItem.request.headers = [
{ name: 'content-type', value: 'application/grpc', enabled: false },
{ name: 'authorization', value: 'Bearer token123', enabled: false }
];
const result = await prepareGrpcRequest(mockItem, mockCollection, mockEnvironment, mockRuntimeVariables);
expect(result.headers).toEqual({});
});
});
});

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Grpcbin",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,27 @@
meta {
name: SayHello
type: grpc
seq: 1
}
grpc {
url: grpc://grpcb.in:9000
method: /hello.HelloService/SayHello
body: grpc
auth: inherit
methodType: unary
}
metadata {
test-bin: hello
test: hello
}
body:grpc {
name: message 1
content: '''
{
"greeting": "amoveo"
}
'''
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/grpc/metadata/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,11 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/tests/grpc/metadata/fixtures/collection"
],
"preferences": {
"beta": {
"nodevm": false
}
}
}

View File

@@ -0,0 +1,32 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('grpc metadata', () => {
test.afterAll(async ({ pageWithUserData: page }) => {
await closeAllCollections(page);
});
test('should handle binary metadata', async ({ pageWithUserData: page }) => {
await test.step('Open the request', async () => {
const collection = page.locator('#sidebar-collection-name').filter({ hasText: 'Grpcbin' });
await collection.click();
const request = page.locator('.collection-item-name').filter({ hasText: 'SayHello' });
await request.click();
});
await test.step('Verify request sent successfully', async () => {
await page.getByTestId('grpc-send-request-button').click();
const statusCode = page.getByTestId('grpc-response-status-code');
const statusText = page.getByTestId('grpc-response-status-text');
await expect(statusCode).toBeVisible({ timeout: 30000 });
await expect(statusText).toBeVisible({ timeout: 30000 });
await expect(statusCode).toHaveText(/0/);
await expect(statusText).toHaveText(/OK/);
});
/* TODO: Reflection fetching incorrectly marks requests as modified, causing save indicators to appear. This save step prevents test timeouts by clearing the modified state. This is a temporary workaround until the reflection fetching issue is resolved. */
await test.step('save request via shortcut', async () => {
await page.keyboard.press('Meta+s');
});
});
});