Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend. Implements CQRS architecture, OpenAPI contract-first API design. BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama FRONTEND: Cross-platform UI with strict typing and Result-based error handling Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
402 lines
12 KiB
Dart
402 lines
12 KiB
Dart
/// 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<String, String> 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<bool>(
|
|
/// 'health',
|
|
/// HealthQuery(),
|
|
/// (json) => json as bool,
|
|
/// );
|
|
/// ```
|
|
Future<Result<TResult>> executeQuery<TResult>({
|
|
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<TResult>(response, fromJson);
|
|
} on TimeoutException catch (e) {
|
|
return ApiError<TResult>(
|
|
ApiErrorInfo(
|
|
message: 'Request timeout: ${e.message ?? "Operation took too long"}',
|
|
type: ApiErrorType.timeout,
|
|
),
|
|
);
|
|
} on SocketException catch (e) {
|
|
return ApiError<TResult>(
|
|
ApiErrorInfo(
|
|
message: 'Network error: ${e.message}',
|
|
type: ApiErrorType.network,
|
|
details: e.osError?.message,
|
|
),
|
|
);
|
|
} on http.ClientException catch (e) {
|
|
return ApiError<TResult>(
|
|
ApiErrorInfo(
|
|
message: 'HTTP client error: ${e.message}',
|
|
type: ApiErrorType.http,
|
|
),
|
|
);
|
|
} on FormatException catch (e) {
|
|
return ApiError<TResult>(
|
|
ApiErrorInfo(
|
|
message: 'JSON parsing error: ${e.message}',
|
|
type: ApiErrorType.serialization,
|
|
details: e.source.toString(),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
return ApiError<TResult>(
|
|
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<VehicleItem>(
|
|
/// 'vehicles',
|
|
/// VehiclesQuery(),
|
|
/// (json) => VehicleItem.fromJson(json as Map<String, Object?>),
|
|
/// page: 1,
|
|
/// pageSize: 20,
|
|
/// filters: [FilterCriteria(field: 'status', operator: FilterOperator.equals, value: 'active')],
|
|
/// sorting: [SortCriteria(field: 'createdAt', direction: SortDirection.descending)],
|
|
/// );
|
|
/// ```
|
|
Future<Result<PaginatedResponse<TItem>>> executePaginatedQuery<TItem>({
|
|
required String endpoint,
|
|
required Serializable query,
|
|
required TItem Function(Map<String, Object?> json) itemFromJson,
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
List<FilterCriteria>? filters,
|
|
List<SortCriteria>? 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 = <String, Object?>{
|
|
...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<TItem>(response, itemFromJson);
|
|
} on TimeoutException catch (e) {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
ApiErrorInfo(
|
|
message: 'Request timeout: ${e.message ?? "Operation took too long"}',
|
|
type: ApiErrorType.timeout,
|
|
),
|
|
);
|
|
} on SocketException catch (e) {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
ApiErrorInfo(
|
|
message: 'Network error: ${e.message}',
|
|
type: ApiErrorType.network,
|
|
details: e.osError?.message,
|
|
),
|
|
);
|
|
} on http.ClientException catch (e) {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
ApiErrorInfo(
|
|
message: 'HTTP client error: ${e.message}',
|
|
type: ApiErrorType.http,
|
|
),
|
|
);
|
|
} on FormatException catch (e) {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
ApiErrorInfo(
|
|
message: 'JSON parsing error: ${e.message}',
|
|
type: ApiErrorType.serialization,
|
|
details: e.source.toString(),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
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<Result<void>> 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<void>(
|
|
ApiErrorInfo(
|
|
message: 'Request timeout: ${e.message ?? "Operation took too long"}',
|
|
type: ApiErrorType.timeout,
|
|
),
|
|
);
|
|
} on SocketException catch (e) {
|
|
return ApiError<void>(
|
|
ApiErrorInfo(
|
|
message: 'Network error: ${e.message}',
|
|
type: ApiErrorType.network,
|
|
details: e.osError?.message,
|
|
),
|
|
);
|
|
} on http.ClientException catch (e) {
|
|
return ApiError<void>(
|
|
ApiErrorInfo(
|
|
message: 'HTTP client error: ${e.message}',
|
|
type: ApiErrorType.http,
|
|
),
|
|
);
|
|
} on FormatException catch (e) {
|
|
return ApiError<void>(
|
|
ApiErrorInfo(
|
|
message: 'JSON parsing error: ${e.message}',
|
|
type: ApiErrorType.serialization,
|
|
details: e.source.toString(),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
return ApiError<void>(
|
|
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<String, Object?> _queryToMap(Serializable query) {
|
|
return query.toJson();
|
|
}
|
|
|
|
/// Handle HTTP response for single value queries
|
|
Result<TResult> _handleResponse<TResult>(
|
|
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<TResult>(result);
|
|
} catch (e) {
|
|
return ApiError<TResult>(
|
|
ApiErrorInfo(
|
|
message: 'Failed to parse response',
|
|
statusCode: response.statusCode,
|
|
type: ApiErrorType.serialization,
|
|
details: e.toString(),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
return ApiError<TResult>(
|
|
_parseErrorResponse(response),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Handle HTTP response for paginated queries
|
|
Result<PaginatedResponse<TItem>> _handlePaginatedResponse<TItem>(
|
|
http.Response response,
|
|
TItem Function(Map<String, Object?>) itemFromJson,
|
|
) {
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
try {
|
|
final json = jsonDecode(response.body) as Map<String, Object?>;
|
|
final paginatedResponse = PaginatedResponse<TItem>.fromJson(
|
|
json,
|
|
itemFromJson,
|
|
);
|
|
return ApiSuccess<PaginatedResponse<TItem>>(paginatedResponse);
|
|
} catch (e) {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
ApiErrorInfo(
|
|
message: 'Failed to parse paginated response',
|
|
statusCode: response.statusCode,
|
|
type: ApiErrorType.serialization,
|
|
details: e.toString(),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
return ApiError<PaginatedResponse<TItem>>(
|
|
_parseErrorResponse(response),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Handle HTTP response for commands
|
|
Result<void> _handleCommandResponse(http.Response response) {
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
return const ApiSuccess<void>(null);
|
|
} else {
|
|
return ApiError<void>(_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<String, Object?>;
|
|
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();
|
|
}
|
|
}
|