ionic-planb-logistic-app-fl.../lib/api/grpc_discovery.dart
Mathias Beaulieu-Duncan 40f19c09f3 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>
2026-01-20 12:52:37 -05:00

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