This commit is contained in:
2025-10-28 00:09:46 +07:00
parent 9ebe7c2919
commit de49f564b1
110 changed files with 15392 additions and 3996 deletions

View File

@@ -1,12 +1,19 @@
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import '../constants/app_constants.dart';
import '../errors/exceptions.dart';
import '../storage/secure_storage.dart';
/// API client for making HTTP requests using Dio
/// Includes token management, request/response logging, and error handling
class ApiClient {
late final Dio _dio;
final SecureStorage _secureStorage;
ApiClient() {
// Callback for 401 unauthorized errors (e.g., to navigate to login)
void Function()? onUnauthorized;
ApiClient(this._secureStorage, {this.onUnauthorized}) {
_dio = Dio(
BaseOptions(
baseUrl: AppConstants.apiBaseUrl,
@@ -20,21 +27,45 @@ class ApiClient {
),
);
// Add request/response interceptors for logging and error handling
_setupInterceptors();
}
/// Setup all Dio interceptors
void _setupInterceptors() {
// Request interceptor - adds auth token and logs requests
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// Log request details in debug mode
handler.next(options);
onRequest: (options, handler) async {
// Add AccessToken header if available
final token = await _secureStorage.getAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['AccessToken'] = token;
}
// Add AppID header
options.headers['AppID'] = AppConstants.appId;
// Log request in debug mode
_logRequest(options);
return handler.next(options);
},
onResponse: (response, handler) {
// Log response details in debug mode
handler.next(response);
// Log response in debug mode
_logResponse(response);
return handler.next(response);
},
onError: (error, handler) {
// Handle different types of errors
_handleDioError(error);
handler.next(error);
onError: (error, handler) async {
// Log error in debug mode
_logError(error);
// Handle 401 unauthorized errors
if (error.response?.statusCode == 401) {
await _handle401Error();
}
return handler.next(error);
},
),
);
@@ -122,6 +153,23 @@ class ApiClient {
}
}
/// Handle 401 Unauthorized errors
Future<void> _handle401Error() async {
developer.log(
'401 Unauthorized - Clearing tokens and triggering logout',
name: 'ApiClient',
level: 900,
);
// Clear all tokens from secure storage
await _secureStorage.clearTokens();
// Trigger the unauthorized callback (e.g., navigate to login)
if (onUnauthorized != null) {
onUnauthorized!();
}
}
/// Handle Dio errors and convert them to custom exceptions
Exception _handleDioError(DioException error) {
switch (error.type) {
@@ -132,13 +180,47 @@ class ApiClient {
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
final message = error.response?.data?['message'] ?? 'Server error occurred';
// Try to extract error message from API response
String message = 'Server error occurred';
if (error.response?.data is Map) {
final data = error.response?.data as Map<String, dynamic>;
// Check for standard API error format
if (data['Errors'] != null && data['Errors'] is List && (data['Errors'] as List).isNotEmpty) {
message = (data['Errors'] as List).first.toString();
} else if (data['message'] != null) {
message = data['message'].toString();
} else if (data['error'] != null) {
message = data['error'].toString();
}
}
if (statusCode != null) {
if (statusCode >= 400 && statusCode < 500) {
return ServerException('Client error: $message (Status: $statusCode)');
} else if (statusCode >= 500) {
return ServerException('Server error: $message (Status: $statusCode)');
// Handle specific status codes
switch (statusCode) {
case 401:
return const ServerException('Unauthorized. Please login again.', code: '401');
case 403:
return const ServerException('Forbidden. You do not have permission.', code: '403');
case 404:
return ServerException('Resource not found: $message', code: '404');
case 422:
return ServerException('Validation error: $message', code: '422');
case 429:
return const ServerException('Too many requests. Please try again later.', code: '429');
case 500:
case 501:
case 502:
case 503:
case 504:
return ServerException('Server error: $message (Status: $statusCode)', code: statusCode.toString());
default:
if (statusCode >= 400 && statusCode < 500) {
return ServerException('Client error: $message (Status: $statusCode)', code: statusCode.toString());
}
return ServerException('HTTP error: $message (Status: $statusCode)', code: statusCode.toString());
}
}
return ServerException('HTTP error: $message');
@@ -153,23 +235,141 @@ class ApiClient {
return const NetworkException('Certificate verification failed');
case DioExceptionType.unknown:
default:
return ServerException('An unexpected error occurred: ${error.message}');
}
}
/// Add authorization header
void addAuthorizationHeader(String token) {
_dio.options.headers['Authorization'] = 'Bearer $token';
/// Log request details
void _logRequest(RequestOptions options) {
developer.log(
'REQUEST[${options.method}] => ${options.uri}',
name: 'ApiClient',
level: 800,
);
if (options.headers.isNotEmpty) {
developer.log(
'Headers: ${_sanitizeHeaders(options.headers)}',
name: 'ApiClient',
level: 800,
);
}
if (options.queryParameters.isNotEmpty) {
developer.log(
'Query Params: ${options.queryParameters}',
name: 'ApiClient',
level: 800,
);
}
if (options.data != null) {
developer.log(
'Body: ${options.data}',
name: 'ApiClient',
level: 800,
);
}
}
/// Remove authorization header
void removeAuthorizationHeader() {
_dio.options.headers.remove('Authorization');
/// Log response details
void _logResponse(Response response) {
developer.log(
'RESPONSE[${response.statusCode}] => ${response.requestOptions.uri}',
name: 'ApiClient',
level: 800,
);
developer.log(
'Data: ${response.data}',
name: 'ApiClient',
level: 800,
);
}
/// Log error details
void _logError(DioException error) {
developer.log(
'ERROR[${error.response?.statusCode}] => ${error.requestOptions.uri}',
name: 'ApiClient',
level: 1000,
error: error,
);
if (error.response?.data != null) {
developer.log(
'Error Data: ${error.response?.data}',
name: 'ApiClient',
level: 1000,
);
}
}
/// Sanitize headers to hide sensitive data in logs
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
final sanitized = Map<String, dynamic>.from(headers);
// Hide Authorization token
if (sanitized.containsKey('Authorization')) {
sanitized['Authorization'] = '***REDACTED***';
}
// Hide any other sensitive headers
final sensitiveKeys = ['api-key', 'x-api-key', 'token'];
for (final key in sensitiveKeys) {
if (sanitized.containsKey(key)) {
sanitized[key] = '***REDACTED***';
}
}
return sanitized;
}
/// Get the Dio instance (use carefully, prefer using the methods above)
Dio get dio => _dio;
/// Update base URL (useful for different environments)
void updateBaseUrl(String newBaseUrl) {
_dio.options.baseUrl = newBaseUrl;
developer.log(
'Base URL updated to: $newBaseUrl',
name: 'ApiClient',
level: 800,
);
}
/// Test connection to the API
Future<bool> testConnection() async {
try {
final response = await _dio.get('/health');
return response.statusCode == 200;
} catch (e) {
developer.log(
'Connection test failed: $e',
name: 'ApiClient',
level: 900,
);
return false;
}
}
/// Get current access token
Future<String?> getAccessToken() async {
return await _secureStorage.getAccessToken();
}
/// Check if user is authenticated
Future<bool> isAuthenticated() async {
return await _secureStorage.isAuthenticated();
}
/// Clear all authentication data
Future<void> clearAuth() async {
await _secureStorage.clearAll();
developer.log(
'Authentication data cleared',
name: 'ApiClient',
level: 800,
);
}
}