diff --git a/.auto-claude-status b/.auto-claude-status index b749234..61fea19 100644 --- a/.auto-claude-status +++ b/.auto-claude-status @@ -1,25 +1,25 @@ { "active": true, - "spec": "001-normalize-code-update-packages-widgetify-component", + "spec": "002-migrate-api-routes-from-http-to-grpc", "state": "building", "subtasks": { - "completed": 11, - "total": 14, + "completed": 4, + "total": 13, "in_progress": 1, "failed": 0 }, "phase": { - "current": "Cleanup and Verification", + "current": "gRPC Client Implementation", "id": null, - "total": 3 + "total": 4 }, "workers": { "active": 0, "max": 1 }, "session": { - "number": 12, - "started_at": "2026-01-20T11:20:56.182893" + "number": 5, + "started_at": "2026-01-20T12:45:59.858836" }, - "last_update": "2026-01-20T11:47:55.069999" + "last_update": "2026-01-20T12:58:25.218168" } \ No newline at end of file diff --git a/.claude_settings.json b/.claude_settings.json index 328e8a0..c7c3ffc 100644 --- a/.claude_settings.json +++ b/.claude_settings.json @@ -11,14 +11,14 @@ "Edit(./**)", "Glob(./**)", "Grep(./**)", - "Read(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/**)", - "Write(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/**)", - "Edit(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/**)", - "Glob(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/**)", - "Grep(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/**)", - "Read(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/.auto-claude/specs/001-normalize-code-update-packages-widgetify-component/**)", - "Write(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/.auto-claude/specs/001-normalize-code-update-packages-widgetify-component/**)", - "Edit(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/001-normalize-code-update-packages-widgetify-component/.auto-claude/specs/001-normalize-code-update-packages-widgetify-component/**)", + "Read(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/**)", + "Write(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/**)", + "Edit(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/**)", + "Glob(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/**)", + "Grep(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/**)", + "Read(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/.auto-claude/specs/002-migrate-api-routes-from-http-to-grpc/**)", + "Write(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/.auto-claude/specs/002-migrate-api-routes-from-http-to-grpc/**)", + "Edit(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/worktrees/tasks/002-migrate-api-routes-from-http-to-grpc/.auto-claude/specs/002-migrate-api-routes-from-http-to-grpc/**)", "Read(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/**)", "Write(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/**)", "Edit(/Users/mathias/Documents/workspaces/plan-b/ionic-planb-logistic-app-flutter/.auto-claude/**)", diff --git a/lib/api/grpc_client.dart b/lib/api/grpc_client.dart new file mode 100644 index 0000000..7ed5e0f --- /dev/null +++ b/lib/api/grpc_client.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +import 'package:grpc/grpc.dart'; + +import '../generated/delivery_service.pbgrpc.dart'; +import '../services/auth_service.dart'; +import 'grpc_config.dart'; +import 'types.dart'; + +// ignore_for_file: unused_element + +/// gRPC-based CQRS API client for Plan B Logistics. +/// +/// This client wraps the generated DeliveryServiceClient with `Result` error +/// handling patterns consistent with [CqrsApiClient]. It provides: +/// - Lazy channel initialization +/// - Authentication via Bearer token in gRPC metadata +/// - Automatic token refresh on UNAUTHENTICATED errors +/// - Proper gRPC error to [ApiError] mapping +/// +/// Usage: +/// ```dart +/// final client = GrpcCqrsApiClient( +/// config: GrpcConfig.development, +/// authService: authService, +/// ); +/// +/// final result = await client.getDeliveryRoutes(); +/// result.when( +/// success: (routes) => handleRoutes(routes), +/// onError: (error) => handleError(error), +/// ); +/// ``` +class GrpcCqrsApiClient { + final GrpcConfig config; + final AuthService? authService; + + ClientChannel? _channel; + DeliveryServiceClient? _deliveryClient; + + GrpcCqrsApiClient({ + required this.config, + this.authService, + }); + + /// Returns the gRPC channel, creating it lazily if needed. + /// + /// The channel is configured based on [config.useTls] for development + /// (insecure) vs production (TLS) environments. + ClientChannel get channel { + if (_channel == null) { + final credentials = config.useTls + ? const ChannelCredentials.secure() + : const ChannelCredentials.insecure(); + + _channel = ClientChannel( + config.host, + port: config.port, + options: ChannelOptions( + credentials: credentials, + connectionTimeout: config.timeout, + idleTimeout: const Duration(minutes: 5), + ), + ); + } + return _channel!; + } + + /// Returns the DeliveryService client, creating it lazily if needed. + DeliveryServiceClient get deliveryClient { + _deliveryClient ??= DeliveryServiceClient(channel); + return _deliveryClient!; + } + + /// Builds [CallOptions] with authentication metadata. + /// + /// Includes Bearer token in metadata if [authService] is configured and + /// a valid token is available. Uses [AuthService.ensureValidToken] to + /// proactively refresh tokens that are expiring soon. + Future _buildCallOptions() async { + final metadata = {}; + + if (authService != null) { + final token = await authService!.ensureValidToken(); + if (token != null) { + metadata['authorization'] = 'Bearer $token'; + } + } + + return CallOptions( + metadata: metadata, + timeout: config.timeout, + ); + } + + /// Merges base [CallOptions] with additional options for a specific call. + CallOptions _mergeOptions(CallOptions base, CallOptions? additional) { + if (additional == null) return base; + + return CallOptions( + metadata: { + ...base.metadata, + ...additional.metadata, + }, + timeout: additional.timeout ?? base.timeout, + providers: [ + ...base.metadataProviders, + ...additional.metadataProviders, + ], + ); + } + + /// Maps gRPC [GrpcError] to [ApiError] for consistent error handling. + /// + /// Maps common gRPC status codes to appropriate [ApiErrorType]: + /// - UNAUTHENTICATED (16) -> HTTP 401 + /// - PERMISSION_DENIED (7) -> HTTP 403 + /// - NOT_FOUND (5) -> HTTP 404 + /// - INVALID_ARGUMENT (3) -> Validation error + /// - DEADLINE_EXCEEDED (4) -> Timeout + /// - UNAVAILABLE (14) -> Network error + /// - Other codes -> Unknown error + ApiError _mapGrpcError(GrpcError error) { + switch (error.code) { + case StatusCode.unauthenticated: + return ApiError.http( + statusCode: 401, + message: error.message ?? 'Authentication required', + ); + case StatusCode.permissionDenied: + return ApiError.http( + statusCode: 403, + message: error.message ?? 'Permission denied', + ); + case StatusCode.notFound: + return ApiError.http( + statusCode: 404, + message: error.message ?? 'Resource not found', + ); + case StatusCode.invalidArgument: + return ApiError.validation( + error.message ?? 'Invalid request', + null, + ); + case StatusCode.deadlineExceeded: + return ApiError.timeout(); + case StatusCode.unavailable: + return ApiError.network( + error.message ?? 'Service unavailable', + ); + case StatusCode.internal: + return ApiError.http( + statusCode: 500, + message: error.message ?? 'Internal server error', + ); + default: + return ApiError.unknown( + error.message ?? 'gRPC error: ${error.codeName}', + exception: error, + ); + } + } + + /// Executes a gRPC call with `Result` error handling and authentication. + /// + /// This is the core method that wraps gRPC calls with: + /// - Authentication token injection + /// - Automatic token refresh on UNAUTHENTICATED errors (single retry) + /// - gRPC error to ApiError mapping + /// - Timeout handling + /// + /// [grpcCall] is the actual gRPC method invocation. + /// [isRetry] tracks whether this is a retry after token refresh. + Future> _executeWithAuth( + Future Function(CallOptions options) grpcCall, { + bool isRetry = false, + }) async { + try { + final options = await _buildCallOptions(); + final result = await grpcCall(options); + return Result.success(result); + } on GrpcError catch (error) { + // Handle UNAUTHENTICATED by attempting token refresh (once) + if (error.code == StatusCode.unauthenticated && + !isRetry && + authService != null) { + final refreshResult = await authService!.refreshAccessToken(); + return refreshResult.when( + success: (token) => _executeWithAuth(grpcCall, isRetry: true), + onError: (refreshError) => Result.error(_mapGrpcError(error)), + cancelled: () => Result.error(_mapGrpcError(error)), + ); + } + return Result.error(_mapGrpcError(error)); + } on TimeoutException { + return Result.error(ApiError.timeout()); + } catch (e, stackTrace) { + return Result.error( + ApiError.unknown( + 'gRPC call failed: ${e.toString()}', + exception: Exception(stackTrace.toString()), + ), + ); + } + } + + /// Executes a gRPC call that returns void (for commands). + /// + /// Similar to [_executeWithAuth] but for operations that don't return + /// meaningful data (commands that return success/failure). + Future> _executeCommandWithAuth( + Future Function(CallOptions options) grpcCall, { + bool isRetry = false, + }) async { + try { + final options = await _buildCallOptions(); + await grpcCall(options); + return Result.success(null); + } on GrpcError catch (error) { + if (error.code == StatusCode.unauthenticated && + !isRetry && + authService != null) { + final refreshResult = await authService!.refreshAccessToken(); + return refreshResult.when( + success: (token) => + _executeCommandWithAuth(grpcCall, isRetry: true), + onError: (refreshError) => Result.error(_mapGrpcError(error)), + cancelled: () => Result.error(_mapGrpcError(error)), + ); + } + return Result.error(_mapGrpcError(error)); + } on TimeoutException { + return Result.error(ApiError.timeout()); + } catch (e, stackTrace) { + return Result.error( + ApiError.unknown( + 'gRPC command failed: ${e.toString()}', + exception: Exception(stackTrace.toString()), + ), + ); + } + } + + /// Shuts down the gRPC channel and releases resources. + /// + /// Should be called when the client is no longer needed to properly + /// clean up network resources. + Future shutdown() async { + await _channel?.shutdown(); + _channel = null; + _deliveryClient = null; + } + + /// Terminates the gRPC channel immediately. + /// + /// Unlike [shutdown], this does not wait for pending calls to complete. + /// Use this for emergency cleanup or when the app is terminating. + Future terminate() async { + await _channel?.terminate(); + _channel = null; + _deliveryClient = null; + } + + /// Returns true if the channel is currently active. + bool get isConnected => _channel != null; +}