diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js index 7ea16f177d5..e7b0088dc68 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js @@ -5,6 +5,7 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders'; import GrpcBody from 'components/RequestPane/GrpcBody'; import GrpcAuth from './GrpcAuth/index'; import GrpcAuthMode from './GrpcAuth/GrpcAuthMode/index'; +import GrpcSettingsPane from 'components/RequestPane/GrpcSettingsPane'; import StatusDot from 'components/StatusDot/index'; import HeightBoundContainer from 'ui/HeightBoundContainer'; import find from 'lodash/find'; @@ -41,6 +42,9 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => { case 'auth': { return ; } + case 'settings': { + return ; + } case 'docs': { return ; } @@ -90,6 +94,11 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => { label: 'Auth', indicator: auth?.mode && auth.mode !== 'none' ? : null }, + { + key: 'settings', + label: 'Settings', + indicator: null + }, { key: 'docs', label: 'Docs', diff --git a/packages/bruno-app/src/components/RequestPane/GrpcSettingsPane/index.js b/packages/bruno-app/src/components/RequestPane/GrpcSettingsPane/index.js new file mode 100644 index 00000000000..d988de97348 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GrpcSettingsPane/index.js @@ -0,0 +1,134 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import get from 'lodash/get'; +import SettingsInput from 'components/SettingsInput'; +import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector'; +import { updateItemSettings } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; + +const getPropertyFromDraftOrRequest = (propertyKey, item) => + item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {}); + +const GrpcSettingsPane = ({ item, collection }) => { + const dispatch = useDispatch(); + + const settings = getPropertyFromDraftOrRequest('settings', item); + const { + maxReceiveMessageLength = '', + maxSendMessageLength = '', + deadline = '', + keepaliveTime = '', + keepaliveTimeout = '', + clientIdleTimeout = '', + maxReconnectBackoff = '', + includeDefaultValues + } = settings; + + const updateSetting = useCallback((key, value) => { + dispatch(updateItemSettings({ + collectionUid: collection.uid, + itemUid: item.uid, + settings: { [key]: value } + })); + }, [dispatch, collection.uid, item.uid]); + + const onNumericChange = useCallback((key) => (e) => { + const value = e.target.value; + if (value === '' || value === '-1' || /^-?\d+$/.test(value)) { + updateSetting(key, value === '' ? '' : value); + } + }, [updateSetting]); + + const onSave = useCallback(() => { + dispatch(saveRequest(item.uid, collection.uid)); + }, [dispatch, item.uid, collection.uid]); + + const handleKeyDown = useCallback((e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + onSave(); + } + }, [onSave]); + + return ( +
+
Configure gRPC channel and request settings.
+
+
+ + + + + + + + + + + + + + + updateSetting('includeDefaultValues', includeDefaultValues === false)} + label="Include Default Values" + description="Include fields with protobuf default values (0, empty string, false) in responses" + size="medium" + /> +
+
+
+ ); +}; + +export default GrpcSettingsPane; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 89120abf51b..18c5ab03894 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1744,13 +1744,15 @@ export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async return reject(error); } + const settings = itemCopy.draft ? itemCopy.draft.settings : itemCopy.settings; const { ipcRenderer } = window; ipcRenderer .invoke('grpc:load-methods-reflection', { request: requestItem, collection: collectionCopy, environment, - runtimeVariables + runtimeVariables, + settings }) .then(resolve) .catch(reject); diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index b1a265276fc..5e9620d1f89 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -81,12 +81,14 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar return new Promise((resolve, reject) => { const { ipcRenderer } = window; const request = item.draft ? item.draft : item; + const settings = item.draft ? item.draft.settings : item.settings; ipcRenderer.invoke('grpc:start-connection', { request, collection, environment, - runtimeVariables + runtimeVariables, + settings }) .then(() => { resolve(); diff --git a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js index ac3f86e43ac..42cda10b60b 100644 --- a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js @@ -152,7 +152,7 @@ const registerGrpcEventHandlers = (window) => { }); // Start a new gRPC connection - ipcMain.handle('grpc:start-connection', async (event, { request, collection, environment, runtimeVariables }) => { + ipcMain.handle('grpc:start-connection', async (event, { request, collection, environment, runtimeVariables, settings }) => { try { const requestCopy = cloneDeep(request); const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables, {}); @@ -219,6 +219,33 @@ const registerGrpcEventHandlers = (window) => { const includeDirs = getProtobufIncludeDirs(collection); + // Build gRPC channel options from settings + const channelOptionsMap = { + maxReceiveMessageLength: 'grpc.max_receive_message_length', + maxSendMessageLength: 'grpc.max_send_message_length', + keepaliveTime: 'grpc.keepalive_time_ms', + keepaliveTimeout: 'grpc.keepalive_timeout_ms', + clientIdleTimeout: 'grpc.client_idle_timeout_ms', + maxReconnectBackoff: 'grpc.max_reconnect_backoff_ms' + }; + const channelOptions = {}; + if (settings) { + for (const [key, option] of Object.entries(channelOptionsMap)) { + if (settings[key] != null) { + channelOptions[option] = settings[key]; + } + } + } + + // Build proto-loader options from settings + const protoOptions = {}; + if (settings?.includeDefaultValues != null) { + protoOptions.defaults = settings.includeDefaultValues; + } + + // Extract deadline (per-RPC) + const deadline = settings?.deadline ?? null; + // Start gRPC connection with the processed request, certificates, and proxy await grpcClient.startConnection({ request: preparedRequest, @@ -230,7 +257,10 @@ const registerGrpcEventHandlers = (window) => { pfx, verifyOptions, includeDirs, - proxyConfig: grpcProxyConfig + proxyConfig: grpcProxyConfig, + channelOptions, + protoOptions, + deadline }); sendEvent('grpc:request', preparedRequest.uid, collection.uid, requestSent); @@ -312,7 +342,7 @@ const registerGrpcEventHandlers = (window) => { }); // Load methods from server reflection - ipcMain.handle('grpc:load-methods-reflection', async (event, { request, collection, environment, runtimeVariables }) => { + ipcMain.handle('grpc:load-methods-reflection', async (event, { request, collection, environment, runtimeVariables, settings }) => { try { const requestCopy = cloneDeep(request); const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables); @@ -375,6 +405,12 @@ const registerGrpcEventHandlers = (window) => { }); } + // Build proto-loader options from settings + const protoOptions = {}; + if (settings?.includeDefaultValues != null) { + protoOptions.defaults = settings.includeDefaultValues; + } + const methods = await grpcClient.loadMethodsFromReflection({ request: preparedRequest, collectionUid: collection.uid, @@ -385,7 +421,8 @@ const registerGrpcEventHandlers = (window) => { pfx, verifyOptions, sendEvent, - proxyConfig: grpcProxyConfig + proxyConfig: grpcProxyConfig, + protoOptions }); return { success: true, methods: safeParseJSON(safeStringifyJSON(methods)) }; diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index ecab5eee61a..ae2765c2548 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -568,6 +568,28 @@ const sem = grammar.createSemantics().addAttribute('ast', { _settings.keepAliveInterval = keepAliveInterval; } + // Parse gRPC-specific numeric settings + const grpcNumericSettings = [ + 'maxReceiveMessageLength', 'maxSendMessageLength', + 'keepaliveTime', 'keepaliveTimeout', + 'clientIdleTimeout', 'maxReconnectBackoff', 'deadline' + ]; + for (const key of grpcNumericSettings) { + if (settings[key] !== undefined) { + const val = parseInt(settings[key], 10); + if (!isNaN(val)) { + _settings[key] = val; + } + } + } + + // Parse gRPC includeDefaultValues as boolean + if (settings.includeDefaultValues !== undefined) { + _settings.includeDefaultValues = typeof settings.includeDefaultValues === 'boolean' + ? settings.includeDefaultValues + : settings.includeDefaultValues === 'true'; + } + return { settings: _settings }; diff --git a/packages/bruno-requests/src/grpc/grpc-client.js b/packages/bruno-requests/src/grpc/grpc-client.js index 7d2b7b5fdb7..55ef1bb4335 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.js +++ b/packages/bruno-requests/src/grpc/grpc-client.js @@ -34,6 +34,16 @@ const configOptions = { json: true }; +/** + * Default gRPC channel options. + * Sets unlimited message sizes since Bruno is a client-side tool + * and should not impose arbitrary limits on responses. + */ +const DEFAULT_CHANNEL_OPTIONS = { + 'grpc.max_receive_message_length': -1, + 'grpc.max_send_message_length': -1 +}; + const reflectionServices = ['grpc.reflection.v1alpha.ServerReflection', 'grpc.reflection.v1.ServerReflection']; const replaceTabsWithSpaces = (str, numSpaces = 2) => { @@ -431,7 +441,7 @@ class GrpcClient { * @returns {Promise} Whether methods were successfully refreshed * @private */ - async #refreshMethods({ url, headers, protoPath, collectionPath, collectionUid, certificates = {}, verifyOptions, includeDirs = [], proxyConfig }) { + async #refreshMethods({ url, headers, protoPath, collectionPath, collectionUid, certificates = {}, verifyOptions, includeDirs = [], proxyConfig, protoOptions = {} }) { try { // Try reflection first if no proto path is specified if (!protoPath) { @@ -445,7 +455,8 @@ class GrpcClient { pfx: certificates.pfx, verifyOptions, sendEvent: () => {}, // No-op for refresh - proxyConfig + proxyConfig, + protoOptions }); return true; } @@ -453,7 +464,7 @@ class GrpcClient { // Try proto file if available if (protoPath) { const absoluteProtoPath = nodePath.resolve(collectionPath, protoPath); - await this.loadMethodsFromProtoFile(absoluteProtoPath, includeDirs); + await this.loadMethodsFromProtoFile(absoluteProtoPath, includeDirs, protoOptions); return true; } @@ -498,13 +509,14 @@ class GrpcClient { /** * Handle unary responses */ - #handleUnaryResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid }) { + #handleUnaryResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid, callOptions = {} }) { const rpc = client.makeUnaryRequest( requestPath, method.requestSerialize, method.responseDeserialize, messages[0], metadata, + callOptions, (error, res) => { this.eventCallback('grpc:response', requestId, collectionUid, { error, res }); } @@ -514,12 +526,13 @@ class GrpcClient { setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } - #handleClientStreamingResponse({ client, requestId, requestPath, method, metadata, collectionUid }) { + #handleClientStreamingResponse({ client, requestId, requestPath, method, metadata, collectionUid, callOptions = {} }) { const rpc = client.makeClientStreamRequest( requestPath, method.requestSerialize, method.responseDeserialize, metadata, + callOptions, (error, res) => { this.eventCallback('grpc:response', requestId, collectionUid, { error, res }); } @@ -529,7 +542,7 @@ class GrpcClient { setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } - #handleServerStreamingResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid }) { + #handleServerStreamingResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid, callOptions = {} }) { const message = messages[0]; const rpc = client.makeServerStreamRequest( requestPath, @@ -537,6 +550,7 @@ class GrpcClient { method.responseDeserialize, message, metadata, + callOptions, (error, res) => { this.eventCallback('grpc:response', requestId, collectionUid, { error, res }); } @@ -546,12 +560,13 @@ class GrpcClient { setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } - #handleBidiStreamingResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid }) { + #handleBidiStreamingResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid, callOptions = {} }) { const rpc = client.makeBidiStreamRequest( requestPath, method.requestSerialize, method.responseDeserialize, - metadata + metadata, + callOptions ); this.#addConnection(requestId, { rpc, client }); @@ -592,7 +607,9 @@ class GrpcClient { verifyOptions, channelOptions = {}, includeDirs = [], - proxyConfig + proxyConfig, + protoOptions = {}, + deadline }) { const credentials = this.#getChannelCredentials({ url: request.url, @@ -630,7 +647,8 @@ class GrpcClient { }, verifyOptions, includeDirs, - proxyConfig + proxyConfig, + protoOptions }); if (!refreshSuccess) { @@ -655,7 +673,7 @@ class GrpcClient { // Resolve proxy target and channel options const { targetHost, proxyChannelOptions } = this.#resolveProxyTarget(host, proxyConfig); - const mergedChannelOptions = { ...channelOptions, ...proxyChannelOptions }; + const mergedChannelOptions = { ...DEFAULT_CHANNEL_OPTIONS, ...channelOptions, ...proxyChannelOptions }; if (userAgentValue && !channelOptions?.['grpc.primary_user_agent']) { mergedChannelOptions['grpc.primary_user_agent'] = userAgentValue; } @@ -666,6 +684,12 @@ class GrpcClient { throw new Error('Failed to create client'); } + // Build per-RPC call options (e.g. deadline) + const callOptions = {}; + if (deadline != null && deadline > 0) { + callOptions.deadline = new Date(Date.now() + deadline); + } + let messages = request.body.grpc; try { messages = messages.map(({ content }) => safeJsonParse(content, 'message content')); @@ -693,7 +717,8 @@ class GrpcClient { requestPath, method, messages, - metadata + metadata, + callOptions }); } @@ -748,7 +773,8 @@ class GrpcClient { verifyOptions, sendEvent, channelOptions = {}, - proxyConfig + proxyConfig, + protoOptions = {} }) { const { host, path } = getParsedGrpcUrlObject(request.url); @@ -762,7 +788,7 @@ class GrpcClient { // Resolve proxy target and channel options const { targetHost, proxyChannelOptions } = this.#resolveProxyTarget(host, proxyConfig); - const mergedChannelOptions = { ...channelOptions, ...proxyChannelOptions }; + const mergedChannelOptions = { ...DEFAULT_CHANNEL_OPTIONS, ...channelOptions, ...proxyChannelOptions }; if (userAgentValue && !channelOptions?.['grpc.primary_user_agent']) { mergedChannelOptions['grpc.primary_user_agent'] = userAgentValue; } @@ -786,12 +812,15 @@ class GrpcClient { const { client, services, callOptions } = await this.#getReflectionClient(targetHost, credentials, metadata, mergedChannelOptions); reflectionClient = client; + const mergedProtoOptions = { ...configOptions, ...protoOptions }; const methods = []; for (const service of services) { if (reflectionServices.includes(service)) { continue; } - const m = await client.listMethods(service, callOptions); + const descriptor = await client.getDescriptorBySymbol(service, callOptions); + const packageObject = descriptor.getPackageObject(mergedProtoOptions); + const m = client.getServiceMethods(packageObject, service); methods.push(...m); } @@ -817,8 +846,9 @@ class GrpcClient { } } - async loadMethodsFromProtoFile(filePath, includeDirs = []) { - const protoDefinition = await protoLoader.load(filePath, { ...configOptions, includeDirs }); + async loadMethodsFromProtoFile(filePath, includeDirs = [], protoOptions = {}) { + const mergedConfigOptions = { ...configOptions, ...protoOptions }; + const protoDefinition = await protoLoader.load(filePath, { ...mergedConfigOptions, includeDirs }); const methods = Object.values(protoDefinition) .filter((definition) => !definition?.format) .flatMap(Object.values); diff --git a/packages/bruno-requests/src/grpc/grpc-client.spec.js b/packages/bruno-requests/src/grpc/grpc-client.spec.js index 74b6f593117..9ea8b27aa4b 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.spec.js +++ b/packages/bruno-requests/src/grpc/grpc-client.spec.js @@ -8,13 +8,17 @@ let capturedHost = null; // Mock GrpcReflection to capture options const mockListServices = jest.fn().mockResolvedValue(['test.Service']); -const mockListMethods = jest.fn().mockResolvedValue([ +const mockGetDescriptorBySymbol = jest.fn().mockResolvedValue({ + getPackageObject: jest.fn().mockReturnValue({}) +}); +const mockGetServiceMethods = jest.fn().mockReturnValue([ { - path: '/test.Service/TestMethod', + name: 'TestMethod', definition: { requestStream: false, responseStream: false - } + }, + path: '/test.Service/TestMethod' } ]); @@ -24,7 +28,8 @@ jest.mock('grpc-js-reflection-client', () => ({ capturedHost = host; return { listServices: mockListServices, - listMethods: mockListMethods + getDescriptorBySymbol: mockGetDescriptorBySymbol, + getServiceMethods: mockGetServiceMethods }; }) })); diff --git a/packages/bruno-schema-types/src/collection/item.ts b/packages/bruno-schema-types/src/collection/item.ts index bdf3b334601..cb5d786cd3b 100644 --- a/packages/bruno-schema-types/src/collection/item.ts +++ b/packages/bruno-schema-types/src/collection/item.ts @@ -25,7 +25,18 @@ export interface WebSocketItemSettings { } | null; } -export type ItemSettings = HttpItemSettings | WebSocketItemSettings | null; +export interface GrpcItemSettings { + maxReceiveMessageLength?: number | null; + maxSendMessageLength?: number | null; + keepaliveTime?: number | null; + keepaliveTimeout?: number | null; + clientIdleTimeout?: number | null; + maxReconnectBackoff?: number | null; + deadline?: number | null; + includeDefaultValues?: boolean | null; +} + +export type ItemSettings = HttpItemSettings | WebSocketItemSettings | GrpcItemSettings | null; export interface Item { uid: UID;