/// CQRS API Client for communicating with the backend library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'types.dart'; // ============================================================================= // API Client Configuration // ============================================================================= /// Configuration for the API client class ApiClientConfig { final String baseUrl; final Duration timeout; final Map defaultHeaders; const ApiClientConfig({ required this.baseUrl, this.timeout = const Duration(seconds: 30), this.defaultHeaders = const {}, }); /// Default configuration for local development static const ApiClientConfig development = ApiClientConfig( baseUrl: 'http://localhost:5246', timeout: Duration(seconds: 30), defaultHeaders: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, ); } // ============================================================================= // CQRS API Client // ============================================================================= /// HTTP client for CQRS-based API /// /// Provides methods for executing: /// - Queries (read operations) /// - Commands (write operations) /// - Paginated queries (list operations with filtering/sorting) class CqrsApiClient { final ApiClientConfig config; final http.Client _httpClient; CqrsApiClient({ required this.config, http.Client? httpClient, }) : _httpClient = httpClient ?? http.Client(); /// Execute a query that returns a single value /// /// Example: /// ```dart /// final result = await client.executeQuery( /// 'health', /// HealthQuery(), /// (json) => json as bool, /// ); /// ``` Future> executeQuery({ required String endpoint, required Serializable query, required TResult Function(Object? json) fromJson, }) async { try { final url = Uri.parse('${config.baseUrl}/api/query/$endpoint'); final body = _serializeQuery(query); final response = await _httpClient .post( url, headers: config.defaultHeaders, body: body, ) .timeout(config.timeout); return _handleResponse(response, fromJson); } on TimeoutException catch (e) { return ApiError( ApiErrorInfo( message: 'Request timeout: ${e.message ?? "Operation took too long"}', type: ApiErrorType.timeout, ), ); } on SocketException catch (e) { return ApiError( ApiErrorInfo( message: 'Network error: ${e.message}', type: ApiErrorType.network, details: e.osError?.message, ), ); } on http.ClientException catch (e) { return ApiError( ApiErrorInfo( message: 'HTTP client error: ${e.message}', type: ApiErrorType.http, ), ); } on FormatException catch (e) { return ApiError( ApiErrorInfo( message: 'JSON parsing error: ${e.message}', type: ApiErrorType.serialization, details: e.source.toString(), ), ); } catch (e) { return ApiError( ApiErrorInfo( message: 'Unexpected error: $e', type: ApiErrorType.unknown, ), ); } } /// Execute a paginated query that returns a list of items /// /// Example: /// ```dart /// final result = await client.executePaginatedQuery( /// 'vehicles', /// VehiclesQuery(), /// (json) => VehicleItem.fromJson(json as Map), /// page: 1, /// pageSize: 20, /// filters: [FilterCriteria(field: 'status', operator: FilterOperator.equals, value: 'active')], /// sorting: [SortCriteria(field: 'createdAt', direction: SortDirection.descending)], /// ); /// ``` Future>> executePaginatedQuery({ required String endpoint, required Serializable query, required TItem Function(Map json) itemFromJson, int page = 1, int pageSize = 20, List? filters, List? sorting, }) async { try { final url = Uri.parse('${config.baseUrl}/api/query/$endpoint'); // Build request body with query + pagination/filtering/sorting final queryMap = _queryToMap(query); final requestBody = { ...queryMap, 'page': page, 'pageSize': pageSize, if (filters != null && filters.isNotEmpty) 'filters': filters.map((f) => f.toJson()).toList(), if (sorting != null && sorting.isNotEmpty) 'sorts': sorting.map((s) => s.toJson()).toList(), }; final body = jsonEncode(requestBody); final response = await _httpClient .post( url, headers: config.defaultHeaders, body: body, ) .timeout(config.timeout); return _handlePaginatedResponse(response, itemFromJson); } on TimeoutException catch (e) { return ApiError>( ApiErrorInfo( message: 'Request timeout: ${e.message ?? "Operation took too long"}', type: ApiErrorType.timeout, ), ); } on SocketException catch (e) { return ApiError>( ApiErrorInfo( message: 'Network error: ${e.message}', type: ApiErrorType.network, details: e.osError?.message, ), ); } on http.ClientException catch (e) { return ApiError>( ApiErrorInfo( message: 'HTTP client error: ${e.message}', type: ApiErrorType.http, ), ); } on FormatException catch (e) { return ApiError>( ApiErrorInfo( message: 'JSON parsing error: ${e.message}', type: ApiErrorType.serialization, details: e.source.toString(), ), ); } catch (e) { return ApiError>( ApiErrorInfo( message: 'Unexpected error: $e', type: ApiErrorType.unknown, ), ); } } /// Execute a command (write operation) /// /// Example: /// ```dart /// final result = await client.executeCommand( /// 'createVehicle', /// CreateVehicleCommand(name: 'Tesla Model 3'), /// ); /// ``` Future> executeCommand({ required String endpoint, required Serializable command, }) async { try { final url = Uri.parse('${config.baseUrl}/api/command/$endpoint'); final body = _serializeQuery(command); final response = await _httpClient .post( url, headers: config.defaultHeaders, body: body, ) .timeout(config.timeout); return _handleCommandResponse(response); } on TimeoutException catch (e) { return ApiError( ApiErrorInfo( message: 'Request timeout: ${e.message ?? "Operation took too long"}', type: ApiErrorType.timeout, ), ); } on SocketException catch (e) { return ApiError( ApiErrorInfo( message: 'Network error: ${e.message}', type: ApiErrorType.network, details: e.osError?.message, ), ); } on http.ClientException catch (e) { return ApiError( ApiErrorInfo( message: 'HTTP client error: ${e.message}', type: ApiErrorType.http, ), ); } on FormatException catch (e) { return ApiError( ApiErrorInfo( message: 'JSON parsing error: ${e.message}', type: ApiErrorType.serialization, details: e.source.toString(), ), ); } catch (e) { return ApiError( ApiErrorInfo( message: 'Unexpected error: $e', type: ApiErrorType.unknown, ), ); } } // --------------------------------------------------------------------------- // Private Helper Methods // --------------------------------------------------------------------------- /// Serialize query/command object to JSON string String _serializeQuery(Serializable query) { final jsonMap = query.toJson(); return jsonEncode(jsonMap); } /// Convert query object to Map for pagination requests Map _queryToMap(Serializable query) { return query.toJson(); } /// Handle HTTP response for single value queries Result _handleResponse( http.Response response, TResult Function(Object? json) fromJson, ) { if (response.statusCode >= 200 && response.statusCode < 300) { try { final json = jsonDecode(response.body); final result = fromJson(json); return ApiSuccess(result); } catch (e) { return ApiError( ApiErrorInfo( message: 'Failed to parse response', statusCode: response.statusCode, type: ApiErrorType.serialization, details: e.toString(), ), ); } } else { return ApiError( _parseErrorResponse(response), ); } } /// Handle HTTP response for paginated queries Result> _handlePaginatedResponse( http.Response response, TItem Function(Map) itemFromJson, ) { if (response.statusCode >= 200 && response.statusCode < 300) { try { final json = jsonDecode(response.body) as Map; final paginatedResponse = PaginatedResponse.fromJson( json, itemFromJson, ); return ApiSuccess>(paginatedResponse); } catch (e) { return ApiError>( ApiErrorInfo( message: 'Failed to parse paginated response', statusCode: response.statusCode, type: ApiErrorType.serialization, details: e.toString(), ), ); } } else { return ApiError>( _parseErrorResponse(response), ); } } /// Handle HTTP response for commands Result _handleCommandResponse(http.Response response) { if (response.statusCode >= 200 && response.statusCode < 300) { return const ApiSuccess(null); } else { return ApiError(_parseErrorResponse(response)); } } /// Parse error information from HTTP response ApiErrorInfo _parseErrorResponse(http.Response response) { String message = 'Request failed with status ${response.statusCode}'; String? details; try { final json = jsonDecode(response.body) as Map; message = json['message'] as String? ?? message; details = json['details'] as String? ?? json['error'] as String?; } catch (_) { // If JSON parsing fails, use response body as details details = response.body; } return ApiErrorInfo( message: message, statusCode: response.statusCode, type: _determineErrorType(response.statusCode), details: details, ); } /// Determine error type based on HTTP status code ApiErrorType _determineErrorType(int statusCode) { if (statusCode >= 400 && statusCode < 500) { if (statusCode == 422) { return ApiErrorType.validation; } return ApiErrorType.http; } else if (statusCode >= 500) { return ApiErrorType.http; } return ApiErrorType.unknown; } /// Dispose of resources void dispose() { _httpClient.close(); } }