auto-claude: subtask-2-1 - Create service discovery utility using ReflectionClient
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>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user