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 (
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;