ApiX
Production-ready Flutter/Dart API client with auth refresh queue, exponential retry, smart caching and error tracking (Sentry-ready). Powered by Dio.
Why ApiX?
Flutter developers spend considerable time reimplementing the same patterns: refresh token, retry, cache, error handling. ApiX combines all of this into a turnkey solution.
| Problem | ApiX Solution |
|---|---|
| Refresh token race conditions | Automatic refresh queue |
| Manual retry with backoff | Built-in RetryInterceptor |
| Complex cache configuration | Ready-to-use strategies |
| Poorly typed errors | Granular exception hierarchy |
Quick Start
import 'package:apix/apix.dart';
// Simple - works immediately
final client = ApiClientFactory.create(baseUrl: 'https://api.example.com');
final response = await client.get<Map<String, dynamic>>('/users');
30 seconds from pub add to your first request.
Installation
dependencies:
apix: ^1.5.0
flutter pub get
Full Configuration
ApiX supports declarative configuration with 6 optional parameters:
final tokenProvider = SecureTokenProvider();
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
// 🔐 Authentication with automatic refresh
authConfig: AuthConfig(
tokenProvider: tokenProvider,
refreshEndpoint: '/auth/refresh',
onTokenRefreshed: (response) async {
final data = response.data as Map<String, dynamic>;
await tokenProvider.saveTokens(
data['access_token'] as String,
data['refresh_token'] as String,
);
},
onAuthFailure: (tokenProvider, error) async {
await tokenProvider.clearTokens();
// Navigate to login, show dialog, etc.
},
),
// 🔄 Retry with exponential backoff
retryConfig: const RetryConfig(
maxAttempts: 3,
retryStatusCodes: [500, 502, 503, 504],
maxDelayMs: 30000, // Cap at 30s
),
// 💾 Smart caching
cacheConfig: CacheConfig(
strategy: CacheStrategy.networkFirst,
defaultTtl: const Duration(minutes: 5),
),
// 📊 Configurable logging
loggerConfig: const LoggerConfig(
level: LogLevel.info,
redactedHeaders: ['Authorization'],
),
// 🐛 Error tracking (SentrySetup helper, Firebase Crashlytics)
errorTrackingConfig: ErrorTrackingConfig(
onError: (e, {stackTrace, extra, tags}) async {
// SentrySetup
await SentrySetup.captureException(
e,
stackTrace: stackTrace,
extra: extra,
tags: tags,
);
// Firebase Crashlytics
FirebaseCrashlytics.instance.recordError(e, stackTrace);
// Custom / Debug
debugPrint('Error: $e');
},
),
// 📈 Request metrics (Firebase, Amplitude, etc.)
metricsConfig: const MetricsConfig(
onMetrics: (metrics) {
// Example with your analytics service
debugPrint('${metrics.method} ${metrics.path} - ${metrics.durationMs}ms');
},
),
);
Features
🔐 Authentication & Secure Storage
// SecureTokenProvider uses flutter_secure_storage
final tokenProvider = SecureTokenProvider();
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
authConfig: AuthConfig(
tokenProvider: tokenProvider,
refreshEndpoint: '/auth/refresh',
onTokenRefreshed: (response) async {
final data = response.data as Map<String, dynamic>;
await tokenProvider.saveTokens(
data['access_token'] as String,
data['refresh_token'] as String,
);
},
// Called when refresh fails — clear tokens and redirect to login
onAuthFailure: (tokenProvider, error) async {
debugPrint('Auth failed: $error');
await tokenProvider.clearTokens();
// router.go('/login');
},
),
);
// After login
await tokenProvider.saveTokens(accessToken, refreshToken);
// Logout
await tokenProvider.clearTokens();
Refresh token queue: If multiple requests fail with 401, only one refresh is triggered and all requests wait then retry automatically. If refresh fails, onAuthFailure is called once (not per queued request).
🔄 Retry with Exponential Backoff
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
retryConfig: const RetryConfig(
maxAttempts: 3,
retryStatusCodes: [500, 502, 503, 504],
baseDelayMs: 1000,
multiplier: 2.0, // 1s → 2s → 4s
maxDelayMs: 30000, // Never wait more than 30s
),
);
// Disable retry for a specific request
final response = await client.get<Map<String, dynamic>>(
'/critical-endpoint',
options: Options(extra: {noRetryKey: true}),
);
💾 Smart Caching
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
cacheConfig: CacheConfig(
strategy: CacheStrategy.networkFirst,
defaultTtl: const Duration(minutes: 5),
),
);
// Override per request
final config = await client.get<Map<String, dynamic>>(
'/app-config',
options: Options(extra: {
'cacheStrategy': CacheStrategy.cacheFirst,
'cacheTtl': const Duration(hours: 24),
}),
);
// Force refresh
final fresh = await client.get<Map<String, dynamic>>(
'/users',
options: Options(extra: {'forceRefresh': true}),
);
| Strategy | Behavior |
|---|---|
cacheFirst |
Cache first, network in background |
networkFirst |
Network first, fallback to cache |
cacheOnly |
Cache only |
networkOnly |
Network only |
📊 Logging
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
loggerConfig: const LoggerConfig(
level: LogLevel.info,
redactedHeaders: ['Authorization', 'Cookie'],
),
);
| Level | Description |
|---|---|
none |
No logs |
error |
Errors only |
warn |
Warnings + errors |
info |
Info + warnings + errors |
trace |
Everything (debug) |
🐛 Sentry Integration
1. Sentry initialization (in main.dart):
void main() async {
await SentrySetup.init(
options: SentrySetupOptions.production(
dsn: 'https://xxx@xxx.ingest.sentry.io/xxx',
),
appRunner: () => runApp(const MyApp()),
);
}
// Or development mode (no traces/replays)
await SentrySetup.init(
options: SentrySetupOptions.development(
dsn: 'your-sentry-dsn',
),
appRunner: () => runApp(const MyApp()),
);
2. API client configuration:
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
errorTrackingConfig: ErrorTrackingConfig(
onError: SentrySetup.captureException,
onBreadcrumb: SentrySetup.addBreadcrumbFromMap,
),
);
| Option | Description |
|---|---|
captureStatusCodes |
HTTP status codes to capture (default: 5xx) |
captureRequestBody |
Include request body (default: false) |
captureResponseBody |
Include response body (default: true) |
redactedHeaders |
Headers to redact (Authorization, Cookie...) |
Error Handling
Automatic Error Transformation
ApiX automatically transforms all Dio errors into typed exceptions via ErrorMapperInterceptor (added automatically):
| Dio Error | ApiX Exception |
|---|---|
connectionTimeout, sendTimeout, receiveTimeout |
TimeoutException |
connectionError |
ConnectionException |
| HTTP 401 | UnauthorizedException |
| HTTP 403 | ForbiddenException |
| HTTP 404 | NotFoundException |
| HTTP 4xx/5xx | HttpException |
The message is automatically extracted from the API response body. Supports flat and nested formats:
{ "message": "Bad request" } → "Bad request"
{ "detail": "Not found" } → "Not found"
{ "error": "Access denied" } → "Access denied"
{ "error": { "message": "Invalid credentials" } } → "Invalid credentials"
{ "error": { "detail": "..." } } → "..."
Falls back to "HTTP {statusCode}" if no known field is found.
Exception Hierarchy
ApiException
├── NetworkException
│ ├── TimeoutException
│ └── ConnectionException
└── HttpException
├── ClientException (4xx)
│ ├── UnauthorizedException (401)
│ │ └── AuthException (refresh failure)
│ ├── ForbiddenException (403)
│ └── NotFoundException (404)
└── ServerException (5xx)
Classic Try-catch
Dio wraps all errors in DioException. Extract the typed ApiException from e.error:
try {
final response = await client.get<Map<String, dynamic>>('/users');
} on DioException catch (e) {
final error = e.error;
if (error is NotFoundException) {
print('User not found: ${error.message}');
} else if (error is UnauthorizedException) {
print('Please login again');
} else if (error is NetworkException) {
print('Check your connection: ${error.message}');
} else {
print('API error: $error');
}
}
Result Pattern (Recommended)
getResult() unwraps DioException automatically — you get typed ApiException directly:
final result = await client.get<Map<String, dynamic>>('/users').getResult();
result.when(
success: (response) => print('Got ${response.data}'),
failure: (error) => print('Error: ${error.message}'),
);
// Or with fold
final message = result.fold(
onSuccess: (response) => 'Got ${response.data}',
onFailure: (error) => 'Error: ${error.message}',
);
Typed Response Methods
ApiX provides 3 levels of response handling, from raw to fully typed with envelope unwrapping.
Level 1: Standard — Raw Response<T>
final response = await client.get<Map<String, dynamic>>('/users/1');
final data = response.data; // Map<String, dynamic>
Available for all HTTP verbs: get, post, put, delete, patch.
Level 2: Parse & Decode — Format response.data
Directly formats response.data (non-nullable). Available for all verbs.
// Decode: Map<String, dynamic> → typed object (tear-off friendly)
final user = await client.getAndDecode('/users/1', User.fromJson);
// Parse: dynamic → any type (flexible)
final count = await client.getAndParse('/users/count', (data) => data as int);
// POST variants
final created = await client.postAndDecode('/users', {'name': 'John'}, User.fromJson);
final token = await client.postAndParse('/auth', creds, (data) => data as String);
// PUT / PATCH also available
final updated = await client.putAndDecode('/users/1', body, User.fromJson);
final patched = await client.patchAndDecode('/users/1', body, User.fromJson);
Level 3: Data Methods — Envelope Unwrapping
For APIs that wrap responses in an envelope like { "data": { ... } }.
Extracts response.data[dataKey] then formats. GET & POST only.
// Configure dataKey globally (default: 'data')
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
// dataKey defaults to 'data', customize if needed:
// Use ApiClientConfig(baseUrl: '...', dataKey: 'result') for { "result": { ... } }
);
Single Object
// Response: { "data": { "id": 1, "name": "John" } }
final user = await client.getAndDecodeData('/users/1', User.fromJson);
// Response: { "data": null } → returns null
final user = await client.getAndDecodeDataOrNull('/users/1', User.fromJson);
// Parse variant for non-JSON types
// Response: { "data": "2024-01-01T00:00:00Z" }
final date = await client.getAndParseData('/time', (d) => DateTime.parse(d as String));
final date = await client.getAndParseDataOrNull('/time', (d) => DateTime.parse(d as String));
Lists
// Response: { "data": [{ "id": 1 }, { "id": 2 }] }
final users = await client.getListAndDecodeData('/users', User.fromJson);
final users = await client.getListAndDecodeDataOrNull('/users', User.fromJson); // null if data is null
final users = await client.getListAndDecodeDataOrEmpty('/users', User.fromJson); // [] if data is null
// Response: { "data": ["admin", "editor"] }
final roles = await client.getListAndParseData('/roles', (item) => item as String);
final roles = await client.getListAndParseDataOrNull('/roles', (item) => item as String);
final roles = await client.getListAndParseDataOrEmpty('/roles', (item) => item as String);
POST Data
// Response: { "data": { "id": 1, "name": "John" } }
final user = await client.postAndDecodeData('/users', {'name': 'John'}, User.fromJson);
final user = await client.postAndDecodeDataOrNull('/users', body, User.fromJson);
// List responses
final results = await client.postListAndDecodeData('/search', query, User.fromJson);
final results = await client.postListAndDecodeDataOrEmpty('/search', query, User.fromJson);
Method Summary
| Level | Methods | Source | Verbs | Variants |
|---|---|---|---|---|
| Standard | get, post, put, delete, patch |
Response<T> |
all | — |
| Parse/Decode | {verb}AndParse, {verb}AndDecode |
response.data |
all | non-nullable only |
| Data | {verb}And{Parse|Decode}Data |
response.data[dataKey] |
GET, POST | OrNull, List, ListOrNull, ListOrEmpty |
API Reference
ApiClientFactory.create
| Parameter | Type | Description |
|---|---|---|
baseUrl |
String |
Base URL (required) |
connectTimeout |
Duration |
Connection timeout (30s) |
receiveTimeout |
Duration |
Receive timeout (30s) |
headers |
Map<String, dynamic> |
Default headers |
authConfig |
AuthConfig? |
Auth configuration |
retryConfig |
RetryConfig? |
Retry configuration |
cacheConfig |
CacheConfig? |
Cache configuration |
loggerConfig |
LoggerConfig? |
Logging configuration |
errorTrackingConfig |
ErrorTrackingConfig? |
Error tracking configuration |
metricsConfig |
MetricsConfig? |
Metrics configuration |
interceptors |
List<Interceptor>? |
Custom interceptors |
ApiClientConfig
| Parameter | Type | Default | Description |
|---|---|---|---|
baseUrl |
String |
required | Base URL for all requests |
connectTimeout |
Duration |
30s | Connection timeout |
receiveTimeout |
Duration |
30s | Receive timeout |
sendTimeout |
Duration |
30s | Send timeout |
headers |
Map<String, dynamic>? |
null | Default headers |
defaultContentType |
String? |
'application/json' |
Default content type |
interceptors |
List<Interceptor>? |
null | Custom interceptors |
dataKey |
String |
'data' |
Key for envelope unwrapping in *Data methods |
Built-in Interceptors
| Interceptor | Added via | Description |
|---|---|---|
AuthInterceptor |
authConfig |
Token injection + refresh queue |
RetryInterceptor |
retryConfig |
Retry with backoff |
CacheInterceptor |
cacheConfig |
Multi-strategy cache |
LoggerInterceptor |
loggerConfig |
Request/response logging |
ErrorTrackingInterceptor |
errorTrackingConfig |
Error tracking |
MetricsInterceptor |
metricsConfig |
Request metrics |
ErrorMapperInterceptor |
Automatic | Transforms DioException → ApiException |
Example App
A complete Flutter app demonstrating all ApiX features is available on GitHub:
Features demonstrated:
- 🔐 SecureTokenProvider with simplified refresh flow
- 💾 Cache strategies (CacheFirst, NetworkFirst, HttpCache)
- 🔄 Retry logic with exponential backoff
- 🐛 Sentry integration with error testing
- 📊 Request metrics and logging
Contributing
Contributions are welcome! Please read our contributing guidelines first.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'feat: add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Acknowledgments
- Built on top of Dio
- Inspired by best practices from production Flutter apps
Made with ❤️ by Germinator