apix 2.1.0
apix: ^2.1.0 copied to clipboard
Production-ready Flutter/Dart API client with auth refresh queue, exponential retry, smart caching and error tracking (Sentry-ready). Powered by Dio.
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: ^2.1.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).
Network resilience: When the refresh request itself fails with a connection or timeout error, the original request is rejected with NetworkException (typed: ConnectionException, TimeoutException) โ onAuthFailure is not invoked, so a connectivity blip never logs the user out. Real auth failures (401/403 from the refresh endpoint) still trigger AuthException and call onAuthFailure.
Token storage failures: Errors raised by your TokenProvider (corrupted keychain, missing entitlements, ...) surface as TokenProviderException โ see Error Handling.
๐ 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
respectRetryAfter: true, // Honor Retry-After header (default)
),
);
// Disable retry for a specific request
final response = await client.get<Map<String, dynamic>>(
'/critical-endpoint',
options: Options(extra: {noRetryKey: true}),
);
Retry-After header (RFC 7231 ยง7.1.3): when respectRetryAfter is true (default), responses carrying a Retry-After header โ typically on 429 Too Many Requests or 503 Service Unavailable โ are honored. Both delta-seconds ("60") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT") formats are parsed. The resolved delay is capped at maxDelayMs. Falls back to exponential backoff if the header is absent or malformed.
๐พ 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):
| Source | ApiX Exception |
|---|---|
connectionTimeout, sendTimeout, receiveTimeout |
TimeoutException |
connectionError |
ConnectionException |
| HTTP 401 | UnauthorizedException |
| HTTP 403 | ForbiddenException |
| HTTP 404 | NotFoundException |
| HTTP 4xx/5xx | HttpException |
*AndDecode / *AndParse parse failure |
ParsingException |
TokenProvider failure (keychain, custom impl) |
TokenProviderException |
Wrong Content-Type (with strictContentType: true) |
UnexpectedContentTypeException |
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)
โโโ ParsingException (decode / parse failure)
โโโ TokenProviderException (TokenProvider failure)
โโโ UnexpectedContentTypeException (strictContentType only)
AuthException exposes originalError so the underlying cause (e.g. TokenProviderException or a custom error from a legacy onRefresh) is recoverable.
Classic Try-catch #
ApiClient methods throw typed ApiException directly โ no need to unwrap DioException:
try {
final response = await client.get<Map<String, dynamic>>('/users');
} on NotFoundException catch (e) {
print('User not found: ${e.message}');
} on UnauthorizedException catch (e) {
print('Please login again');
} on NetworkException catch (e) {
print('Check your connection: ${e.message}');
} on ApiException catch (e) {
print('API error: ${e.message}');
}
Result Pattern (Functional) #
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}',
);
Validating 2xx Responses (Legacy APIs) #
Some APIs signal business errors via HTTP 200 with a payload like
{ "success": false, "error": "..." }. The responseValidator hook lets you
turn that into a typed ApiException so it flows through the same try/catch
as HTTP errors:
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
responseValidator: (response) {
final body = response.data;
if (body is Map && body['success'] == false) {
return ApiException(
message: body['message'] as String? ?? 'Unknown error',
statusCode: response.statusCode,
);
}
return null; // pass through
},
);
The validator only fires on 2xx responses. Returning null lets the response
through unchanged. Returning any ApiException subclass (including custom
ones) preserves the exact type for on YourException catch.
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 |
Strict Content-Type Checks (Captive Portals) #
*AndDecode methods can verify that the response's Content-Type starts
with application/json before attempting to parse. Useful in fintech /
mobile contexts where a captive Wi-Fi portal may return HTML 200 in place
of the expected JSON:
final client = ApiClientFactory.create(
baseUrl: 'https://api.example.com',
strictContentType: true, // opt-in (default: false)
);
try {
final user = await client.getAndDecode('/me', User.fromJson);
} on UnexpectedContentTypeException catch (e) {
// e.expectedContentType, e.actualContentType available
// Likely a captive portal โ surface a "check your network" UI
}
*AndParse methods are unaffected (they accept any payload type by design).
A missing Content-Type header in strict mode triggers the same exception
with actualContentType: null.
API Reference #
ApiClientFactory.create #
| Parameter | Type | Description |
|---|---|---|
baseUrl |
String |
Base URL (required) |
connectTimeout |
Duration |
Connection timeout (30s) |
receiveTimeout |
Duration |
Receive timeout (30s) |
sendTimeout |
Duration |
Send timeout (30s) |
defaultContentType |
String? |
Default Content-Type (application/json) |
headers |
Map<String, dynamic>? |
Default headers |
dataKey |
String |
Envelope key for *Data methods ('data') |
strictContentType |
bool |
Enforce application/json on *AndDecode (false) |
responseValidator |
ResponseValidator? |
Hook to validate 2xx responses |
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 |
httpClientAdapter |
HttpClientAdapter? |
Custom Dio adapter |
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 |
strictContentType |
bool |
false |
Throw UnexpectedContentTypeException when *AndDecode receives a non-JSON response |
responseValidator |
ResponseValidator? |
null | Inspect 2xx responses; return an ApiException to fail the request |
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:
๐ apix_example_app
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