375 lines
10 KiB
Dart
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,
|
|
);
|
|
}
|
|
} |