diff --git a/packages/bruno-requests/src/grpc/grpc-client.js b/packages/bruno-requests/src/grpc/grpc-client.js index 7d2b7b5fdb..fcf721cb46 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.js +++ b/packages/bruno-requests/src/grpc/grpc-client.js @@ -116,7 +116,8 @@ const normalizeWindowsNamedPipe = (pipePath) => { const getParsedGrpcUrlObject = (url) => { const addProtocolIfMissing = (str) => { if (str.includes('://')) return str; - if (str.includes('localhost') || str.includes('127.0.0.1')) { + const lower = str.toLowerCase(); + if (lower.includes('localhost') || lower.includes('127.0.0.1')) { return `grpc://${str}`; } return `grpcs://${str}`; @@ -133,11 +134,11 @@ const getParsedGrpcUrlObject = (url) => { return { host: normalizeWindowsNamedPipe(url), path: '', protocol: 'pipe', isLocalTransport: true }; } - const urlObj = new URL(addProtocolIfMissing(url.toLowerCase())); + const urlObj = new URL(addProtocolIfMissing(url)); return { host: urlObj.host, - protocol: urlObj.protocol.replace(':', ''), + protocol: urlObj.protocol.replace(':', '').toLowerCase(), path: removeTrailingSlash(urlObj.pathname), isLocalTransport: false }; @@ -228,8 +229,14 @@ class GrpcClient { * @param {grpc.ChannelOptions} options - channel options * @returns {Promise<{ client: GrpcReflection, services: string[], callOptions: Object }>} */ - async #getReflectionClient(host, credentials = ChannelCredentials.createInsecure(), metadata = null, options = {}) { - const makeClient = (version) => new GrpcReflection(host, credentials, options, version); + async #getReflectionClient(host, credentials = ChannelCredentials.createInsecure(), metadata = null, options = {}, pathPrefix = '') { + const makeClient = (version) => { + const client = new GrpcReflection(host, credentials, options, version); + if (pathPrefix) { + this.#applyPathPrefix(client, host, credentials, options, pathPrefix); + } + return client; + }; const callOptions = this.#createCallOptions(metadata); let client; @@ -250,6 +257,30 @@ class GrpcClient { return { client, services, callOptions }; } + /** + * Replace a GrpcReflection instance's internal gRPC client with one + * whose method paths are prefixed with the URL subpath. + * This allows reflection to work when the gRPC server is hosted behind a URL subpath. + */ + #applyPathPrefix(reflectionInstance, host, credentials, options, prefix) { + const originalClient = reflectionInstance.client; + const serviceDef = originalClient.constructor?.service; + if (!serviceDef) return; + + const prefixedDef = {}; + for (const [name, def] of Object.entries(serviceDef)) { + prefixedDef[name] = { ...def, path: prefix + def.path }; + } + + const PrefixedClient = makeGenericClientConstructor(prefixedDef); + const prefixedClient = new PrefixedClient(host, credentials, options); + // Close the original client's channel to prevent leaks + if (typeof originalClient.close === 'function') { + originalClient.close(); + } + reflectionInstance.client = prefixedClient; + } + /** * Close a GrpcReflection client's underlying gRPC channel. * GrpcReflection doesn't expose a close() method, so we access the @@ -783,7 +814,7 @@ class GrpcClient { let reflectionClient = null; try { - const { client, services, callOptions } = await this.#getReflectionClient(targetHost, credentials, metadata, mergedChannelOptions); + const { client, services, callOptions } = await this.#getReflectionClient(targetHost, credentials, metadata, mergedChannelOptions, path); reflectionClient = client; const methods = []; @@ -1003,7 +1034,7 @@ class GrpcClient { } else if (protocol === 'pipe') { console.warn('Windows named pipes are not directly supported by grpcurl'); parts.push('-plaintext'); - } else if (url.startsWith('grpcs://') || url.startsWith('https://')) { + } else if (protocol === 'grpcs' || protocol === 'https') { if (ca) { /** * Instead of using certificate that relies on CN, use SANs diff --git a/packages/bruno-requests/src/grpc/grpc-client.spec.js b/packages/bruno-requests/src/grpc/grpc-client.spec.js index 74b6f59311..547e28e899 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.spec.js +++ b/packages/bruno-requests/src/grpc/grpc-client.spec.js @@ -5,6 +5,7 @@ // Store captured values for assertions let capturedChannelOptions = null; let capturedHost = null; +let capturedRequestPath = null; // Mock GrpcReflection to capture options const mockListServices = jest.fn().mockResolvedValue(['test.Service']); @@ -73,7 +74,10 @@ jest.mock('@grpc/grpc-js', () => { const mockRpc = createMockRpc(); return { close: jest.fn(), - makeUnaryRequest: jest.fn().mockReturnValue(mockRpc), + makeUnaryRequest: jest.fn().mockImplementation((path) => { + capturedRequestPath = path; + return mockRpc; + }), makeClientStreamRequest: jest.fn().mockReturnValue(mockRpc), makeServerStreamRequest: jest.fn().mockReturnValue(mockRpc), makeBidiStreamRequest: jest.fn().mockReturnValue(mockRpc) @@ -109,6 +113,7 @@ describe('GrpcClient', () => { jest.clearAllMocks(); capturedChannelOptions = null; capturedHost = null; + capturedRequestPath = null; mockEventCallback = jest.fn(); grpcClient = new GrpcClient(mockEventCallback); }); @@ -715,4 +720,89 @@ describe('GrpcClient', () => { expect(capturedHost).toBe('myserver:50051'); }); }); + + describe('URL subpath support in startConnection', () => { + const baseCollection = { + uid: 'test-collection-uid', + pathname: '/test/path' + }; + + beforeEach(() => { + 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()) + }); + }); + + test('should include URL subpath in the gRPC request path', async () => { + const request = { + url: 'grpcs://myserver:443/my-subpath', + uid: 'test-request-uid', + method: '/test.Service/TestMethod', + headers: {}, + body: { grpc: [{ content: '{}' }] } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedRequestPath).toBe('/my-subpath/test.Service/TestMethod'); + }); + + test('should preserve URL subpath case sensitivity', async () => { + const request = { + url: 'grpcs://myserver:443/MySubPath', + uid: 'test-request-uid', + method: '/test.Service/TestMethod', + headers: {}, + body: { grpc: [{ content: '{}' }] } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedRequestPath).toBe('/MySubPath/test.Service/TestMethod'); + }); + + test('should work without subpath (standard URL)', async () => { + const request = { + url: 'grpc://myserver:50051', + uid: 'test-request-uid', + method: '/test.Service/TestMethod', + headers: {}, + body: { grpc: [{ content: '{}' }] } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedRequestPath).toBe('/test.Service/TestMethod'); + }); + + test('should connect to host without subpath in channel target', async () => { + const request = { + url: 'grpcs://myserver:443/my-subpath', + uid: 'test-request-uid', + method: '/test.Service/TestMethod', + headers: {}, + body: { grpc: [{ content: '{}' }] } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedHost).toBe('myserver:443'); + }); + }); });