mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: add user-agent support in gRPC client channel options (#6808)
* feat: add user-agent support in gRPC client channel options - Extracted user-agent from request headers and set it as grpc.primary_user_agent channel option. - Updated client instantiation to merge user-agent with existing channel options for enhanced request handling. * test: add unit tests for GrpcClient user-agent handling * test: enhance GrpcClient user-agent tests with edge case handling * test: enhance GrpcClient channelOptions handling with override capability
This commit is contained in:
@@ -528,8 +528,19 @@ class GrpcClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user-agent from headers if provided (case-insensitive)
|
||||
// Set it as grpc.primary_user_agent channel option to prepend to the default user-agent
|
||||
const userAgentKey = Object.keys(request.headers).find(
|
||||
(key) => key.toLowerCase() === 'user-agent'
|
||||
);
|
||||
const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null;
|
||||
|
||||
const mergedChannelOptions = userAgentValue
|
||||
? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions }
|
||||
: channelOptions;
|
||||
|
||||
const Client = makeGenericClientConstructor({});
|
||||
const client = new Client(host, credentials, channelOptions);
|
||||
const client = new Client(host, credentials, mergedChannelOptions);
|
||||
if (!client) {
|
||||
throw new Error('Failed to create client');
|
||||
}
|
||||
@@ -612,9 +623,19 @@ class GrpcClient {
|
||||
passphrase,
|
||||
pfx,
|
||||
verifyOptions,
|
||||
sendEvent
|
||||
sendEvent,
|
||||
channelOptions = {}
|
||||
}) {
|
||||
const { host, path } = getParsedGrpcUrlObject(request.url);
|
||||
|
||||
// Extract user-agent from headers if provided (case-insensitive)
|
||||
// Set it as grpc.primary_user_agent channel option to prepend to the default user-agent
|
||||
const userAgentKey = Object.keys(request.headers).find(
|
||||
(key) => key.toLowerCase() === 'user-agent'
|
||||
);
|
||||
const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null;
|
||||
const mergedChannelOptions = userAgentValue ? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions } : channelOptions;
|
||||
|
||||
const metadata = new Metadata();
|
||||
Object.entries(request.headers).forEach(([name, value]) => {
|
||||
metadata.add(name, value);
|
||||
@@ -630,7 +651,7 @@ class GrpcClient {
|
||||
});
|
||||
|
||||
try {
|
||||
const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, {});
|
||||
const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, mergedChannelOptions);
|
||||
|
||||
const methods = [];
|
||||
for (const service of services) {
|
||||
|
||||
508
packages/bruno-requests/src/grpc/grpc-client.spec.js
Normal file
508
packages/bruno-requests/src/grpc/grpc-client.spec.js
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
// Store captured channel options for assertions
|
||||
let capturedChannelOptions = null;
|
||||
|
||||
// Mock GrpcReflection to capture options
|
||||
const mockListServices = jest.fn().mockResolvedValue(['test.Service']);
|
||||
const mockListMethods = jest.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/test.Service/TestMethod',
|
||||
definition: {
|
||||
requestStream: false,
|
||||
responseStream: false
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
jest.mock('grpc-js-reflection-client', () => ({
|
||||
GrpcReflection: jest.fn().mockImplementation((host, credentials, options) => {
|
||||
capturedChannelOptions = options;
|
||||
return {
|
||||
listServices: mockListServices,
|
||||
listMethods: mockListMethods
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock @grpc/grpc-js
|
||||
jest.mock('@grpc/grpc-js', () => {
|
||||
const createMockMetadata = () => {
|
||||
const map = {};
|
||||
return {
|
||||
add: jest.fn((key, value) => {
|
||||
if (map[key] === undefined) {
|
||||
map[key] = value;
|
||||
} else if (Array.isArray(map[key])) {
|
||||
map[key].push(value);
|
||||
} else {
|
||||
map[key] = [map[key], value];
|
||||
}
|
||||
}),
|
||||
getMap: jest.fn(() => map)
|
||||
};
|
||||
};
|
||||
|
||||
// Create a mock RPC object with event emitter interface
|
||||
const createMockRpc = () => {
|
||||
const handlers = {};
|
||||
const mockRpc = {
|
||||
on: jest.fn((event, handler) => {
|
||||
handlers[event] = handler;
|
||||
return mockRpc; // Return the mock object for chaining
|
||||
}),
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
call: {
|
||||
channel: { close: jest.fn() }
|
||||
}
|
||||
};
|
||||
return mockRpc;
|
||||
};
|
||||
|
||||
return {
|
||||
makeGenericClientConstructor: jest.fn(() => {
|
||||
return jest.fn().mockImplementation((host, credentials, options) => {
|
||||
capturedChannelOptions = options;
|
||||
const mockRpc = createMockRpc();
|
||||
return {
|
||||
close: jest.fn(),
|
||||
makeUnaryRequest: jest.fn().mockReturnValue(mockRpc),
|
||||
makeClientStreamRequest: jest.fn().mockReturnValue(mockRpc),
|
||||
makeServerStreamRequest: jest.fn().mockReturnValue(mockRpc),
|
||||
makeBidiStreamRequest: jest.fn().mockReturnValue(mockRpc)
|
||||
};
|
||||
});
|
||||
}),
|
||||
ChannelCredentials: {
|
||||
createInsecure: jest.fn().mockReturnValue('insecure-credentials'),
|
||||
createSsl: jest.fn().mockReturnValue('ssl-credentials'),
|
||||
createFromSecureContext: jest.fn().mockReturnValue('secure-context-credentials')
|
||||
},
|
||||
Metadata: jest.fn().mockImplementation(() => createMockMetadata()),
|
||||
status: {},
|
||||
credentials: {},
|
||||
CallCredentials: {
|
||||
createFromMetadataGenerator: jest.fn().mockReturnValue('call-credentials')
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock proto-loader
|
||||
jest.mock('@grpc/proto-loader', () => ({
|
||||
load: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
import { GrpcClient } from './grpc-client';
|
||||
|
||||
describe('GrpcClient', () => {
|
||||
let grpcClient;
|
||||
let mockEventCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
capturedChannelOptions = null;
|
||||
mockEventCallback = jest.fn();
|
||||
grpcClient = new GrpcClient(mockEventCallback);
|
||||
});
|
||||
|
||||
describe('User-Agent behavior in loadMethodsFromReflection', () => {
|
||||
const baseRequest = {
|
||||
url: 'grpc://localhost:50051',
|
||||
uid: 'test-request-uid',
|
||||
headers: {}
|
||||
};
|
||||
|
||||
const baseParams = {
|
||||
collectionUid: 'test-collection-uid',
|
||||
sendEvent: jest.fn()
|
||||
};
|
||||
|
||||
describe('case-insensitive header extraction', () => {
|
||||
test('should extract User-Agent header (capitalized)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
|
||||
test('should extract user-agent header (lowercase)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'user-agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
|
||||
test('should extract USER-AGENT header (uppercase)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'USER-AGENT': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
|
||||
test('should extract uSeR-aGeNt header (mixed case)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'uSeR-aGeNt': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel options merging', () => {
|
||||
test('should preserve existing channelOptions when user-agent is set', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams,
|
||||
channelOptions: {
|
||||
'grpc.max_receive_message_length': 1024 * 1024,
|
||||
'grpc.keepalive_time_ms': 30000
|
||||
}
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
|
||||
expect(capturedChannelOptions['grpc.keepalive_time_ms']).toBe(30000);
|
||||
});
|
||||
|
||||
test('should include grpc.primary_user_agent in merged options alongside other options', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams,
|
||||
channelOptions: {
|
||||
'grpc.other_option': 'value'
|
||||
}
|
||||
});
|
||||
|
||||
// Use array notation for keys containing dots to avoid Jest interpreting as nested path
|
||||
expect(capturedChannelOptions).toHaveProperty(['grpc.primary_user_agent'], 'Bruno/1.0');
|
||||
expect(capturedChannelOptions).toHaveProperty(['grpc.other_option'], 'value');
|
||||
});
|
||||
|
||||
test('should allow channelOptions to override grpc.primary_user_agent', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams,
|
||||
channelOptions: {
|
||||
'grpc.primary_user_agent': 'ExistingUA'
|
||||
}
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('ExistingUA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing user-agent handling', () => {
|
||||
test('should pass channelOptions unchanged when no user-agent header', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'Content-Type': 'application/grpc' }
|
||||
};
|
||||
|
||||
const channelOptions = {
|
||||
'grpc.max_receive_message_length': 1024 * 1024
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams,
|
||||
channelOptions
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
test('should pass empty object when no user-agent and no channelOptions', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not add grpc.primary_user_agent when user-agent header is missing', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { Authorization: 'Bearer token' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams,
|
||||
channelOptions: {}
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
expect(Object.keys(capturedChannelOptions)).not.toContain('grpc.primary_user_agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle empty user-agent value', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': '' }
|
||||
};
|
||||
|
||||
await grpcClient.loadMethodsFromReflection({
|
||||
request,
|
||||
...baseParams
|
||||
});
|
||||
|
||||
// Empty string is falsy, so grpc.primary_user_agent should not be set
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User-Agent behavior in startConnection', () => {
|
||||
const baseRequest = {
|
||||
url: 'grpc://localhost:50051',
|
||||
uid: 'test-request-uid',
|
||||
method: '/test.Service/TestMethod',
|
||||
headers: {},
|
||||
body: {
|
||||
grpc: [{ content: '{}' }]
|
||||
}
|
||||
};
|
||||
|
||||
const baseCollection = {
|
||||
uid: 'test-collection-uid',
|
||||
pathname: '/test/path'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Pre-register a method so startConnection can find it
|
||||
grpcClient.methods.set('/test.Service/TestMethod', {
|
||||
path: '/test.Service/TestMethod',
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (val) => Buffer.from(JSON.stringify(val)),
|
||||
responseDeserialize: (val) => JSON.parse(val.toString())
|
||||
});
|
||||
});
|
||||
|
||||
describe('case-insensitive header extraction', () => {
|
||||
test('should extract User-Agent header (capitalized)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
|
||||
test('should extract user-agent header (lowercase)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'user-agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
|
||||
test('should extract USER-AGENT header (uppercase)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'USER-AGENT': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
|
||||
test('should extract uSeR-aGeNt header (mixed case)', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'uSeR-aGeNt': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel options merging', () => {
|
||||
test('should preserve existing channelOptions when user-agent is set', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection,
|
||||
channelOptions: {
|
||||
'grpc.max_receive_message_length': 1024 * 1024,
|
||||
'grpc.keepalive_time_ms': 30000
|
||||
}
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0');
|
||||
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
|
||||
expect(capturedChannelOptions['grpc.keepalive_time_ms']).toBe(30000);
|
||||
});
|
||||
|
||||
test('should include grpc.primary_user_agent in merged options alongside other options', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection,
|
||||
channelOptions: {
|
||||
'grpc.other_option': 'value'
|
||||
}
|
||||
});
|
||||
|
||||
// Use array notation for keys containing dots to avoid Jest interpreting as nested path
|
||||
expect(capturedChannelOptions).toHaveProperty(['grpc.primary_user_agent'], 'Bruno/1.0');
|
||||
expect(capturedChannelOptions).toHaveProperty(['grpc.other_option'], 'value');
|
||||
});
|
||||
|
||||
test('should allow channelOptions to override grpc.primary_user_agent', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': 'Bruno/1.0' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection,
|
||||
channelOptions: {
|
||||
'grpc.primary_user_agent': 'ExistingUA'
|
||||
}
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('ExistingUA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing user-agent handling', () => {
|
||||
test('should pass channelOptions unchanged when no user-agent header', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'Content-Type': 'application/grpc' }
|
||||
};
|
||||
|
||||
const channelOptions = {
|
||||
'grpc.max_receive_message_length': 1024 * 1024
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection,
|
||||
channelOptions
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
test('should not add grpc.primary_user_agent when user-agent header is missing', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { Authorization: 'Bearer token' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection,
|
||||
channelOptions: {}
|
||||
});
|
||||
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
expect(Object.keys(capturedChannelOptions)).not.toContain('grpc.primary_user_agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle empty user-agent value', async () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
headers: { 'User-Agent': '' }
|
||||
};
|
||||
|
||||
await grpcClient.startConnection({
|
||||
request,
|
||||
collection: baseCollection
|
||||
});
|
||||
|
||||
// Empty string is falsy, so grpc.primary_user_agent should not be set
|
||||
expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user