Files
minhthu/lib/core/network/api_client.dart
2025-10-28 00:09:46 +07:00

375 lines
10 KiB
Dart

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;
// 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,
connectTimeout: const Duration(milliseconds: AppConstants.connectionTimeout),
receiveTimeout: const Duration(milliseconds: AppConstants.receiveTimeout),
sendTimeout: const Duration(milliseconds: AppConstants.sendTimeout),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_setupInterceptors();
}
/// Setup all Dio interceptors
void _setupInterceptors() {
// Request interceptor - adds auth token and logs requests
_dio.interceptors.add(
InterceptorsWrapper(
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 in debug mode
_logResponse(response);
return handler.next(response);
},
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);
},
),
);
}
/// Make a GET request
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
try {
return await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Make a POST request
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
try {
return await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Make a PUT request
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
try {
return await _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// Make a DELETE request
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
try {
return await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 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) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const NetworkException('Connection timeout. Please check your internet connection.');
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
// 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) {
// 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');
case DioExceptionType.cancel:
return const NetworkException('Request was cancelled');
case DioExceptionType.connectionError:
return const NetworkException('No internet connection. Please check your network settings.');
case DioExceptionType.badCertificate:
return const NetworkException('Certificate verification failed');
case DioExceptionType.unknown:
return ServerException('An unexpected error occurred: ${error.message}');
}
}
/// 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,
);
}
}
/// 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,
);
}
}