import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import { requestUrlChanged, updateRequestMethod, updateRequestProtoPath } from 'providers/ReduxStore/slices/collections'; import { saveRequest, browseFiles, loadGrpcMethodsFromReflection, openCollectionSettings, generateGrpcurlCommand } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { isMacOS } from 'utils/common/platform'; import { getRelativePath, getBasename, getAbsoluteFilePath } from 'utils/common/path'; import useLocalStorage from 'hooks/useLocalStorage/index'; import StyledWrapper from './StyledWrapper'; import ToggleSwitch from 'components/ToggleSwitch/index'; import { IconX, IconCheck, IconRefresh, IconDeviceFloppy, IconArrowRight, IconCode, IconFile, IconChevronDown, IconSettings, IconAlertCircle, IconCopy } from '@tabler/icons'; import toast from 'react-hot-toast'; import { loadGrpcMethodsFromProtoFile, cancelGrpcConnection, endGrpcConnection } from 'utils/network/index'; import Dropdown from 'components/Dropdown/index'; import { IconGrpcUnary, IconGrpcClientStreaming, IconGrpcServerStreaming, IconGrpcBidiStreaming } from 'components/Icons/Grpc'; import Modal from 'components/Modal/index'; import CodeEditor from 'components/CodeEditor'; import { debounce } from 'lodash'; import { getPropertyFromDraftOrRequest } from 'utils/collections'; import { existsSync } from 'utils/filesystem'; // Constants for gRPC method types const STREAMING_METHOD_TYPES = ['client-streaming', 'server-streaming', 'bidi-streaming']; const CLIENT_STREAMING_METHOD_TYPES = ['client-streaming', 'bidi-streaming']; const GrpcurlModal = ({ isOpen, onClose, command }) => { const { displayedTheme } = useTheme(); const [copied, setCopied] = useState(false); const preferences = useSelector((state) => state.app.preferences); const handleCopy = async () => { try { await navigator.clipboard.writeText(command); setCopied(true); toast.success('Command copied to clipboard'); setTimeout(() => setCopied(false), 2000); } catch (error) { toast.error('Failed to copy command'); } }; return ( Generate gRPCurl Command BETA } size="lg" hideFooter={true} >
); }; const GrpcQueryUrl = ({ item, collection, handleRun }) => { const { theme, storedTheme } = useTheme(); const dispatch = useDispatch(); const method = getPropertyFromDraftOrRequest(item, 'request.method'); const type = getPropertyFromDraftOrRequest(item, 'request.type'); const url = getPropertyFromDraftOrRequest(item, 'request.url', ''); const protoPath = getPropertyFromDraftOrRequest(item, 'request.protoPath'); const isMac = isMacOS(); const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S'; const editorRef = useRef(null); const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid)); const [protoFilePath, setProtoFilePath] = useState(protoPath); const [grpcMethods, setGrpcMethods] = useState([]); const [isLoadingMethods, setIsLoadingMethods] = useState(false); const [selectedGrpcMethod, setSelectedGrpcMethod] = useState({ path: method, type: type }); const methodDropdownRef = useRef(); const protoDropdownRef = useRef(); const haveFetchedMethodsRef = useRef(false); const [showGrpcurlModal, setShowGrpcurlModal] = useState(false); const [grpcurlCommand, setGrpcurlCommand] = useState(''); const [isReflectionMode, setIsReflectionMode] = useState(false); const collectionProtoFiles = get(collection, 'brunoConfig.grpc.protoFiles', []); const [reflectionCache, setReflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {}); const [protofileCache, setProtofileCache] = useLocalStorage('bruno.grpc.protofileCache', {}); const fileExistsCache = useRef(new Map()); const [showProtoDropdown, setShowProtoDropdown] = useState(false); const fileExists = useCallback(async (filePath) => { if (!filePath) return false; if (fileExistsCache.current.has(filePath)) { return fileExistsCache.current.get(filePath); } try { const absolutePath = getAbsoluteFilePath(filePath, collection.pathname); const exists = await existsSync(absolutePath); fileExistsCache.current.set(filePath, exists); return exists; } catch (error) { console.error('Error checking if file exists:', error); return false; } }, [collection.pathname]); const [collectionProtoFilesExistence, setCollectionProtoFilesExistence] = useState([]); useEffect(() => { const fetchCollectionProtoFilesExistence = async () => { if (!collectionProtoFiles) return; const existence = await Promise.all(collectionProtoFiles.map(async (protoFile) => { const absolutePath = getAbsoluteFilePath(protoFile.path, collection.pathname); const exists = await fileExists(absolutePath) return { path: protoFile.path, absolutePath, exists } })); setCollectionProtoFilesExistence(existence); }; fetchCollectionProtoFilesExistence(); }, [fileExists]); const invalidProtoFiles = useMemo(() => { return collectionProtoFilesExistence.filter(file => !file.exists); }, [collectionProtoFilesExistence]); const currentProtoFileExists = useMemo(() => { return fileExists(protoFilePath); }, [protoFilePath, fileExists]); const onMethodDropdownCreate = (ref) => (methodDropdownRef.current = ref); const onProtoDropdownCreate = (ref) => (protoDropdownRef.current = ref); const isStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type); const isClientStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && CLIENT_STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type); const onSave = (finalValue) => { dispatch(saveRequest(item.uid, collection.uid)); }; const onUrlChange = (value) => { if (!editorRef.current?.editor) return; const editor = editorRef.current.editor; const cursor = editor.getCursor(); const finalUrl = value?.trim() || value; dispatch( requestUrlChanged({ itemUid: item.uid, collectionUid: collection.uid, url: finalUrl }) ); // Restore cursor position only if URL was trimmed if (finalUrl !== value) { setTimeout(() => { if (editor) { editor.setCursor(cursor); } }, 0); } if(!protoFilePath && value) { setIsReflectionMode(true); handleReflection(finalUrl); } }; const onMethodSelect = ({ path, type }) => { if (isConnectionActive) { cancelGrpcConnection(item.uid) .then(() => { toast.success('gRPC connection cancelled'); }) .catch((err) => { console.error('Failed to cancel gRPC connection:', err); }); } dispatch( updateRequestMethod({ method: path, methodType: type, itemUid: item.uid, collectionUid: collection.uid }) ); }; const handleReflection = async (url, isManualRefresh = false) => { if (!url) return; const cachedMethods = reflectionCache[url]; if (!isManualRefresh && cachedMethods && !isLoadingMethods) { setGrpcMethods(cachedMethods); setProtoFilePath(''); setIsReflectionMode(true); const isDuplicateSave = !item.request.protoPath; if (!isDuplicateSave) { dispatch(updateRequestProtoPath({ protoPath: '', itemUid: item.uid, collectionUid: collection.uid })); } if (cachedMethods && cachedMethods.length > 0) { const haveSelectedMethod = selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path); if (!haveSelectedMethod) { setSelectedGrpcMethod(null); onMethodSelect({ path: '', type: '' }); } else if (selectedGrpcMethod) { // Update the method type for the currently selected method to ensure it matches const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path); if (currentMethod) { const methodType = currentMethod.type; setSelectedGrpcMethod({ path: selectedGrpcMethod.path, type: methodType }); } } return; } } setIsLoadingMethods(true); try { const { methods, error } = await dispatch(loadGrpcMethodsFromReflection(item, collection.uid, url)); if (error) { console.error('Error loading gRPC methods:', error); toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); return; } // Cache the methods for this URL setReflectionCache(prevCache => ({ ...prevCache, [url]: methods })); setGrpcMethods(methods); setProtoFilePath(''); setIsReflectionMode(true); const isDuplicateSave = !item.request.protoPath; if (!isDuplicateSave) { dispatch(updateRequestProtoPath({ protoPath: '', itemUid: item.uid, collectionUid: collection.uid })); } if (methods && methods.length > 0) { const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); if (!haveSelectedMethod) { setSelectedGrpcMethod(null); onMethodSelect({ path: '', type: '' }); } else if (selectedGrpcMethod) { // Update the method type for the currently selected method to ensure it matches const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); if (currentMethod) { const methodType = currentMethod.type; setSelectedGrpcMethod({ path: selectedGrpcMethod.path, type: methodType }); } } toast.success(`Loaded ${methods.length} gRPC methods from reflection`); } } catch (error) { console.error('Error loading gRPC methods:', error); toast.error('Failed to load gRPC methods from reflection'); } finally { setIsLoadingMethods(false); } }; const handleGrpcurl = async (url) => { if (!url) { toast.error('Please enter a valid gRPC server URL'); return; } if (!selectedGrpcMethod?.path) { toast.error('Please select a gRPC method'); return; } try { const result = await dispatch(generateGrpcurlCommand(item, collection.uid)); if (result.success) { setGrpcurlCommand(result.command); setShowGrpcurlModal(true); } else { toast.error(result.error || 'Failed to generate grpcurl command'); } } catch (error) { console.error('Error generating grpcurl command:', error); toast.error('Failed to generate grpcurl command'); } }; // Add a new function to group methods by service const groupMethodsByService = (methods) => { if (!methods || !methods.length) return {}; const groupedMethods = {}; methods.forEach(method => { // The format is "/service.ServiceName/MethodName" const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path; const parts = pathWithoutLeadingSlash.split('/'); // The service is the part before the last slash const serviceName = parts[0] || 'Default'; // The method name is the part after the last slash const methodName = parts[1] || method.path; // Store the extracted method name for easier display const enhancedMethod = { ...method, serviceName, methodName }; if (!groupedMethods[serviceName]) { groupedMethods[serviceName] = []; } groupedMethods[serviceName].push(enhancedMethod); }); return groupedMethods; }; const MethodsDropdownIcon = forwardRef((props, ref) => { return (
{selectedGrpcMethod &&
{getIconForMethodType(selectedGrpcMethod.type)}
} {selectedGrpcMethod ? ( {selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path} ) : ( Select Method )}
); }); const ProtoFileDropdownIcon = forwardRef((props, ref) => { return (
setShowProtoDropdown(prev => !prev)}> {isReflectionMode ? (<> ) : ( )} {isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(protoFilePath) : 'Select Proto File')}
); }); const handleGrpcMethodSelect = (method) => { const methodType = method.type setSelectedGrpcMethod({ path: method.path, type: methodType }); onMethodSelect({ path: method.path, type: methodType }); }; const getIconForMethodType = (type) => { switch (type) { case 'unary': return ; case 'client-streaming': return ; case 'server-streaming': return ; case 'bidi-streaming': return ; default: return ; } }; const handleCancelConnection = (e) => { e.stopPropagation(); cancelGrpcConnection(item.uid) .then(() => { toast.success('gRPC connection cancelled'); }) .catch((err) => { console.error('Failed to cancel gRPC connection:', err); toast.error('Failed to cancel gRPC connection'); }); }; const handleEndConnection = (e) => { e.stopPropagation(); endGrpcConnection(item.uid) .then(() => { toast.success('gRPC stream ended'); }) .catch((err) => { console.error('Failed to end gRPC stream:', err); toast.error('Failed to end gRPC stream'); }); }; const handleSelectCollectionProtoFile = (protoFile) => { try { if (!protoFile) { toast.error('No proto file selected'); return; } // Get the absolute path from the relative path const absolutePath = protoFile.absolutePath; if (!protoFile.exists) { toast.error(`Proto file not found: ${protoFile.path}`); return; } setProtoFilePath(protoFile.path); setIsReflectionMode(false); dispatch(updateRequestProtoPath({ protoPath: protoFile.path, itemUid: item.uid, collectionUid: collection.uid })); loadMethodsFromProtoFile(absolutePath); } catch (error) { console.error('Error selecting collection proto file:', error); toast.error('Failed to select collection proto file'); } }; const handleResetProtoFile = () => { setProtoFilePath(''); setIsReflectionMode(true); const isDuplicateSave = !item.request.protoPath; if (!isDuplicateSave) { dispatch(updateRequestProtoPath({ protoPath: '', itemUid: item.uid, collectionUid: collection.uid })); } setGrpcMethods([]); setSelectedGrpcMethod(null); onMethodSelect({ path: '', type: '' }); toast.success('Proto file reset'); }; const loadMethodsFromProtoFile = async (filePath, isManualRefresh = false) => { if (!filePath) { toast.error('No proto file selected'); return; }; const absolutePath = getAbsoluteFilePath(filePath, collection.pathname); // Check if we have cached methods for this proto file const cachedMethods = protofileCache[absolutePath]; if (cachedMethods && !isLoadingMethods && !isManualRefresh) { setGrpcMethods(cachedMethods); if (cachedMethods && cachedMethods.length > 0) { // Check if currently selected method is still valid const haveSelectedMethod = selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path); if (!haveSelectedMethod) { setSelectedGrpcMethod(null); onMethodSelect({ path: '', type: '' }); } else { // Update the method type for the currently selected method to ensure it matches const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path); if (currentMethod) { const methodType = currentMethod.type; setSelectedGrpcMethod({ path: selectedGrpcMethod.path, type: methodType }); } } } return; } setIsLoadingMethods(true); try { const { methods, error } = await loadGrpcMethodsFromProtoFile(absolutePath); if (error) { console.error('Error loading gRPC methods:', error); toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); return; } // Cache the methods for this proto file setProtofileCache(prevCache => ({ ...prevCache, [absolutePath]: methods })); setGrpcMethods(methods); if (methods && methods.length > 0) { toast.success(`Loaded ${methods.length} gRPC methods from proto file`); // Check if currently selected method is still valid const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); if (!haveSelectedMethod) { setSelectedGrpcMethod(null); onMethodSelect({ path: '', type: '' }); } else { // Update the method type for the currently selected method to ensure it matches const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path); if (currentMethod) { const methodType = currentMethod.type; setSelectedGrpcMethod({ path: selectedGrpcMethod.path, type: methodType }); } } } else { toast.warning('No gRPC methods found in proto file'); } } catch (err) { console.error('Error loading gRPC methods:', err); toast.error('Failed to load gRPC methods from proto file'); } finally { setIsLoadingMethods(false); } }; const handleSelectProtoFile = (e) => { e.stopPropagation(); const filters = [{ name: 'Proto Files', extensions: ['proto'] }]; dispatch(browseFiles(filters, [''])) .then((filePaths) => { if (filePaths && filePaths.length > 0) { const filePath = filePaths[0]; const relativePath = getRelativePath(filePath, collection.pathname); setProtoFilePath(relativePath); setIsReflectionMode(false); dispatch(updateRequestProtoPath({ protoPath: relativePath, itemUid: item.uid, collectionUid: collection.uid })); // Load methods from the newly selected proto file const absolutePath = getAbsoluteFilePath(relativePath, collection.pathname); loadMethodsFromProtoFile(absolutePath); } }) .catch((err) => { console.error('Error selecting proto file:', err); toast.error('Failed to select proto file'); }); }; const handleOpenCollectionGrpc = () => { dispatch(openCollectionSettings(collection.uid, 'grpc')); }; const debouncedOnUrlChange = debounce(onUrlChange, 1000); useEffect(() => { fileExistsCache.current.clear(); }, [collection.pathname]); useEffect(() => { if(haveFetchedMethodsRef.current) { return; } haveFetchedMethodsRef.current = true; if(protoFilePath) { setIsReflectionMode(false); loadMethodsFromProtoFile(protoFilePath); return; } if (!url) return; setIsReflectionMode(true); handleReflection(url); }, []); return (
gRPC
onSave(finalValue)} theme={storedTheme} onChange={(newValue) => debouncedOnUrlChange(newValue)} onRun={handleRun} collection={collection} highlightPathParams={true} item={item} /> {grpcMethods && grpcMethods.length > 0 && (
} placement="bottom-end" style={{ maxWidth: "unset" }}>
{Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
{serviceName || 'Default Service'}
{methods.map((method, methodIndex) => (
handleGrpcMethodSelect(method)} >
{getIconForMethodType(method.type)}
{method.methodName}
{method.type}
))}
))}
)}
} placement="bottom-end" visible={showProtoDropdown} onClickOutside={() => setShowProtoDropdown(false)} >

{isReflectionMode ? "Using Reflection" : "Select Proto File"}

{/* Mode Toggle */}
Mode
Proto File { e.stopPropagation(); e.preventDefault(); setIsReflectionMode(!isReflectionMode); if (!isReflectionMode) { // Switching to reflection mode setProtoFilePath(''); dispatch(updateRequestProtoPath({ protoPath: '', itemUid: item.uid, collectionUid: collection.uid })); if (url) { handleReflection(url); } } else { // Switching to proto file mode setGrpcMethods([]); setSelectedGrpcMethod(null); onMethodSelect({ path: '', type: '' }); } }} size="2xs" /> Reflection
{!isReflectionMode && ( <> {collectionProtoFiles && collectionProtoFiles.length > 0 && (
From Collection Settings
{invalidProtoFiles.length > 0 && (

Some proto files could not be found.

)}
{collectionProtoFilesExistence.map((protoFile, index) => { const isSelected = protoFilePath === protoFile.absolutePath; const isInvalid = !protoFile.exists; return (
{ if (!isInvalid) { setShowProtoDropdown(false); handleSelectCollectionProtoFile(protoFile); } }} >
{getBasename(protoFile.absolutePath)} {isInvalid && ( )}
{protoFile.path}
); })}
)} {collectionProtoFiles && collectionProtoFiles.length > 0 && (
)} {protoFilePath && !collectionProtoFilesExistence.some(pf => pf.absolutePath === protoFilePath ) && (
Current Proto File
{!currentProtoFileExists && (

Selected proto file not found. Please select a valid proto file from collection settings or browse for a new one.

)}
{getBasename(protoFilePath)} {!currentProtoFileExists && ( )}
{protoFilePath}
)}
)} {isReflectionMode && (
Using server reflection to discover gRPC methods.
)}
{ e.stopPropagation(); if (isReflectionMode) { handleReflection(url, true); } else if (protoFilePath) { loadMethodsFromProtoFile(protoFilePath, true); } else { toast.error('No proto file selected'); } }} > {isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
{ e.stopPropagation(); handleGrpcurl(url); }} > Generate grpcurl command
{ e.stopPropagation(); if (!item.draft) return; onSave(); }} > Save ({saveShortcut})
{isConnectionActive && isStreamingMethod && (
Cancel
{isClientStreamingMethod &&
}
)} {(!isConnectionActive || !isStreamingMethod) && (
{ e.stopPropagation(); handleRun(e); }} >
)}
{isConnectionActive && isStreamingMethod && (
)} {showGrpcurlModal && ( setShowGrpcurlModal(false)} command={grpcurlCommand} /> )}
); }; export default GrpcQueryUrl;