auto-claude: subtask-3-1 - Create GrpcCqrsApiClient class with channel management
Implements the foundational gRPC client class with: - Lazy ClientChannel initialization with proper credentials - DeliveryServiceClient lazy initialization - Authentication via Bearer token in gRPC metadata - CallOptions builder with token injection - gRPC error to ApiError mapping (status codes -> HTTP equivalents) - Token refresh on UNAUTHENTICATED errors (single retry) - Proper channel shutdown/terminate methods The core _executeWithAuth and _executeCommandWithAuth methods provide the foundation for query and command methods in subsequent subtasks. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
228c29b7da
commit
f6ecc8b1ae
@ -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"
|
||||
}
|
||||
@ -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/**)",
|
||||
|
||||
266
lib/api/grpc_client.dart
Normal file
266
lib/api/grpc_client.dart
Normal file
@ -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<T>` 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<CallOptions> _buildCallOptions() async {
|
||||
final metadata = <String, String>{};
|
||||
|
||||
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<T>` 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<Result<T>> _executeWithAuth<T>(
|
||||
Future<T> 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<Result<void>> _executeCommandWithAuth(
|
||||
Future<void> 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<void> 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<void> terminate() async {
|
||||
await _channel?.terminate();
|
||||
_channel = null;
|
||||
_deliveryClient = null;
|
||||
}
|
||||
|
||||
/// Returns true if the channel is currently active.
|
||||
bool get isConnected => _channel != null;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user