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: ^4.1.0
flutter pub get

Full Configuration

ApiX supports declarative configuration with 8 optional config blocks:

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
  // Idempotent methods only by default β€” POST/PATCH are NOT replayed.
  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');
    },
  ),

  // πŸ”— Collapse identical concurrent requests β€” independent of the cache,
  // so you can have this without storing anything.
  deduplicationConfig: const DeduplicationConfig(),

  // ⏱️ One performance span per request, under the current Sentry transaction
  tracingConfig: const TracingConfig(),
);

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, each spread by jitter
    maxDelayMs: 30000, // Never wait more than 30s
    respectRetryAfter: true, // Honor Retry-After header (default)
    jitter: 0.2, // Β±20 % spread, ON by default β€” 0.0 disables it
    // Idempotent methods only (RFC 7231 Β§4.2.2) β€” POST/PATCH excluded (default)
    retryableMethods: {'GET', 'HEAD', 'OPTIONS', 'TRACE', 'PUT', 'DELETE'},
  ),
  // Observe every retry β€” route it to a breadcrumb and a storm stops being
  // invisible.
  onRetry: (attempt) => Sentry.addBreadcrumb(Breadcrumb(
    message: 'retry #${attempt.attempt} in ${attempt.delay.inMilliseconds}ms '
        '(status ${attempt.statusCode})',
    category: 'http',
  )),
);

// Disable retry for a specific request
final response = await client.get<Map<String, dynamic>>(
  '/critical-endpoint',
  options: Options(extra: {noRetryKey: true}),
);

// Force-retry a non-idempotent request that is safe to replay
// (e.g. a POST protected by an Idempotency-Key)
final topup = await client.post<Map<String, dynamic>>(
  '/wallet/topups',
  data: payload,
  options: Options(
    headers: {'Idempotency-Key': idempotencyKey},
    extra: {forceRetryKey: true},
  ),
);

Method-aware retry (idempotency): retry only replays requests whose method is in retryableMethods, which defaults to the idempotent methods per RFC 7231 Β§4.2.2 (GET, HEAD, OPTIONS, TRACE, PUT, DELETE). POST and PATCH are excluded by default β€” replaying them after a 5xx that the server may already have committed (e.g. a gateway 502/504) would duplicate the side effect (double charge). To retry a non-idempotent request that is provably safe to replay, opt in per request with forceRetryKey (or RequestOptions.forceRetry()); it overrides the method guard only.

Jitter (on by default): a purely deterministic backoff makes every client that failed during the same outage second retry at exactly the same instants, so a server coming back up meets a synchronised spike. jitter spreads each delay uniformly across Β±20 % of itself. Set jitter: 0.0 for the strictly deterministic sequence β€” and note that any test asserting an exact delay needs it. A server-named Retry-After is never jittered.

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 Can return stale?
cacheFirst Serve cache immediately, refresh in the background (stale-while-revalidate) Yes β€” flagged
networkFirst Network first, fall back to cache on failure Yes, on fallback β€” flagged
httpCacheAware Follow the server's Cache-Control / ETag No (304 is server-confirmed)
cacheOnly Cache only, never network β€” fails if missing or expired No
networkOnly Network only, never read cache No

networkFirst is the default: configure nothing and you get fresh data.

Knowing what you got

Any response served from the cache says so, and says whether it was past its TTL:

final response = await client.get<Map<String, dynamic>>('/orders');

if (response.isFromCache && response.isStale) {
  showBanner('Showing data from earlier β€” refreshing…');
}

isStale is true in the two places apix knowingly returns expired data: cacheFirst serving instantly while it revalidates, and the offline fallback of networkFirst / httpCacheAware. Both are useful; both are lies if the caller can't tell. On an amount, a balance or a status, surface it.

The TTL is a guarantee

defaultTtl is enforced by the interceptor, not by the storage backend β€” a custom CacheStorage cannot weaken it by forgetting to filter. Backends just store and return; deciding what to do with an expired entry is the strategy's job.

Persistent cache

InMemoryCacheStorage (the default) starts empty on every launch, so it does nothing for a cold start β€” which is exactly when the wait is most visible. FileCacheStorage survives restarts and pulls in no extra dependency: you hand it the directory.

final dir = await getTemporaryDirectory(); // path_provider, in your app
final client = ApiClientFactory.create(
  baseUrl: 'https://api.example.com',
  cacheConfig: CacheConfig(
    storage: FileCacheStorage(Directory('${dir.path}/apix_cache')),
    strategy: CacheStrategy.cacheFirst,
  ),
);

⚠️ Entries are stored in clear text. Never cache credentials, tokens, personal data or amounts you would not write to a log. Prefer a cache directory the OS may purge over a backed-up documents directory.


πŸ”— Deduplication without a cache

Deduplication collapses identical concurrent requests into one call. It says nothing about whether responses should be stored, so it no longer requires a cache:

final client = ApiClientFactory.create(
  baseUrl: 'https://api.example.com',
  deduplicationConfig: const DeduplicationConfig(),
  // no cacheConfig β€” nothing is ever written anywhere
);

Three widgets asking for the same profile at once produce one request. A later, sequential request still hits the network: this is about concurrency, not caching.

When both deduplicationConfig and cacheConfig are supplied, the cache's own deduplication is switched off so a request is not collapsed twice.

πŸ”’ Encrypted cache

FileCacheStorage writes in clear text. Where the data worth keeping between launches is also the data that must not leak, wrap it:

final storage = EncryptedCacheStorage(
  delegate: FileCacheStorage(directory: dir),
  encrypt: (plain) => myCipher.encrypt(plain),
  decrypt: (sealed) => myCipher.decrypt(sealed),
);

You supply the cipher, so apix carries neither a crypto dependency nor your key.

Body and headers are sealed. Cache keys are not β€” the whole invalidation API reads them (removeByPrefix, invalidateUrl), so an identifying path or query parameter stays readable on disk. Keep those out of the URL, or don't cache that endpoint. Status, timestamps and ETag stay readable too, so expiry can be checked without a key.

An entry that cannot be decrypted β€” rotated key, corrupted file β€” reads as a miss and is purged, rather than throwing on the request path.

⏱️ Performance spans

final client = ApiClientFactory.create(
  baseUrl: 'https://api.example.com',
  tracingConfig: const TracingConfig(),
);

Opens one span per request as a child of the current Sentry transaction, with method, host, path and final status. One span covers the whole logical request β€” retries and backoff included β€” because that is what the caller waited for. A response served from cache opens none: it spent no time on the network.

Requires an active transaction (tracesSampleRate > 0 in SentrySetup); without one, nothing is traced.

πŸ“¦ No dio import required

apix re-exports the dio types its own API hands back, so consumer code β€” and, more to the point, consumer tests β€” need not depend on package:dio directly. That matters beyond convenience: a direct import makes apix's declared dio version range a constraint on your code too.

import 'package:apix/apix.dart';

// Binary downloads (statements, receipts)
final pdf = await client.get<List<int>>(
  '/statements/2026-08.pdf',
  options: Options(responseType: ResponseType.bytes),
);

// A custom interceptor β€” `create(interceptors:)` takes a List<Interceptor>
class TenantInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    options.headers['X-Tenant'] = currentTenant;
    handler.next(options);
  }
}

// Uploads
final form = FormData.fromMap({'file': await MultipartFile.fromFile(path)});

Covered: Response, Options, CancelToken, ResponseType, RequestOptions, Interceptor and its three handlers, DioException, DioExceptionType, FormData, MultipartFile, Headers.

Testing without I/O

Adapter stubbing lives in a separate entry point, kept out of production autocomplete:

import 'package:apix/apix.dart';
import 'package:apix/testing.dart';

class FakeAdapter implements HttpClientAdapter {
  @override
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<List<int>>? requestStream,
    Future<dynamic>? cancelFuture,
  ) async =>
      ResponseBody.fromString(
        '{"code":"RATE_LIMITED","message":"Slow down"}',
        429,
        headers: {
          Headers.contentTypeHeader: ['application/json'],
          'retry-after': ['30'],
        },
      );

  @override
  void close({bool force = false}) {}
}

final client = ApiClientFactory.create(
  baseUrl: 'https://api.test',
  httpClientAdapter: FakeAdapter(),
);

πŸ›Ÿ Observability never breaks the request

Every callback you hand apix β€” logHandler, onMetrics, onBreadcrumb, onError, onRetry, startSpan β€” is a side channel. If yours throws, or fails asynchronously, the request carries on and its own outcome reaches you unchanged:

final client = ApiClientFactory.create(
  baseUrl: 'https://api.example.com',
  // Your log sink is down. The request still returns 200.
  loggerConfig: LoggerConfig(logHandler: (_) => throw StateError('sink down')),
);

Failures are swallowed and deliberately not reported anywhere: the only channel available for reporting is the one that just failed, and routing it back would risk a loop.

A request is also observed once, even when deduplication makes it travel the interceptor chain twice β€” so one cancelled request is one log line and one tracker event, not two.

πŸ“Š 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()),
);

Tuning Sentry options not exposed by apix β€” use configureOptions as an escape hatch:

await SentrySetup.init(
  options: SentrySetupOptions(
    dsn: 'https://xxx@xxx.ingest.sentry.io/xxx',
    environment: 'production',
    configureOptions: (sentryOptions) {
      sentryOptions.enableTombstone = true; // sentry_flutter >= 9.14
    },
  ),
  appRunner: () => runApp(const MyApp()),
);

The callback runs after every apix default, so it can override anything β€” including beforeSend. For composition that preserves apix's network-noise filter, prefer customBeforeSend / customBeforeSendTransaction.

⚠️ SentrySetupOptions.production() and .development() do not forward configureOptions. To use it, spell the options out with the full constructor as above β€” the factories are only shorthands for the sample-rate presets.

2. API client configuration:

final client = ApiClientFactory.create(
  baseUrl: 'https://api.example.com',
  errorTrackingConfig: ErrorTrackingConfig(
    onError: SentrySetup.captureException,
    onBreadcrumb: SentrySetup.addBreadcrumbFromMap,
  ),
);

What onError receives: the typed ApiException β€” ServerException, NotFoundException, ConnectionException, ... β€” not the underlying DioException. Trackers group issues by the exception's runtime type, so a single DioException for everything would file every 500, 404 and timeout under one issue. The original is still reachable through (exception as ApiException).originalError.

Network-noise filter: SentrySetupOptions.filterNetworkNoise (default true) drops transport-level noise before it reaches Sentry β€” SocketException, TLS handshake failures, and apix's own NetworkException subtypes (TimeoutException, ConnectionException). Every other ApiException β€” including ClientException and ServerException β€” is always reported: apix classifies its own errors by type hierarchy, never by type name, so a 5xx is never mistaken for a socket error that happens to share a class name.

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...)

3. Upload debug symbols β€” or your release stack traces are unreadable.

sentry_flutter reports the error; it does not upload the symbols needed to make sense of it. Without this step everything looks fine β€” events arrive, nothing fails β€” but a release stack trace reads:

QKa: Provider<Gx> not found for Ez

instead of ProfileScreen: Provider<ProfileBloc> not found for _ProfileScreenView. Debug builds are unaffected, so the gap only shows up in production.

# pubspec.yaml
dev_dependencies:
  sentry_dart_plugin: ^3.0.0

sentry:
  upload_debug_symbols: true
  upload_source_maps: false
  project: your-project
  org: your-org

Credentials go in a gitignored sentry.properties at the project root β€” flat keys, not the defaults.* form the sentry-cli binary uses:

org=your-org
project=your-project
auth_token=sntrys_...

Then run it after every release build, or the symbols on Sentry drift out of step with the binary your users are running:

flutter build apk --release
dart run sentry_dart_plugin

The token needs the Project Read & Write and Release Admin scopes, plus Organization Read.


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 (other) ClientException
HTTP 5xx ServerException
Other status on the error path (3xx, unknown) 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)
β”‚   β”‚   └── TooManyRequestsException (429, carries `retryAfter`)
β”‚   β”œβ”€β”€ ServerException (5xx)
β”‚   └── HttpTrackingException (a captured status, see Sentry section)
β”œβ”€β”€ ParsingException (decode / parse failure)
β”œβ”€β”€ TokenProviderException (TokenProvider failure)
└── UnexpectedContentTypeException (strictContentType only)

Every 4xx maps to a ClientException and every 5xx to a ServerException, so branching on the category works whether or not the status has a dedicated subclass:

try {
  await client.get<Map<String, dynamic>>('/orders');
} on NotFoundException {
  // 404 β€” the specific subclass still wins
} on ClientException catch (e) {
  // any other 4xx: 400, 409, 422, 429... β€” e.statusCode tells you which
} on ServerException catch (e) {
  // any 5xx β€” retryable, worth reporting
}

Branching on the application error code

An HTTP status drifts. The same business case can move from 400 to 409 to 422 across server revisions without changing meaning, and a call site keyed on the status changes behaviour the day it does. When your backend publishes a stable code, branch on that instead:

try {
  await client.post<void>('/transfers', data: payload);
} on ApiException catch (e) {
  switch (e.code) {
    case 'OPERATION_NOT_RETRYABLE':
      showFinalFailure();
    case 'INSUFFICIENT_FUNDS':
      showTopUpPrompt();
    default:
      showGenericError();
  }
}

code is read from the response body β€” flat ({"code": ...}) or nested ({"error": {"code": ...}}) β€” under the key named by errorCodeKey (default 'code'). It is always a String, even when the server sends a number, so a switch never has to care which. It is null on non-HTTP failures, which have no body to read.

Keep status branching for the cross-cutting cases β€” 401 refresh, 403, a generic 5xx β€” or when no code is guaranteed.

Rate limits

on TooManyRequestsException catch (e) {
  final wait = e.retryAfter;
  showMessage(wait == null
      ? 'Trop de tentatives. RΓ©essayez plus tard.'
      : 'Trop de tentatives. RΓ©essayez dans ${wait.inSeconds} s.');
}

retryAfter is the parsed Retry-After header (delta-seconds or HTTP-date), or null when the server sent none β€” treat null as unknown delay, never as retry now.

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')
errorCodeKey String Body key holding the application error code ('code')
strictContentType bool Enforce application/json on *AndDecode (false)
responseValidator ResponseValidator? Hook to validate 2xx responses
authConfig AuthConfig? Auth configuration
retryConfig RetryConfig? Retry configuration
onRetry void Function(RetryAttempt)? Called before each retry waits
cacheConfig CacheConfig? Cache configuration
deduplicationConfig DeduplicationConfig? Standalone deduplication, no cache required
loggerConfig LoggerConfig? Logging configuration
errorTrackingConfig ErrorTrackingConfig? Error tracking configuration
metricsConfig MetricsConfig? Metrics configuration
tracingConfig TracingConfig? One performance span per request
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
errorCodeKey String 'code' Body key read into ApiException.code
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 β€” cache strategies, cache invalidation and mutations, with live request metrics in the status bar    ApiX Example App β€” method-aware retry probes, Sentry error triggers, and the fetched data

Features demonstrated:

  • πŸ” SecureTokenProvider with simplified refresh flow
  • πŸ’Ύ Cache strategies (CacheFirst, NetworkFirst, HttpCache) + invalidation API
  • πŸ”„ Retry: exponential backoff, Retry-After, and the method-aware guard β€” three live probes measure how many times the server is actually hit for GET, POST and POST + forceRetry()
  • πŸ›‘οΈ Typed failures: ParsingException, UnexpectedContentTypeException, responseValidator β†’ custom exception, TokenProviderException
  • πŸ“¦ Envelope API (*Data methods) against a mocked backend
  • πŸ› Sentry integration with error testing
  • πŸ“Š Request metrics and logging

A shorter, single-file example lives in example/example.dart β€” that is the one rendered on pub.dev. The linked repo is the full Flutter app.

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
testing
Test-only entry point for apix consumers.