apix 2.1.0 copy "apix: ^2.1.0" to clipboard
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 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: ^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

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

2
likes
160
points
71
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Production-ready Flutter/Dart API client with auth refresh queue, exponential retry, smart caching and error tracking (Sentry-ready). Powered by Dio.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

crypto, dio, flutter, flutter_secure_storage, sentry_flutter

More

Packages that depend on apix