Adds GrpcDiscoveryClient for enumerating gRPC services via server reflection. Includes: - GrpcDiscoveryClient class with listServices(), discoverAllServices() methods - Support for getting file descriptors for symbols and filenames - DiscoveredService and DiscoveredMethod data classes - Custom exceptions (ReflectionException, ConnectionException) - Generated proto files for gRPC reflection (fixed for protobuf 6.0.0) Co-Authored-By: Claude <noreply@anthropic.com>
353 lines
10 KiB
Dart
353 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:grpc/grpc.dart';
|
|
|
|
import 'grpc_config.dart';
|
|
import '../generated/reflection.pbgrpc.dart';
|
|
import '../generated/descriptor.pb.dart';
|
|
|
|
/// Exception types for gRPC discovery operations.
|
|
abstract class GrpcDiscoveryException implements Exception {
|
|
final String message;
|
|
|
|
GrpcDiscoveryException(this.message);
|
|
|
|
@override
|
|
String toString() => '$runtimeType: $message';
|
|
}
|
|
|
|
/// Thrown when the gRPC reflection service returns an error.
|
|
class ReflectionException extends GrpcDiscoveryException {
|
|
final int errorCode;
|
|
|
|
ReflectionException(super.message, this.errorCode);
|
|
|
|
@override
|
|
String toString() => 'ReflectionException: $message (code: $errorCode)';
|
|
}
|
|
|
|
/// Thrown when connection to the server fails.
|
|
class ConnectionException extends GrpcDiscoveryException {
|
|
ConnectionException(super.message);
|
|
}
|
|
|
|
/// Represents a discovered gRPC service with its methods.
|
|
class DiscoveredService {
|
|
final String name;
|
|
final List<DiscoveredMethod> methods;
|
|
|
|
const DiscoveredService({
|
|
required this.name,
|
|
required this.methods,
|
|
});
|
|
|
|
@override
|
|
String toString() => 'DiscoveredService($name, methods: ${methods.length})';
|
|
}
|
|
|
|
/// Represents a method within a discovered gRPC service.
|
|
class DiscoveredMethod {
|
|
final String name;
|
|
final String inputType;
|
|
final String outputType;
|
|
final bool clientStreaming;
|
|
final bool serverStreaming;
|
|
|
|
const DiscoveredMethod({
|
|
required this.name,
|
|
required this.inputType,
|
|
required this.outputType,
|
|
required this.clientStreaming,
|
|
required this.serverStreaming,
|
|
});
|
|
|
|
@override
|
|
String toString() => 'DiscoveredMethod($name)';
|
|
}
|
|
|
|
/// Client for discovering gRPC services using server reflection.
|
|
///
|
|
/// Connects to a gRPC server's reflection service to enumerate available
|
|
/// services and their method signatures. This is useful for development
|
|
/// and proto generation.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// final discovery = GrpcDiscoveryClient(config: GrpcConfig.development);
|
|
/// try {
|
|
/// final services = await discovery.listServices();
|
|
/// for (final service in services) {
|
|
/// print('Service: $service');
|
|
/// }
|
|
/// } finally {
|
|
/// await discovery.close();
|
|
/// }
|
|
/// ```
|
|
class GrpcDiscoveryClient {
|
|
final GrpcConfig config;
|
|
late final ClientChannel _channel;
|
|
late final ServerReflectionClient _stub;
|
|
bool _isInitialized = false;
|
|
|
|
GrpcDiscoveryClient({required this.config});
|
|
|
|
/// Initializes the gRPC channel and reflection client.
|
|
///
|
|
/// This is called automatically on first use, but can be called
|
|
/// explicitly to verify connection.
|
|
void _ensureInitialized() {
|
|
if (_isInitialized) return;
|
|
|
|
_channel = ClientChannel(
|
|
config.host,
|
|
port: config.port,
|
|
options: ChannelOptions(
|
|
credentials: config.useTls
|
|
? const ChannelCredentials.secure()
|
|
: const ChannelCredentials.insecure(),
|
|
connectionTimeout: config.timeout,
|
|
),
|
|
);
|
|
_stub = ServerReflectionClient(_channel);
|
|
_isInitialized = true;
|
|
}
|
|
|
|
/// Lists all available services on the server.
|
|
///
|
|
/// Returns a list of fully qualified service names.
|
|
/// Excludes the reflection service itself by default.
|
|
///
|
|
/// Throws [ConnectionException] if connection fails.
|
|
/// Throws [ReflectionException] if reflection service returns an error.
|
|
Future<List<String>> listServices({bool includeReflection = false}) async {
|
|
_ensureInitialized();
|
|
|
|
try {
|
|
final responseStream = _stub.serverReflectionInfo(
|
|
_createRequestStream([
|
|
ServerReflectionRequest()..listServices = '',
|
|
]),
|
|
);
|
|
|
|
final responses = await responseStream.toList();
|
|
|
|
if (responses.isEmpty) {
|
|
throw ReflectionException('No response from reflection service', 0);
|
|
}
|
|
|
|
final response = responses.first;
|
|
if (response.hasErrorResponse()) {
|
|
throw ReflectionException(
|
|
response.errorResponse.errorMessage,
|
|
response.errorResponse.errorCode,
|
|
);
|
|
}
|
|
|
|
List<String> services = response.listServicesResponse.service
|
|
.map((s) => s.name)
|
|
.toList();
|
|
|
|
if (!includeReflection) {
|
|
services = services
|
|
.where((s) => !s.contains('reflection'))
|
|
.toList();
|
|
}
|
|
|
|
return services;
|
|
} on GrpcError catch (e) {
|
|
throw ConnectionException('Failed to connect to ${config.address}: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// Gets file descriptors for a symbol (service, message, etc.).
|
|
///
|
|
/// Returns a list of [FileDescriptorProto] that define the symbol
|
|
/// and all its transitive dependencies.
|
|
///
|
|
/// Throws [ConnectionException] if connection fails.
|
|
/// Throws [ReflectionException] if symbol not found or other error.
|
|
Future<List<FileDescriptorProto>> getFileDescriptorsForSymbol(
|
|
String symbol,
|
|
) async {
|
|
_ensureInitialized();
|
|
|
|
try {
|
|
final responseStream = _stub.serverReflectionInfo(
|
|
_createRequestStream([
|
|
ServerReflectionRequest()..fileContainingSymbol = symbol,
|
|
]),
|
|
);
|
|
|
|
final responses = await responseStream.toList();
|
|
|
|
if (responses.isEmpty) {
|
|
throw ReflectionException('No response for symbol: $symbol', 0);
|
|
}
|
|
|
|
final response = responses.first;
|
|
if (response.hasErrorResponse()) {
|
|
throw ReflectionException(
|
|
response.errorResponse.errorMessage,
|
|
response.errorResponse.errorCode,
|
|
);
|
|
}
|
|
|
|
return response.fileDescriptorResponse.fileDescriptorProto
|
|
.map((bytes) => FileDescriptorProto.fromBuffer(bytes))
|
|
.toList();
|
|
} on GrpcError catch (e) {
|
|
throw ConnectionException('Failed to get descriptors: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// Gets file descriptors by filename.
|
|
///
|
|
/// Returns a list of [FileDescriptorProto] for the specified file
|
|
/// and its dependencies.
|
|
///
|
|
/// Throws [ConnectionException] if connection fails.
|
|
/// Throws [ReflectionException] if file not found or other error.
|
|
Future<List<FileDescriptorProto>> getFileDescriptorsByName(
|
|
String filename,
|
|
) async {
|
|
_ensureInitialized();
|
|
|
|
try {
|
|
final responseStream = _stub.serverReflectionInfo(
|
|
_createRequestStream([
|
|
ServerReflectionRequest()..fileByFilename = filename,
|
|
]),
|
|
);
|
|
|
|
final responses = await responseStream.toList();
|
|
|
|
if (responses.isEmpty) {
|
|
throw ReflectionException('No response for file: $filename', 0);
|
|
}
|
|
|
|
final response = responses.first;
|
|
if (response.hasErrorResponse()) {
|
|
throw ReflectionException(
|
|
response.errorResponse.errorMessage,
|
|
response.errorResponse.errorCode,
|
|
);
|
|
}
|
|
|
|
return response.fileDescriptorResponse.fileDescriptorProto
|
|
.map((bytes) => FileDescriptorProto.fromBuffer(bytes))
|
|
.toList();
|
|
} on GrpcError catch (e) {
|
|
throw ConnectionException('Failed to get file descriptor: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// Discovers all services and their methods.
|
|
///
|
|
/// Returns a list of [DiscoveredService] containing service names
|
|
/// and their method signatures.
|
|
///
|
|
/// This is a convenience method that combines [listServices] and
|
|
/// [getFileDescriptorsForSymbol] to provide detailed service information.
|
|
Future<List<DiscoveredService>> discoverAllServices() async {
|
|
final serviceNames = await listServices();
|
|
final List<DiscoveredService> discoveredServices = [];
|
|
|
|
for (final serviceName in serviceNames) {
|
|
try {
|
|
final descriptors = await getFileDescriptorsForSymbol(serviceName);
|
|
final methods = <DiscoveredMethod>[];
|
|
|
|
for (final descriptor in descriptors) {
|
|
for (final service in descriptor.service) {
|
|
// Match the service by fully qualified name
|
|
final fullName = descriptor.package.isEmpty
|
|
? service.name
|
|
: '${descriptor.package}.${service.name}';
|
|
|
|
if (fullName == serviceName) {
|
|
for (final method in service.method) {
|
|
methods.add(DiscoveredMethod(
|
|
name: method.name,
|
|
inputType: method.inputType,
|
|
outputType: method.outputType,
|
|
clientStreaming: method.clientStreaming,
|
|
serverStreaming: method.serverStreaming,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
discoveredServices.add(DiscoveredService(
|
|
name: serviceName,
|
|
methods: methods,
|
|
));
|
|
} on GrpcDiscoveryException {
|
|
// If we can't get details for a service, add it with no methods
|
|
discoveredServices.add(DiscoveredService(
|
|
name: serviceName,
|
|
methods: const [],
|
|
));
|
|
}
|
|
}
|
|
|
|
return discoveredServices;
|
|
}
|
|
|
|
/// Discovers services matching a pattern.
|
|
///
|
|
/// Filters discovered services by name containing the [pattern].
|
|
/// Case-insensitive matching.
|
|
Future<List<DiscoveredService>> discoverServicesMatching(String pattern) async {
|
|
final services = await discoverAllServices();
|
|
final lowerPattern = pattern.toLowerCase();
|
|
return services
|
|
.where((s) => s.name.toLowerCase().contains(lowerPattern))
|
|
.toList();
|
|
}
|
|
|
|
Stream<ServerReflectionRequest> _createRequestStream(
|
|
List<ServerReflectionRequest> requests,
|
|
) async* {
|
|
for (final request in requests) {
|
|
yield request;
|
|
}
|
|
}
|
|
|
|
/// Closes the gRPC channel connection.
|
|
///
|
|
/// Should be called when done with discovery to release resources.
|
|
Future<void> close() async {
|
|
if (_isInitialized) {
|
|
await _channel.shutdown();
|
|
_isInitialized = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Utility function to discover and print gRPC services.
|
|
///
|
|
/// This is a convenience function for development and debugging.
|
|
/// Connects to the server specified in [config], discovers all services,
|
|
/// and prints them to the console.
|
|
///
|
|
/// Returns a map of service names to their discovered service details.
|
|
Future<Map<String, DiscoveredService>> discoverAndPrintServices({
|
|
GrpcConfig config = GrpcConfig.development,
|
|
}) async {
|
|
final discovery = GrpcDiscoveryClient(config: config);
|
|
|
|
try {
|
|
final services = await discovery.discoverAllServices();
|
|
final Map<String, DiscoveredService> result = {};
|
|
|
|
for (final service in services) {
|
|
result[service.name] = service;
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
await discovery.close();
|
|
}
|
|
}
|