11 KiB
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 implementationapi_response.dart- Generic API response wrapper matching backend formatapi_client_example.dart- Comprehensive usage examplesREADME.md- This documentation
Installation
The API client requires the following dependencies (already added to pubspec.yaml):
dependencies:
dio: ^5.3.2
flutter_secure_storage: ^9.0.0
Quick Start
1. Initialize API Client
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
final response = await apiClient.get(
'/warehouses',
queryParameters: {'limit': 10},
);
POST Request
final response = await apiClient.post(
'/auth/login',
data: {
'username': 'user@example.com',
'password': 'password123',
},
);
PUT Request
final response = await apiClient.put(
'/products/123',
data: {'name': 'Updated Name'},
);
DELETE Request
final response = await apiClient.delete('/products/123');
API Response Format
All API responses follow this standard format from the backend:
{
"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:
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
// 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:
// 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:
- Error is logged
- All tokens are cleared from secure storage
onUnauthorizedcallback is triggered- App can navigate to login screen
// 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:
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 errorsServerException: 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:
// Logged as:
Headers: {Authorization: ***REDACTED***, Content-Type: application/json}
Configuration
Timeout Settings
Configure timeouts in lib/core/constants/app_constants.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:
static const String apiBaseUrl = 'https://api.example.com';
Or update dynamically:
// 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:
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
final isConnected = await apiClient.testConnection();
if (!isConnected) {
print('Cannot connect to API');
}
Check Authentication
final isAuthenticated = await apiClient.isAuthenticated();
if (!isAuthenticated) {
// Navigate to login
}
Get Current Token
final token = await apiClient.getAccessToken();
if (token != null) {
print('Token exists');
}
Clear Authentication
// Logout - clears all tokens
await apiClient.clearAuth();
Integration with Repository Pattern
The API client is designed to work with the repository pattern:
// 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:
final getIt = GetIt.instance;
// Register SecureStorage
getIt.registerLazySingleton<SecureStorage>(() => SecureStorage());
// Register ApiClient
getIt.registerLazySingleton<ApiClient>(
() => ApiClient(
getIt<SecureStorage>(),
onUnauthorized: () {
// Handle unauthorized access
},
),
);
Best Practices
- Always use ApiResponse: Parse all responses using the
ApiResponsewrapper - Handle errors gracefully: Catch specific exception types for better error handling
- Use endpoints constants: Define all endpoints in
api_endpoints.dart - Don't expose Dio: Use the provided methods (get, post, put, delete) instead of accessing
diodirectly - Test connection: Use
testConnection()before critical operations - Log appropriately: The client logs automatically, but you can add app-level logs too
Testing
Mock the API client in tests:
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
onUnauthorizedcallback 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:
- Check this documentation
- Review
api_client_example.dart - Check Flutter DevTools logs
- Review backend API documentation