Apix Logo

ApiX

pub package CI coverage License: MIT

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

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:

👉 apix_example_app

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.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'feat: add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. 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

Libraries

apix