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

458
lib/core/network/README.md Normal file
View 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

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,
);
}
}

View 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,
];
}