fill
This commit is contained in:
458
lib/core/network/README.md
Normal file
458
lib/core/network/README.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# API Client - Network Module
|
||||
|
||||
A robust API client for the Flutter warehouse management app, built on top of Dio with comprehensive error handling, authentication management, and request/response logging.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Token Management**: Automatically injects Bearer tokens from secure storage
|
||||
- **401 Error Handling**: Automatically clears tokens and triggers logout on unauthorized access
|
||||
- **Request/Response Logging**: Comprehensive logging for debugging with sensitive data redaction
|
||||
- **Error Transformation**: Converts Dio exceptions to custom app exceptions
|
||||
- **Timeout Configuration**: Configurable connection, receive, and send timeouts (30 seconds)
|
||||
- **Secure Storage Integration**: Uses flutter_secure_storage for token management
|
||||
- **Environment Support**: Easy base URL switching for different environments
|
||||
|
||||
## Files
|
||||
|
||||
- `api_client.dart` - Main API client implementation
|
||||
- `api_response.dart` - Generic API response wrapper matching backend format
|
||||
- `api_client_example.dart` - Comprehensive usage examples
|
||||
- `README.md` - This documentation
|
||||
|
||||
## Installation
|
||||
|
||||
The API client requires the following dependencies (already added to `pubspec.yaml`):
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
dio: ^5.3.2
|
||||
flutter_secure_storage: ^9.0.0
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Initialize API Client
|
||||
|
||||
```dart
|
||||
import 'package:minhthu/core/core.dart';
|
||||
|
||||
// Create secure storage instance
|
||||
final secureStorage = SecureStorage();
|
||||
|
||||
// Create API client with unauthorized callback
|
||||
final apiClient = ApiClient(
|
||||
secureStorage,
|
||||
onUnauthorized: () {
|
||||
// Navigate to login screen
|
||||
context.go('/login');
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Make API Requests
|
||||
|
||||
#### GET Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.get(
|
||||
'/warehouses',
|
||||
queryParameters: {'limit': 10},
|
||||
);
|
||||
```
|
||||
|
||||
#### POST Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.post(
|
||||
'/auth/login',
|
||||
data: {
|
||||
'username': 'user@example.com',
|
||||
'password': 'password123',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
#### PUT Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.put(
|
||||
'/products/123',
|
||||
data: {'name': 'Updated Name'},
|
||||
);
|
||||
```
|
||||
|
||||
#### DELETE Request
|
||||
|
||||
```dart
|
||||
final response = await apiClient.delete('/products/123');
|
||||
```
|
||||
|
||||
## API Response Format
|
||||
|
||||
All API responses follow this standard format from the backend:
|
||||
|
||||
```dart
|
||||
{
|
||||
"Value": {...}, // The actual data
|
||||
"IsSuccess": true, // Success flag
|
||||
"IsFailure": false, // Failure flag
|
||||
"Errors": [], // List of error messages
|
||||
"ErrorCodes": [] // List of error codes
|
||||
}
|
||||
```
|
||||
|
||||
Use the `ApiResponse` class to parse responses:
|
||||
|
||||
```dart
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => User.fromJson(json), // Parse the Value field
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
final user = apiResponse.value;
|
||||
print('Success: ${user.username}');
|
||||
} else {
|
||||
print('Error: ${apiResponse.getErrorMessage()}');
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Login
|
||||
|
||||
```dart
|
||||
// 1. Login via API
|
||||
final response = await apiClient.post('/auth/login', data: credentials);
|
||||
|
||||
// 2. Parse response
|
||||
final apiResponse = ApiResponse.fromJson(response.data, (json) => User.fromJson(json));
|
||||
|
||||
// 3. Save tokens (done by LoginUseCase)
|
||||
if (apiResponse.isSuccess) {
|
||||
final user = apiResponse.value!;
|
||||
await secureStorage.saveAccessToken(user.accessToken);
|
||||
await secureStorage.saveRefreshToken(user.refreshToken);
|
||||
}
|
||||
|
||||
// 4. Subsequent requests automatically include Bearer token
|
||||
```
|
||||
|
||||
### Automatic Token Injection
|
||||
|
||||
The API client automatically adds the Bearer token to all requests:
|
||||
|
||||
```dart
|
||||
// You just make the request
|
||||
final response = await apiClient.get('/warehouses');
|
||||
|
||||
// The interceptor automatically adds:
|
||||
// Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 401 Error Handling
|
||||
|
||||
When a 401 Unauthorized error occurs:
|
||||
|
||||
1. Error is logged
|
||||
2. All tokens are cleared from secure storage
|
||||
3. `onUnauthorized` callback is triggered
|
||||
4. App can navigate to login screen
|
||||
|
||||
```dart
|
||||
// This is handled automatically - no manual intervention needed
|
||||
// Just provide the callback when creating the client:
|
||||
final apiClient = ApiClient(
|
||||
secureStorage,
|
||||
onUnauthorized: () {
|
||||
// This will be called on 401 errors
|
||||
context.go('/login');
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API client transforms Dio exceptions into custom app exceptions:
|
||||
|
||||
```dart
|
||||
try {
|
||||
final response = await apiClient.get('/products');
|
||||
} on NetworkException catch (e) {
|
||||
// Handle network errors (timeout, no internet, etc.)
|
||||
print('Network error: ${e.message}');
|
||||
} on ServerException catch (e) {
|
||||
// Handle server errors (4xx, 5xx)
|
||||
print('Server error: ${e.message}');
|
||||
if (e.code == '401') {
|
||||
// Unauthorized - already handled by interceptor
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle unknown errors
|
||||
print('Unknown error: $e');
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
- `NetworkException`: Connection timeouts, no internet, certificate errors
|
||||
- `ServerException`: HTTP errors (400-599) with specific error codes
|
||||
- 401: Unauthorized (automatically handled)
|
||||
- 403: Forbidden
|
||||
- 404: Not Found
|
||||
- 422: Validation Error
|
||||
- 429: Rate Limited
|
||||
- 500+: Server Errors
|
||||
|
||||
## Logging
|
||||
|
||||
The API client provides comprehensive logging for debugging:
|
||||
|
||||
### Request Logging
|
||||
```
|
||||
REQUEST[GET] => https://api.example.com/warehouses
|
||||
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
|
||||
Query Params: {limit: 10}
|
||||
Body: {...}
|
||||
```
|
||||
|
||||
### Response Logging
|
||||
```
|
||||
RESPONSE[200] => https://api.example.com/warehouses
|
||||
Data: {...}
|
||||
```
|
||||
|
||||
### Error Logging
|
||||
```
|
||||
ERROR[401] => https://api.example.com/warehouses
|
||||
Error Data: {Errors: [Unauthorized access], ErrorCodes: [AUTH_001]}
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
All sensitive headers (Authorization, api-key, token) are automatically redacted in logs:
|
||||
|
||||
```dart
|
||||
// Logged as:
|
||||
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Timeout Settings
|
||||
|
||||
Configure timeouts in `lib/core/constants/app_constants.dart`:
|
||||
|
||||
```dart
|
||||
static const int connectionTimeout = 30000; // 30 seconds
|
||||
static const int receiveTimeout = 30000; // 30 seconds
|
||||
static const int sendTimeout = 30000; // 30 seconds
|
||||
```
|
||||
|
||||
### Base URL
|
||||
|
||||
Configure base URL in `lib/core/constants/app_constants.dart`:
|
||||
|
||||
```dart
|
||||
static const String apiBaseUrl = 'https://api.example.com';
|
||||
```
|
||||
|
||||
Or update dynamically:
|
||||
|
||||
```dart
|
||||
// For different environments
|
||||
apiClient.updateBaseUrl('https://dev-api.example.com'); // Development
|
||||
apiClient.updateBaseUrl('https://staging-api.example.com'); // Staging
|
||||
apiClient.updateBaseUrl('https://api.example.com'); // Production
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Define endpoints in `lib/core/constants/api_endpoints.dart`:
|
||||
|
||||
```dart
|
||||
class ApiEndpoints {
|
||||
static const String login = '/auth/login';
|
||||
static const String warehouses = '/warehouses';
|
||||
static const String products = '/products';
|
||||
|
||||
// Dynamic endpoints
|
||||
static String productById(int id) => '/products/$id';
|
||||
|
||||
// Query parameters helper
|
||||
static Map<String, dynamic> productQueryParams({
|
||||
required int warehouseId,
|
||||
required String type,
|
||||
}) {
|
||||
return {
|
||||
'warehouseId': warehouseId,
|
||||
'type': type,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Methods
|
||||
|
||||
### Test Connection
|
||||
|
||||
```dart
|
||||
final isConnected = await apiClient.testConnection();
|
||||
if (!isConnected) {
|
||||
print('Cannot connect to API');
|
||||
}
|
||||
```
|
||||
|
||||
### Check Authentication
|
||||
|
||||
```dart
|
||||
final isAuthenticated = await apiClient.isAuthenticated();
|
||||
if (!isAuthenticated) {
|
||||
// Navigate to login
|
||||
}
|
||||
```
|
||||
|
||||
### Get Current Token
|
||||
|
||||
```dart
|
||||
final token = await apiClient.getAccessToken();
|
||||
if (token != null) {
|
||||
print('Token exists');
|
||||
}
|
||||
```
|
||||
|
||||
### Clear Authentication
|
||||
|
||||
```dart
|
||||
// Logout - clears all tokens
|
||||
await apiClient.clearAuth();
|
||||
```
|
||||
|
||||
## Integration with Repository Pattern
|
||||
|
||||
The API client is designed to work with the repository pattern:
|
||||
|
||||
```dart
|
||||
// Remote Data Source
|
||||
class WarehouseRemoteDataSourceImpl implements WarehouseRemoteDataSource {
|
||||
final ApiClient apiClient;
|
||||
|
||||
WarehouseRemoteDataSourceImpl(this.apiClient);
|
||||
|
||||
@override
|
||||
Future<List<Warehouse>> getWarehouses() async {
|
||||
final response = await apiClient.get(ApiEndpoints.warehouses);
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(
|
||||
response.data,
|
||||
(json) => (json as List).map((e) => Warehouse.fromJson(e)).toList(),
|
||||
);
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.value != null) {
|
||||
return apiResponse.value!;
|
||||
} else {
|
||||
throw ServerException(apiResponse.getErrorMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Register the API client with GetIt:
|
||||
|
||||
```dart
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
// Register SecureStorage
|
||||
getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
|
||||
|
||||
// Register ApiClient
|
||||
getIt.registerLazySingleton<ApiClient>(
|
||||
() => ApiClient(
|
||||
getIt<SecureStorage>(),
|
||||
onUnauthorized: () {
|
||||
// Handle unauthorized access
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use ApiResponse**: Parse all responses using the `ApiResponse` wrapper
|
||||
2. **Handle errors gracefully**: Catch specific exception types for better error handling
|
||||
3. **Use endpoints constants**: Define all endpoints in `api_endpoints.dart`
|
||||
4. **Don't expose Dio**: Use the provided methods (get, post, put, delete) instead of accessing `dio` directly
|
||||
5. **Test connection**: Use `testConnection()` before critical operations
|
||||
6. **Log appropriately**: The client logs automatically, but you can add app-level logs too
|
||||
|
||||
## Testing
|
||||
|
||||
Mock the API client in tests:
|
||||
|
||||
```dart
|
||||
class MockApiClient extends Mock implements ApiClient {}
|
||||
|
||||
void main() {
|
||||
late MockApiClient mockApiClient;
|
||||
|
||||
setUp(() {
|
||||
mockApiClient = MockApiClient();
|
||||
});
|
||||
|
||||
test('should get warehouses', () async {
|
||||
// Arrange
|
||||
when(mockApiClient.get(any))
|
||||
.thenAnswer((_) async => Response(
|
||||
data: {'Value': [], 'IsSuccess': true},
|
||||
statusCode: 200,
|
||||
requestOptions: RequestOptions(path: '/warehouses'),
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
final response = await mockApiClient.get('/warehouses');
|
||||
expect(response.statusCode, 200);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token not being added to requests
|
||||
- Ensure token is saved in secure storage
|
||||
- Check if token is expired
|
||||
- Verify `getAccessToken()` returns a value
|
||||
|
||||
### 401 errors not triggering logout
|
||||
- Verify `onUnauthorized` callback is set
|
||||
- Check error interceptor logs
|
||||
- Ensure secure storage is properly initialized
|
||||
|
||||
### Connection timeouts
|
||||
- Check network connectivity
|
||||
- Verify base URL is correct
|
||||
- Increase timeout values if needed
|
||||
|
||||
### Logging not appearing
|
||||
- Use Flutter DevTools or console
|
||||
- Check log level settings
|
||||
- Ensure developer.log is not filtered
|
||||
|
||||
## Examples
|
||||
|
||||
See `api_client_example.dart` for comprehensive usage examples including:
|
||||
- Login flow
|
||||
- GET/POST/PUT/DELETE requests
|
||||
- Error handling
|
||||
- Custom options
|
||||
- Request cancellation
|
||||
- Environment switching
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check this documentation
|
||||
2. Review `api_client_example.dart`
|
||||
3. Check Flutter DevTools logs
|
||||
4. Review backend API documentation
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
246
lib/core/network/api_response.dart
Normal file
246
lib/core/network/api_response.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Generic API response wrapper that handles the standard API response format
|
||||
///
|
||||
/// All API responses follow this structure:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "Value": T,
|
||||
/// "IsSuccess": bool,
|
||||
/// "IsFailure": bool,
|
||||
/// "Errors": List<String>,
|
||||
/// "ErrorCodes": List<String>
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final response = ApiResponse.fromJson(
|
||||
/// jsonData,
|
||||
/// (json) => User.fromJson(json),
|
||||
/// );
|
||||
///
|
||||
/// if (response.isSuccess && response.value != null) {
|
||||
/// // Handle success
|
||||
/// final user = response.value!;
|
||||
/// } else {
|
||||
/// // Handle error
|
||||
/// final errorMessage = response.errors.first;
|
||||
/// }
|
||||
/// ```
|
||||
class ApiResponse<T> extends Equatable {
|
||||
/// The actual data/payload of the response
|
||||
/// Can be null if the API call failed or returned no data
|
||||
final T? value;
|
||||
|
||||
/// Indicates if the API call was successful
|
||||
final bool isSuccess;
|
||||
|
||||
/// Indicates if the API call failed
|
||||
final bool isFailure;
|
||||
|
||||
/// List of error messages if the call failed
|
||||
final List<String> errors;
|
||||
|
||||
/// List of error codes for programmatic error handling
|
||||
final List<String> errorCodes;
|
||||
|
||||
const ApiResponse({
|
||||
this.value,
|
||||
required this.isSuccess,
|
||||
required this.isFailure,
|
||||
this.errors = const [],
|
||||
this.errorCodes = const [],
|
||||
});
|
||||
|
||||
/// Create an ApiResponse from JSON
|
||||
///
|
||||
/// The [fromJsonT] function is used to deserialize the "Value" field.
|
||||
/// If null, the value is used as-is.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// // For single object
|
||||
/// ApiResponse.fromJson(json, (j) => User.fromJson(j))
|
||||
///
|
||||
/// // For list of objects
|
||||
/// ApiResponse.fromJson(
|
||||
/// json,
|
||||
/// (j) => (j as List).map((e) => User.fromJson(e)).toList()
|
||||
/// )
|
||||
///
|
||||
/// // For primitive types or no conversion needed
|
||||
/// ApiResponse.fromJson(json, null)
|
||||
/// ```
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic)? fromJsonT,
|
||||
) {
|
||||
return ApiResponse(
|
||||
value: json['Value'] != null && fromJsonT != null
|
||||
? fromJsonT(json['Value'])
|
||||
: json['Value'] as T?,
|
||||
isSuccess: json['IsSuccess'] ?? false,
|
||||
isFailure: json['IsFailure'] ?? true,
|
||||
errors: json['Errors'] != null
|
||||
? List<String>.from(json['Errors'])
|
||||
: const [],
|
||||
errorCodes: json['ErrorCodes'] != null
|
||||
? List<String>.from(json['ErrorCodes'])
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a successful response (useful for testing or manual creation)
|
||||
factory ApiResponse.success(T value) {
|
||||
return ApiResponse(
|
||||
value: value,
|
||||
isSuccess: true,
|
||||
isFailure: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a failed response (useful for testing or manual creation)
|
||||
factory ApiResponse.failure({
|
||||
required List<String> errors,
|
||||
List<String>? errorCodes,
|
||||
}) {
|
||||
return ApiResponse(
|
||||
isSuccess: false,
|
||||
isFailure: true,
|
||||
errors: errors,
|
||||
errorCodes: errorCodes ?? const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if response has data
|
||||
bool get hasValue => value != null;
|
||||
|
||||
/// Get the first error message if available
|
||||
String? get firstError => errors.isNotEmpty ? errors.first : null;
|
||||
|
||||
/// Get the first error code if available
|
||||
String? get firstErrorCode => errorCodes.isNotEmpty ? errorCodes.first : null;
|
||||
|
||||
/// Get a combined error message from all errors
|
||||
String get combinedErrorMessage {
|
||||
if (errors.isEmpty) return 'An unknown error occurred';
|
||||
return errors.join(', ');
|
||||
}
|
||||
|
||||
/// Convert to a map (useful for serialization or debugging)
|
||||
Map<String, dynamic> toJson(Object? Function(T)? toJsonT) {
|
||||
return {
|
||||
'Value': value != null && toJsonT != null ? toJsonT(value as T) : value,
|
||||
'IsSuccess': isSuccess,
|
||||
'IsFailure': isFailure,
|
||||
'Errors': errors,
|
||||
'ErrorCodes': errorCodes,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a copy with modified fields
|
||||
ApiResponse<T> copyWith({
|
||||
T? value,
|
||||
bool? isSuccess,
|
||||
bool? isFailure,
|
||||
List<String>? errors,
|
||||
List<String>? errorCodes,
|
||||
}) {
|
||||
return ApiResponse(
|
||||
value: value ?? this.value,
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
isFailure: isFailure ?? this.isFailure,
|
||||
errors: errors ?? this.errors,
|
||||
errorCodes: errorCodes ?? this.errorCodes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [value, isSuccess, isFailure, errors, errorCodes];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (isSuccess) {
|
||||
return 'ApiResponse.success(value: $value)';
|
||||
} else {
|
||||
return 'ApiResponse.failure(errors: $errors, errorCodes: $errorCodes)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to convert ApiResponse to nullable value easily
|
||||
extension ApiResponseExtension<T> on ApiResponse<T> {
|
||||
/// Get value if success, otherwise return null
|
||||
T? get valueOrNull => isSuccess ? value : null;
|
||||
|
||||
/// Get value if success, otherwise throw exception with error message
|
||||
T get valueOrThrow {
|
||||
if (isSuccess && value != null) {
|
||||
return value!;
|
||||
}
|
||||
throw Exception(combinedErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// Specialized API response for list data with pagination
|
||||
class PaginatedApiResponse<T> extends ApiResponse<List<T>> {
|
||||
/// Current page number
|
||||
final int currentPage;
|
||||
|
||||
/// Total number of pages
|
||||
final int totalPages;
|
||||
|
||||
/// Total number of items
|
||||
final int totalItems;
|
||||
|
||||
/// Number of items per page
|
||||
final int pageSize;
|
||||
|
||||
/// Whether there is a next page
|
||||
bool get hasNextPage => currentPage < totalPages;
|
||||
|
||||
/// Whether there is a previous page
|
||||
bool get hasPreviousPage => currentPage > 1;
|
||||
|
||||
const PaginatedApiResponse({
|
||||
super.value,
|
||||
required super.isSuccess,
|
||||
required super.isFailure,
|
||||
super.errors,
|
||||
super.errorCodes,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalItems,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
/// Create a PaginatedApiResponse from JSON
|
||||
factory PaginatedApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
List<T> Function(dynamic) fromJsonList,
|
||||
) {
|
||||
final apiResponse = ApiResponse<List<T>>.fromJson(json, fromJsonList);
|
||||
|
||||
return PaginatedApiResponse(
|
||||
value: apiResponse.value,
|
||||
isSuccess: apiResponse.isSuccess,
|
||||
isFailure: apiResponse.isFailure,
|
||||
errors: apiResponse.errors,
|
||||
errorCodes: apiResponse.errorCodes,
|
||||
currentPage: json['CurrentPage'] ?? 1,
|
||||
totalPages: json['TotalPages'] ?? 1,
|
||||
totalItems: json['TotalItems'] ?? 0,
|
||||
pageSize: json['PageSize'] ?? 20,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
...super.props,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
pageSize,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user