flutter_api_client 1.3.1
flutter_api_client: ^1.3.1 copied to clipboard
Type-safe HTTP client for Flutter/Dart with retries, cache, dedup, refresh-queue auth, cancel tokens, and a spec-driven mock + docs + backend-guide generator.
flutter_api_client #
A type-safe, extensible HTTP client for Flutter/Dart. Ships with retries,
caching, request deduplication, concurrent-safe token refresh, an offline
write queue, structured logging, and a spec-driven endpoint system that
turns one ApiSpec into a working mock adapter, an OpenAPI 3.1 document,
a Markdown API reference, and a backend implementation guide.
Table of contents #
- Why this package
- Installation
- Quick start
- ApiClient configuration
- Making requests
- Response types
- Error handling
- Auth & token storage
- Interceptors
- Cancel tokens
- Multipart / file upload
- GraphQL
- Spec-driven endpoints
- Testing with MockAdapter
- Pluggable transport
- API reference
- Migration from 0.1.x
- Test suite
- License
Quick start — 3 steps #
1. Add the dependency
dependencies:
flutter_api_client: ^1.3.0
dev_dependencies:
build_runner: ^2.15.0
If you use the code generator, opt the builders into your project's
build.yaml (they no longer auto-apply to every consumer, so apps that only
use the runtime client don't pay the codegen/analyzer cost):
# build.yaml
targets:
$default:
builders:
flutter_api_client|api_spec_builder:
enabled: true
flutter_api_client|api_spec_test_builder:
enabled: true
2. Define your spec and generate files
// lib/my_api.dart
import 'package:flutter_api_client/flutter_api_client.dart';
part 'my_api.g.dart';
@ApiSpecEntry()
final myApi = ApiSpec(
title: 'My API',
version: '1.0.0',
baseUrl: 'https://api.example.com',
)..group('Users', (g) {
g.endpoint('GET /users', responses: [ResponseExample.ok({'users': []})]);
});
dart run build_runner build
# Generates:
# lib/my_api.g.dart — spec accessor
# lib/my_api.test.g.dart — runnable test scaffold
3. Generate docs and a full test suite
dart run flutter_api_client:gen
# Writes: docs/api/openapi.json, openapi.yaml, api-reference.md, backend-guide.md
dart run flutter_api_client:gen --only tests
# Writes: test/api_spec_test.dart (complete runnable tests for every endpoint)
flutter test --concurrency=8
Why this package #
flutter_api_client is a production-ready HTTP client that goes beyond basic REST calls. It provides enterprise-grade features out of the box, eliminating the need for multiple third-party packages.
Feature Comparison #
| Feature | dio |
http |
flutter_api_client 1.1 |
|---|---|---|---|
Typed get<T> / post<T> with decoder |
manual | no | ✅ built-in |
Sealed ApiResult<T> (Success / Failure) |
no | no | ✅ built-in |
Multi-request CancelToken |
yes | no | ✅ yes |
Exponential backoff + jitter + Retry-After |
plugin | no | ✅ built-in |
| Concurrent-safe 401 refresh queue | DIY | no | ✅ built-in |
Cache: networkFirst, cacheFirst, staleWhileRevalidate, cacheOnly |
plugin | no | ✅ built-in |
ETag / If-None-Match revalidation |
plugin | no | ✅ built-in |
| Request dedup / in-flight coalescing | DIY | no | ✅ built-in |
| Offline write queue (pluggable storage) | DIY | no | ✅ built-in |
| cURL + pretty logger with redaction | plugin | no | ✅ built-in |
Pluggable transport (HttpAdapter) |
yes | no | ✅ yes |
Built-in MockAdapter for tests |
plugin | no | ✅ built-in |
| Spec → mock + OpenAPI + docs + backend guide | no | no | ✅ yes |
| Upload / download progress callbacks | yes | no | ✅ yes |
| Per-request response mode (JSON / bytes / text / stream) | yes | no | ✅ yes |
| GraphQL query / mutation / APQ | no | no | ✅ built-in |
| Zero native dependencies | yes | yes | ✅ yes |
| Production stability | mature | mature | ✅ stable |
Key Advantages #
- All-in-One Solution: No need for
dio_smart_retry,dio_cache_interceptor,pretty_dio_logger,http_mock_adapter— everything is integrated - Spec-Driven Development: Write your API contract once, get tests, docs, and backend guides automatically
- Type Safety: Sealed
ApiResult<T>ensures exhaustive error handling at compile time - Zero Configuration for Common Patterns: Sensible defaults for caching, retries, and auth refresh
- Testing First: Built-in mocking without external dependencies
- GraphQL Support: First-class query/mutation support with automatic persisted queries (APQ)
Features at a Glance #
Core HTTP Client #
- Type-safe requests: Generic methods
get<T>,post<T>,put<T>,patch<T>,delete<T> - Sealed result type:
ApiResult<T>with exhaustiveSuccess/Failurematching - Response modes: JSON parsing, raw bytes, plain text, streaming
- Custom decoders: Transform responses directly to your model types
- Multipart uploads: File upload with progress tracking
- Query parameters: Automatic URL encoding with list and nested object support
Authentication & Security #
- Token storage: Pluggable
TokenStorageinterface with memory and cached implementations - Auto token refresh: Concurrent-safe 401 handling with refresh queue
- Header redaction: Sensitive data protection in logs
- Bearer token: Automatic
Authorizationheader injection - Per-request auth control: Disable auth for public endpoints
Resilience & Performance #
- Smart retries: Exponential backoff with jitter and
Retry-Afterheader support - HTTP caching: Four cache modes (
networkFirst,cacheFirst,staleWhileRevalidate,cacheOnly) - ETag validation: Automatic
If-None-Matchfor bandwidth optimization - Request deduplication: Collapse identical in-flight requests
- Offline queue: Persist failed writes for replay when online
- Cancel tokens: Cancel multiple related requests simultaneously
- Payload size guards: Optional request / response byte limits in the default adapter
Developer Experience #
- Logging interceptors: cURL commands and pretty-printed request/response logs
- Custom interceptors: Extensible request/response/error pipeline
- Type-safe errors: Seven typed exception classes for exhaustive error handling
- Progress callbacks: Track upload and download progress
- Timeout control: Per-request timeout overrides
Testing & Mocking #
- MockAdapter: Route-based mocking with request capture
- SpecMockAdapter: Schema-validating mock from API spec
- Built-in test suite: 405+ passing tests covering all features
- No external mock libs: Everything needed for testing included
Spec-Driven Development (Unique Feature) #
- API specification DSL: Define contracts with
ApiSpec - OpenAPI generator: Automatic OpenAPI 3.1 YAML/JSON output
- Documentation generator: Markdown API reference for developers
- Backend guide generator: Implementation guides for Express, FastAPI, Gin
- Test generator: Complete runnable test suites from specs
- GraphQL support: Query, mutation, subscription specs with SDL generation
GraphQL Client #
- Query & mutation: Full GraphQL-over-HTTP protocol support
- Error handling: Typed
GraphQLErrorwith location and path - Partial responses: Handle mixed data and errors
- Auto persisted queries (APQ): Bandwidth optimization with query hashing
- Variable validation: Schema-based variable checking in specs
- Framework snippets: backend skeletons for Express/Apollo, FastAPI/Strawberry, and Gin/gqlgen
Extensibility #
- Pluggable transport: Swap HTTP implementation (
cupertino_http,cronet_http) - Custom adapters: WebSocket, gRPC, or any custom transport
- Interceptor chain: Ordered execution with request/response/error hooks
- Custom cache stores: Implement persistent caching (SQLite, Hive, etc.)
- Response handlers: Custom error message extraction
Installation #
# pubspec.yaml
dependencies:
flutter_api_client: ^1.1.0
flutter pub get
Runtime dependencies: http ^1.6.0,
collection ^1.19.1,
hive ^2.2.3.
Permissions: this package does not add Android, iOS, macOS, or web permissions of its own. Outbound networking policy (for example Android internet access or iOS/macOS App Transport Security exceptions) remains an application-level concern.
Quick start #
import 'package:flutter_api_client/flutter_api_client.dart';
final client = ApiClient(
ApiClientConfig(
baseUrl: 'https://api.example.com/api/v1',
tokenStorage: MemoryTokenStorage(accessToken: 'my-jwt'),
interceptors: [
PrettyLogger(),
DedupInterceptor(),
CacheInterceptor(
store: MemoryCacheStore(),
defaultPolicy: CachePolicy.staleWhileRevalidate(Duration(minutes: 5)),
),
RetryInterceptor(
policy: RetryPolicy.exponential(maxAttempts: 3),
),
],
),
);
// Simple typed GET
final res = await client.get<Map<String, dynamic>>('users/me');
if (res.isSuccess) {
print(res.data); // Map<String, dynamic>
} else {
print(res.errorMessage); // human-readable string
}
// Typed result pattern (recommended for new code)
final result = await client.get<User>(
'users/me',
decoder: (json) => User.fromJson(json as Map<String, dynamic>),
);
result.when(
success: (user) => print('Hello ${user.name}'),
failure: (err) => print('Error: ${err.message}'),
);
ApiClient configuration #
ApiClientConfig #
| Parameter | Type | Default | Description |
|---|---|---|---|
baseUrl |
String |
required | Base URL prepended to every endpoint |
tokenStorage |
TokenStorage? |
— | Persistent token source (preferred) |
getAccessToken |
Future<String?> Function()? |
— | Callback token source (alternative to tokenStorage) |
refreshToken |
Future<bool> Function()? |
— | Called once on 401 before retry |
extraHeaders |
Map<String, String> |
{} |
Headers added to every request |
connectTimeout |
Duration |
30 s | Default request timeout |
authScheme |
String |
'Bearer' |
Prefix for Authorization; use '' for a bare token |
maxRequestBodyBytes |
int? |
null |
Optional encoded request body limit for DefaultHttpAdapter |
maxResponseBodyBytes |
int? |
null |
Optional buffered response body limit for DefaultHttpAdapter |
responseHandler |
ResponseHandlerInterface? |
built-in | Custom error-message extractor |
interceptors |
List<Interceptor> |
[] |
Applied in declaration order |
adapter |
HttpAdapter? |
DefaultHttpAdapter() |
Swap transport for tests or custom HTTP stacks |
defaultHeaders |
Map<String, String>? |
null |
Replaces the entire built-in default-header block when set |
defaultAccept |
String |
'application/json' |
Default Accept header |
defaultAcceptLanguage |
String |
'en' |
Default Accept-Language (locale) |
defaultContentType |
String |
'application/json' |
Default Content-Type for non-multipart requests |
authHeaderName |
String |
'Authorization' |
Header the auth token is written to |
bodySerializer |
RequestBodySerializer |
JSON | Encodes request bodies before transport |
responseJsonCodec |
ResponseJsonCodec |
jsonDecode |
Parses JSON response bodies |
charset |
Charset |
UTF-8 | Charset for encoding string bodies / decoding responses |
queryEncoder |
QueryEncoder |
repeated-key | Serializes URL query parameters |
isSuccessStatus |
bool Function(int) |
2xx |
Decides which status codes are Success |
Convenience constructors:
// Token via callback
ApiClientConfig.withToken(
baseUrl: 'https://api.example.com',
getAccessToken: () async => await SecureStorage.read('token'),
refreshToken: () async => await authService.refresh(),
);
// Token via TokenStorage implementation
ApiClientConfig.withStorage(
baseUrl: 'https://api.example.com',
tokenStorage: CachedTokenStorage(MySecureStorage()),
refreshToken: () async => await authService.refresh(),
);
// Swap transport in tests (no auth wired)
ApiClientConfig.test(
baseUrl: 'https://api.example.com',
adapter: MockAdapter(),
);
// Optional payload size limits (null = unlimited)
ApiClientConfig(
baseUrl: 'https://api.example.com',
maxRequestBodyBytes: 512 * 1024,
maxResponseBodyBytes: 10 * 1024 * 1024,
);
Deep customization #
Every default is overridable, and every override is optional — leaving a knob unset preserves the exact pre-1.3.0 behaviour.
Default headers & locale
ApiClientConfig(
baseUrl: 'https://api.example.com',
defaultAcceptLanguage: 'fr-CA', // locale
defaultAccept: 'application/vnd.api+json',
defaultContentType: 'application/json; charset=utf-8',
);
// …or replace the built-in Accept / Accept-Language / Content-Type block wholesale:
ApiClientConfig(
baseUrl: 'https://api.example.com',
defaultHeaders: {'Accept': 'application/cbor', 'X-App': 'myapp'},
);
Per-request headers / extraHeaders still override these case-insensitively.
Pluggable serialization — implement RequestBodySerializer,
ResponseJsonCodec, and/or Charset to send/parse a non-JSON or non-UTF-8
payload. Charset.decode and ResponseJsonCodec.decode must throw a
FormatException on bad input so the client can surface a typed ParseError.
class FormUrlEncodedSerializer implements RequestBodySerializer {
const FormUrlEncodedSerializer();
@override
Object? encode(Object? data) => data is Map
? data.entries
.map((e) => '${Uri.encodeQueryComponent('${e.key}')}='
'${Uri.encodeQueryComponent('${e.value}')}')
.join('&')
: data?.toString();
}
ApiClientConfig(
baseUrl: 'https://api.example.com',
bodySerializer: const FormUrlEncodedSerializer(),
);
Query-string encoding — control how lists, nested maps, and nulls render:
ApiClientConfig(
baseUrl: 'https://api.example.com',
queryEncoder: const QueryEncoder(
listFormat: QueryListFormat.comma, // ids=1,2,3 (also: repeated | brackets)
nested: QueryNestedStyle.dotted, // filter.name=foo (or: brackets)
includeNulls: true, // emit q= instead of dropping nulls
),
);
Retry backoff / auth header / success boundary
// A custom backoff curve (still capped at maxDelay, jittered when enabled):
RetryInterceptor(
policy: RetryPolicy(backoff: (attempt) => Duration(seconds: attempt * attempt)),
);
// A non-standard auth header, and a custom success rule (treat 3xx as success):
ApiClientConfig(
baseUrl: 'https://api.example.com',
authHeaderName: 'X-Auth-Token',
isSuccessStatus: (code) => code >= 200 && code < 400,
);
Note: if you rename the auth header, the bundled loggers won't redact it by default (they key on
authorization). Add your header name to the logger'sredactHeadersset so the token isn't logged in the clear.
Through the convenience factories — withToken / withStorage / test
accept a single ClientCustomization bundle so you don't need the full
constructor:
ApiClientConfig.withToken(
baseUrl: 'https://api.example.com',
getAccessToken: () async => await SecureStorage.read('token'),
customization: const ClientCustomization(
defaultAcceptLanguage: 'de-DE',
authHeaderName: 'X-Auth-Token',
),
);
Making requests #
Every verb method accepts an optional RequestOptions to override
config-level settings for a single call.
// GET
final res = await client.get<List<dynamic>>('posts');
// POST with body
final res = await client.post<Map<String, dynamic>>(
'posts',
{'title': 'Hello', 'body': 'World'},
);
// PUT / PATCH / DELETE
await client.put('posts/1', {'title': 'Updated'});
await client.patch('posts/1', {'title': 'Patched'});
await client.delete('posts/1');
// QUERY — safe, idempotent, cacheable read that carries a request body, so
// complex queries live in the body instead of the URL. Standardized in
// RFC 10008: https://www.rfc-editor.org/rfc/rfc10008.html
final res = await client.query<List<dynamic>>(
'posts/search',
{'filter': {'author': 'ada'}, 'sort': 'created_at'},
);
// Per-request options
final res = await client.get<dynamic>(
'feed',
options: RequestOptions(
queryParameters: {'page': 2, 'limit': 20},
timeout: Duration(seconds: 10),
baseUrlOverride: 'https://cdn.example.com',
includeToken: false,
extraHeaders: {'X-Client-Version': '3.0'},
responseType: ResponseType.json,
cancelToken: myToken,
cachePolicy: CachePolicy.networkFirst(),
retryPolicy: RetryPolicy(maxAttempts: 1),
maxRequestBodyBytes: 64 * 1024,
maxResponseBodyBytes: 512 * 1024,
onReceiveProgress: (received, total) => print('$received/$total'),
),
);
Payload size limits #
DefaultHttpAdapter can enforce optional encoded request and buffered response
body limits. Both limits default to null, which preserves the current
unlimited behavior.
If a configured limit is exceeded, the request fails with
PayloadTooLargeError.
final client = ApiClient(
ApiClientConfig(
baseUrl: 'https://api.example.com',
maxRequestBodyBytes: 512 * 1024,
maxResponseBodyBytes: 10 * 1024 * 1024,
),
);
final result = await client.get<dynamic>(
'feed',
options: const RequestOptions(maxResponseBodyBytes: 256 * 1024),
);
Custom adapters may ignore these limits unless they implement the same policy.
Decoder #
A decoder maps raw JSON directly to your model type:
final res = await client.get<User>(
'users/me',
decoder: (json) => User.fromJson(json as Map<String, dynamic>),
);
Response types #
ApiResult<T> — returned by all verb methods #
All HTTP verb methods (get, post, put, patch, delete) return the same
sealed result type:
final result = await client.get<User>(
'users/me',
decoder: (json) => User.fromJson(json as Map<String, dynamic>),
);
result.when(
success: (user) => print(user.name),
failure: (err) => print(err.message),
);
// Convenience helpers
result.isSuccess // bool
result.isFailure // bool
result.data // T?
result.error // ApiException?
result.errorMessage // String?
result.statusCode // int?
result.headers // Map<String, String>
// Transform the success value without unwrapping
final nameResult = result.map((user) => user.name); // ApiResult<String>
ParseError is used when a successful ResponseType.json response contains
malformed JSON or an obvious text / HTML payload. Empty successful JSON bodies
decode to null when the requested T can represent null.
Response body modes (ResponseType) #
| Mode | Returns | Use for |
|---|---|---|
json (default) |
parsed dynamic / decoded T |
JSON APIs |
bytes |
Uint8List |
images, binary downloads |
plainText |
String (UTF-8, no JSON parse) |
plain-text health endpoints |
stream |
Uint8List (buffered) |
advanced use |
// Raw bytes
final res = await client.get<Uint8List>('photo.png',
options: RequestOptions(responseType: ResponseType.bytes));
// Plain text
final res = await client.get<String>('health',
options: RequestOptions(responseType: ResponseType.plainText));
Error handling #
All exceptions from ApiClient derive from the sealed ApiException:
| Subtype | When raised |
|---|---|
NetworkError |
DNS, socket, or connectivity failure |
TimeoutError |
Request exceeded its timeout |
CancelError |
Cancelled via CancelToken.cancel() |
PayloadTooLargeError |
Configured request or response byte limit was exceeded |
HttpError |
Non-2xx response (carries statusCode, body, headers) |
ParseError |
Response body could not be decoded |
UnknownError |
Any other unexpected exception |
Failure carries a typed ApiException, so exhaustive matching is possible:
final result = await client.get<dynamic>('users/1');
if (result case Failure(:final error)) {
switch (error) {
case NetworkError(:final message):
showOfflineBanner(message);
case HttpError(:final statusCode) when statusCode == 401:
navigateToLogin();
case TimeoutError():
showRetryDialog();
case ParseError():
showUnexpectedPayloadDialog();
default:
logError(error);
}
}
Auth & token storage #
TokenStorage interface #
Implement this to integrate any persistence layer:
class SecureTokenStorage implements TokenStorage {
final _storage = const FlutterSecureStorage();
@override
Future<String?> getAccessToken() =>
_storage.read(key: 'access_token');
@override
Future<void> setAccessToken(String? token) =>
_storage.write(key: 'access_token', value: token);
@override
Future<String?> getRefreshToken() =>
_storage.read(key: 'refresh_token');
@override
Future<void> setRefreshToken(String? token) =>
_storage.write(key: 'refresh_token', value: token);
}
TokenStorage.clear() is provided by default and calls both setters with null.
MemoryTokenStorage #
Volatile in-memory storage — good for tests and simple apps:
MemoryTokenStorage(accessToken: 'jwt', refreshToken: 'refresh-jwt')
CachedTokenStorage #
Wraps any TokenStorage with an in-memory layer so getAccessToken()
is synchronously fast after the first read. Writes update the cache
immediately and persist to the delegate in the background:
final storage = CachedTokenStorage(SecureTokenStorage());
// Force the next read to hit the delegate again (e.g., after logout elsewhere)
storage.clearCache();
// Update cache + delegate immediately
storage.updateAccessToken('new-jwt');
storage.updateRefreshToken('new-refresh-jwt');
Concurrent-safe 401 refresh #
When multiple requests simultaneously receive 401, exactly one
refreshToken call is made. All other requests wait on the same future
and replay with the new token automatically:
ApiClientConfig(
baseUrl: 'https://api.example.com',
tokenStorage: CachedTokenStorage(SecureTokenStorage()),
refreshToken: () async {
final newToken = await authService.refresh(); // called exactly once
return newToken != null;
},
)
Interceptors #
Pass interceptors to ApiClientConfig.interceptors. They run in
list order on request and reverse list order on response/error.
Recommended ordering: place
DedupInterceptorbeforeRetryInterceptorin the list (e.g.[DedupInterceptor(), RetryInterceptor(), CacheInterceptor(...)]— auth is prepended automatically). Either order is correct, but with dedup ahead of retry the dedup window spans the whole retry sequence, so followers coalesce onto the retried result (fewer network hits). With retry ahead of dedup, followers are released on the pre-retry failure and re-issue independently (still correct, just more network hits).
Each interceptor overrides one or more of:
Future<InterceptorResult> onRequest(InterceptedRequest req) → ProceedResult / ResolveResult / RejectResult
Future<InterceptorResult> onResponse(InterceptedRequest, AdapterResponse) → same
Future<InterceptorResult> onError(InterceptedRequest, ApiException) → same
RetryInterceptor #
Retries retryable transport failures and retryable status responses with
exponential backoff, optional jitter, and Retry-After header support.
RetryInterceptor(
policy: RetryPolicy(
maxAttempts: 3, // total attempts (initial + retries)
baseDelay: Duration(milliseconds: 200),
maxDelay: Duration(seconds: 30),
retryOnStatus: {408, 429, 500, 502, 503, 504},
safeMethods: {'GET', 'HEAD', 'OPTIONS'},
useJitter: true, // randomises delay ±30%
respectRetryAfter: true, // honours Retry-After response header
),
)
// Convenience factory
RetryInterceptor(policy: RetryPolicy.exponential(maxAttempts: 4))
Triggers a retry:
- Status codes in
retryOnStatuson methods insafeMethods NetworkErrororTimeoutErroron methods insafeMethods
Does not trigger a retry:
- Non-safe methods unless you explicitly include them in
safeMethods - 4xx errors other than 408 / 429
HttpErrorwith any other codeCancelError
Per-request override:
await client.get('resource',
options: RequestOptions(retryPolicy: RetryPolicy(maxAttempts: 1)));
CacheInterceptor #
Caches GET responses in a CacheStore. MemoryCacheStore is a bounded
LRU cache (default 256 entries). For on-disk persistence implement the
CacheStore interface backed by Hive, SQLite, etc.
Cache modes:
| Mode | Behaviour |
|---|---|
cacheFirst |
Serve from cache if fresh. Fall back to network; update cache. |
networkFirst |
Always hit network first; update cache on 2xx. Return stale cache only if network fails. |
staleWhileRevalidate |
If a cached entry exists and TTL is still valid, return it immediately. Once the TTL has expired, the interceptor performs a conditional network revalidation before serving the new response. |
cacheOnly |
Only serve from cache. Returns status 504 on a cache miss. |
CacheInterceptor(
store: MemoryCacheStore(maxEntries: 500),
defaultPolicy: CachePolicy.cacheFirst(ttl: Duration(minutes: 10)),
)
ETag / If-None-Match: enabled by default. On subsequent requests the
interceptor adds If-None-Match with the stored ETag. A 304 response is
transparently converted to the cached entry.
Per-request override:
// Force fresh data for one call
await client.get('live-price',
options: RequestOptions(cachePolicy: CachePolicy.networkFirst()));
// Offline read-only
await client.get('config',
options: RequestOptions(cachePolicy: CachePolicy.cacheOnly()));
Custom persistent store:
class HiveCacheStore implements CacheStore {
@override Future<CacheEntry?> read(String key) async { ... }
@override Future<void> write(CacheEntry entry) async { ... }
@override Future<void> delete(String key) async { ... }
@override Future<void> clear() async { ... }
}
DedupInterceptor #
Coalesces identical in-flight requests so the network is only hit once.
All callers that issued the same method + URL while the first request was
in-flight receive the identical AdapterResponse object.
DedupInterceptor() // GET + HEAD (default)
DedupInterceptor(methods: {'GET', 'HEAD', 'OPTIONS'}) // custom set
Useful when multiple widgets independently trigger the same endpoint on the same frame (e.g., a user avatar fetched by both the app bar and a profile card).
AuthInterceptor #
Attaches the Authorization header and handles 401 token refresh.
Normally added automatically when you set tokenStorage or
getAccessToken on the config. Add it manually only if you need a custom
retryStatusCodes set:
AuthInterceptor(
storage: CachedTokenStorage(SecureTokenStorage()),
refresh: () async => await authService.refresh(),
authScheme: 'Bearer', // default; '' for bare token
retryStatusCodes: {401}, // default
)
includeToken: false: no Authorization header is attached, and 401
retry is skipped. Use for public endpoints such as login.
Empty/null token: no Authorization header is added — no crash.
OfflineQueueInterceptor #
Captures mutating requests that fail with NetworkError or TimeoutError
while the device is offline and stores them for later replay.
final box = await Hive.openBox<String>('offline_queue');
OfflineQueueInterceptor(
store: HiveOfflineQueueStore(box),
isOnline: () async => (await Connectivity().checkConnectivity())
!= ConnectivityResult.none,
methods: {'POST', 'PUT', 'PATCH', 'DELETE'}, // default
)
Replaying the queue when connectivity returns — use the built-in
OfflineQueueReplayer:
final box = await Hive.openBox<String>('offline_queue');
final store = HiveOfflineQueueStore(box);
// ...when online (e.g. from a connectivity listener):
final report = await OfflineQueueReplayer(
store: store,
client: client,
maxAttempts: 3, // dead-letter a request after this many failed replays
).replay();
print(report); // succeeded / reEnqueued / deadLettered counts
The replayer drains the queue in createdAt order and re-issues each request
through client, so a fresh Authorization token is attached automatically.
A request that fails transiently (network/timeout) is re-enqueued with an
incremented attempt count; once it reaches maxAttempts it is dead-lettered
instead of looping forever. A request the server actually rejects (a non
network/timeout error) is dropped.
Queued requests retain ordinary request headers, but the interceptor drops the
Authorization header before persisting so replay uses the current token
instead of a stale one.
Never enqueued:
- GET / HEAD (idempotent reads)
HttpError(4xx/5xx — server-side, not a connectivity issue)- Requests when
isOnline()returnstrue
HiveOfflineQueueStore is the built-in persistent option. It stores each
queued request as a JSON string in a user-supplied Hive box. Queue bodies
must remain JSON-serialisable to persist cleanly.
CurlLogger #
Logs every request as a ready-to-paste curl command. Sensitive headers and
common credential keys in JSON request bodies are redacted by default.
CurlLogger(
printer: (s) => debugPrint(s),
redactHeaders: const {'authorization', 'x-api-key'},
)
Example output:
curl -X POST -H 'Content-Type: application/json' \
-H 'Authorization: <redacted>' \
--data '{"email":"a@b.com","password":"<redacted>"}' \
'https://api.example.com/api/v1/auth/login'
PrettyLogger #
Structured, colour-coded request/response logger. Sensitive response headers
are redacted, and response bodies are capped at 2 000 characters (truncated
with …).
PrettyLogger(
printer: (s) => debugPrint(s),
redactHeaders: const {'authorization', 'cookie', 'set-cookie'},
requestBody: true,
responseBody: true,
useColors: true, // set false in CI / log files
)
Example output:
┌─── POST auth/login ───
│ Content-Type: application/json
│ Authorization: <redacted>
│ body: {"email":"a@b.com","password":"<redacted>"}
└───────────────
┌─── 200 POST auth/login ───
│ content-type: application/json
│ set-cookie: <redacted>
│ body: {"token":"<redacted>","user":{"id":1}}
└───────────────
Cancel tokens #
A single CancelToken can be bound to multiple requests. cancel() causes
every associated request to complete with CancelError without disturbing
unrelated ones. With the default DefaultHttpAdapter, request-owned
package:http clients are also closed on cancel; shared injected clients are
not.
final token = CancelToken();
// Bind to multiple requests
final [feed, comments] = await Future.wait([
client.get('feed', options: RequestOptions(cancelToken: token)),
client.get('comments', options: RequestOptions(cancelToken: token)),
]);
// Cancel both when the user navigates away
token.cancel('screen closed');
// State inspection
token.isCancelled // true
token.error // CancelError('screen closed')
token.whenCancelled // Future<CancelError>
// Guard code that runs after async gaps
token.throwIfCancelled(); // throws CancelError if already cancelled
// Listener (returns unregister function)
final unregister = token.addListener((err) {
print('Cancelled: ${err.message}');
});
unregister(); // stop listening before cancel fires
// A listener registered after cancel fires immediately
final afterToken = CancelToken()..cancel('already done');
afterToken.addListener((err) => print(err.message)); // fires synchronously
Multipart / file upload #
import 'package:http/http.dart' as http;
final avatar = await http.MultipartFile.fromPath('avatar', '/path/to/photo.jpg');
final res = await client.post<Map<String, dynamic>>(
'users/me/avatar',
FormData.fromMap({
'avatar': avatar,
'description': 'Profile picture',
'tags': ['selfie', 'profile'], // → repeated fields tags[]
}),
isMultipart: true,
options: RequestOptions(
onSendProgress: (sent, total) =>
print('${(sent / total! * 100).round()}%'),
),
);
FormData.fromMap type handling:
| Value type | Result |
|---|---|
String / num / bool |
Form field (toString'd) |
http.MultipartFile |
File part |
List<http.MultipartFile> |
Multiple file parts |
List<T> |
Repeated fields as key[] |
null |
Skipped |
GraphQL #
GraphQLClient wraps an ApiClient and implements the standard
GraphQL-over-HTTP protocol ({query, variables, operationName} →
{data, errors, extensions}).
final gql = GraphQLClient(client, endpoint: '/graphql');
Query #
final res = await gql.query<User>(
r'query Me { me { id name email } }',
decoder: (data) => User.fromJson((data as Map)['me']),
);
if (res.isSuccess) {
print(res.data!.name);
} else if (res.hasErrors) {
for (final e in res.errors) {
// e.message, e.locations, e.path, e.extensions
print('[${e.extensions?["code"]}] ${e.message}');
}
}
Mutation #
final res = await gql.mutation<Map<String, dynamic>>(
r'''
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) { token }
}
''',
variables: {'email': 'a@b.com', 'password': 'secret123'},
includeToken: false,
);
GraphQLResponse<T> fields #
| Field | Type | Description |
|---|---|---|
data |
T? |
Decoded data field |
errors |
List<GraphQLError> |
Empty on full success |
isSuccess |
bool |
True only when errors is empty and no transport error |
isPartial |
bool |
True when both data and errors are present |
hasErrors |
bool |
Shorthand for errors.isNotEmpty |
statusCode |
int |
Underlying HTTP status |
networkError |
ApiException? |
Set when transport failed entirely |
extensions |
Map<String, Object?>? |
Server metadata block |
Automatic Persisted Queries (APQ) #
final gql = GraphQLClient(
client,
usePersistedQueries: true,
);
On first send the client transmits only the hash. On PersistedQueryNotFound
it automatically falls back to the full document. The default hash is the
SHA-256 hex digest the APQ protocol expects, so it matches a server-registered
document out of the box — override hashQuery only for a custom registry.
All ApiClient interceptors apply to GraphQL calls transparently because
every operation routes through the standard HTTP layer.
Spec-driven endpoints #
ApiSpec is the single source of truth for your API contract. One spec drives:
- A
SpecMockAdapter— full integration tests with no real server - OpenAPI 3.1 YAML — for tooling and codegen
- Markdown API reference — for frontend developers
- A backend implementation guide — for your server team
Defining a spec #
final spec = ApiSpec(
title: 'My App API',
version: '1.0.0',
baseUrl: 'https://api.example.com/api/v1',
);
spec.group('Auth', (g) {
g.endpoint(
'POST /auth/login',
summary: 'Authenticate with email + password',
auth: false,
request: RequestExample(
body: const {'email': 'a@b.com', 'password': 'secret123'},
schema: Schema.object({
'email': Schema.string(format: 'email', required: true),
'password': Schema.string(minLength: 8, required: true),
}),
),
responses: [
ResponseExample.ok(const {'token': 'jwt', 'user': {'id': 1}}),
ResponseExample.error(401, const {'message': 'Invalid credentials'}),
],
);
});
spec.group('Users', (g) {
g.endpoint(
'GET /users/{id}',
summary: 'Fetch a user by ID',
pathParams: {'id': Schema.integer(required: true)},
response: ResponseExample.ok(const {'id': 1, 'name': 'Alice'}),
);
});
GraphQL in the same spec #
spec.graphql((g) {
g.query(
'Me',
document: r'query Me { me { id name } }',
responseExample: const {'me': {'id': 1, 'name': 'Alice'}},
);
g.mutation(
'Login',
document: r'''
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) { token }
}
''',
variables: {
'email': Schema.string(required: true, format: 'email'),
'password': Schema.string(required: true, minLength: 8),
},
responseExample: const {'login': {'token': 'jwt'}},
errors: const [
GraphQLErrorExample(
message: 'Invalid credentials',
extensions: {'code': 'UNAUTHENTICATED'},
),
],
);
});
Using the spec as a mock in tests #
SpecMockAdapter validates request bodies against their declared Schema
and returns the declared response example. No real server required.
final client = ApiClient(
ApiClientConfig.test(
baseUrl: spec.baseUrl,
adapter: SpecMockAdapter(spec),
),
);
// Valid request → 200 with declared response body
final ok = await client.post('auth/login',
const {'email': 'a@b.com', 'password': 'secret123'},
includeToken: false);
expect(ok.isSuccess, true);
expect(ok.data, contains('token'));
// Invalid body (password too short) → 422 Unprocessable Entity
final bad = await client.post('auth/login',
const {'email': 'a@b.com', 'password': 'x'},
includeToken: false);
expect(bad.statusCode, 422);
// Force a specific error in one test
final forcedError = ApiClient(ApiClientConfig.test(
baseUrl: spec.baseUrl,
adapter: SpecMockAdapter(spec,
statusOverrides: {'POST /auth/login': 401}),
));
// Force a GraphQL error
final gqlError = ApiClient(ApiClientConfig.test(
baseUrl: spec.baseUrl,
adapter: SpecMockAdapter(spec,
statusOverrides: {'GQL Login': 401}),
));
Generating OpenAPI, docs, and backend guide #
import 'dart:io';
import 'package:flutter_api_client/flutter_api_client.dart';
void main() {
final spec = buildSpec();
Directory('doc').createSync(recursive: true);
File('doc/openapi.yaml')
.writeAsStringSync(OpenApiGenerator(spec).toYaml());
File('doc/API_DOCS.md')
.writeAsStringSync(MarkdownDocGenerator(spec).generate());
File('doc/BACKEND_GUIDE.md').writeAsStringSync(
BackendGuideGenerator(
spec,
framework: BackendFramework.express, // .express / .fastapi / .gin
).generate(),
);
}
The backend guide contains:
- Architecture diagram and route table
- Auth notes, standard error envelope, status-code matrix
- Per-endpoint validation rules, sample request/response bodies, handler skeletons
- GraphQL section: operation table, derived SDL with auto-generated types, resolver skeletons, framework snippets
- Acceptance checklist (REST + GraphQL) for your backend developers
Schema types #
Schema.string(format: 'email', required: true, minLength: 5, maxLength: 255)
Schema.integer(required: true, minimum: 1, maximum: 9999)
Schema.number(minimum: 0.0)
Schema.boolean()
Schema.array(Schema.string())
Schema.object({
'name': Schema.string(required: true),
'age': Schema.integer(),
})
Testing with MockAdapter #
MockAdapter records all received requests and returns canned responses.
final mock = MockAdapter();
// Static route
mock.on('GET', RegExp(r'/users/1$'),
statusCode: 200, body: {'id': 1, 'name': 'Alice'});
// Dynamic route — for stateful / conditional responses
var calls = 0;
mock.onRequest('GET', RegExp(r'/flaky$'), (req) async {
calls++;
if (calls < 3) throw const NetworkError('down');
return AdapterResponse(
statusCode: 200,
headers: const {},
bodyBytes: Uint8List.fromList('{"ok":true}'.codeUnits),
);
});
final client = ApiClient(
ApiClientConfig.test(
baseUrl: 'https://api.example.com',
adapter: mock,
),
);
// Assert captured requests
expect(mock.received, hasLength(1));
expect(mock.received.first.method, 'GET');
expect(mock.received.first.headers['Authorization'], isNotNull);
Returns 404 automatically for any unregistered route. Add a latency
parameter to simulate network delay:
MockAdapter(latency: Duration(milliseconds: 50))
Pluggable transport #
Swap the underlying HTTP implementation without touching any other code:
// Default: package:http
ApiClientConfig(baseUrl: '...')
// Custom client factory (e.g. cupertino_http, cronet_http)
ApiClientConfig(
baseUrl: '...',
adapter: DefaultHttpAdapter(
clientFactory: () => CupertinoClient.defaultSessionConfiguration(),
),
)
// Fully custom adapter (WebSocket bridge, gRPC, etc.)
class MyAdapter implements HttpAdapter {
@override
Future<AdapterResponse> send(AdapterRequest request) async { ... }
@override
void close() {}
}
AdapterRequest carries:
method, url, headers, body, formData, isMultipart, timeout,
cancelToken, onSendProgress, onReceiveProgress
AdapterResponse carries:
statusCode, headers, bodyBytes, reasonPhrase
API reference #
Core #
| Symbol | Description |
|---|---|
ApiClient |
Main HTTP client |
ApiClientConfig |
Configuration value object |
ApiClientInterface |
Abstract contract for get/post/put/patch/delete |
ApiResult<T> |
Sealed result returned by all HTTP verb methods |
Success<T> |
Successful ApiResult (carries data, statusCode, headers) |
Failure<T> |
Failed ApiResult (carries error, optional statusCode) |
ApiException |
Sealed base class for all errors |
NetworkError |
I/O / connectivity failure |
TimeoutError |
Request timed out |
CancelError |
Cancelled via CancelToken |
PayloadTooLargeError |
Configured payload byte limit exceeded |
HttpError |
Non-2xx HTTP status (carries statusCode, body, headers) |
ParseError |
Response body decode failure |
UnknownError |
Catch-all |
CancelToken |
Cancel one or more in-flight requests |
RequestOptions |
Per-request config overrides |
ResponseType |
json / bytes / plainText / stream |
FormData |
Multipart form data builder |
buildUri() |
Constructs Uri from base URL + endpoint + query params |
buildQueryString() |
Encodes a Map<String, dynamic> to a query string |
Auth #
| Symbol | Description |
|---|---|
TokenStorage |
Abstract interface (implement to plug in any backend) |
MemoryTokenStorage |
Volatile in-memory implementation |
CachedTokenStorage |
Memory-caching wrapper around any TokenStorage |
AuthInterceptor |
Attaches Bearer token, handles 401 + concurrent-safe refresh |
Interceptors #
| Symbol | Description |
|---|---|
Interceptor |
Base class (onRequest / onResponse / onError) |
InterceptorChain |
Executes the ordered list of interceptors |
RetryInterceptor |
Retry on configurable status codes and exception types |
RetryPolicy |
Controls max attempts, delays, jitter, Retry-After |
CacheInterceptor |
HTTP response cache with four cache modes |
CachePolicy |
Cache mode + TTL + ETag settings |
CacheStore |
Abstract cache backend interface |
MemoryCacheStore |
Bounded LRU in-memory cache |
CacheEntry |
Cached response (key, body, headers, ETag, timestamp) |
DedupInterceptor |
In-flight request deduplication |
OfflineQueueInterceptor |
Captures offline writes for later replay |
OfflineQueueStore |
Abstract queue backend interface |
InMemoryOfflineQueueStore |
Volatile in-memory queue |
HiveOfflineQueueStore |
Persistent Hive-backed queue store |
QueuedRequest |
Serialisable pending request (with toJson() / fromJson()) |
CurlLogger |
Logs requests as curl commands |
PrettyLogger |
Structured colour request/response logger |
GraphQL #
| Symbol | Description |
|---|---|
GraphQLClient |
GraphQL over HTTP (query / mutation / APQ) |
GraphQLResponse<T> |
Result with data, errors, extensions |
GraphQLError |
Single error from the errors array |
Spec #
| Symbol | Description |
|---|---|
ApiSpec |
API contract definition |
EndpointSpec |
Single REST endpoint (method, path, schema, examples) |
Schema |
JSON Schema-like type with runtime validation |
RequestExample |
Request body + schema |
ResponseExample |
Response body + status code |
SpecMockAdapter |
Drives ApiClient from an ApiSpec in tests |
OpenApiGenerator |
Generates OpenAPI 3.1 YAML |
MarkdownDocGenerator |
Generates Markdown API reference |
BackendGuideGenerator |
Generates backend implementation guide |
BackendFramework |
express / fastapi / gin |
Migration from 0.1.x #
| 0.1.x | 1.0.0 |
|---|---|
client.get('users') |
client.get<dynamic>('users') |
ApiClientConfig(requestInterceptor: x, responseInterceptor: y) |
ApiClientConfig(interceptors: [x, y]) |
| Untyped response wrapper | ApiResult<T> (T defaults to dynamic) |
| Manual 401 retry logic | Built-in via AuthInterceptor + refreshToken |
| Manual retry-on-503 | RetryInterceptor(policy: RetryPolicy.exponential(...)) |
lib/core/api_client.dart |
lib/src/core/api_client.dart |
lib/core/token_storage.dart |
lib/src/auth/token_storage.dart |
Test suite #
405 root-package tests + 4 example tests — all passing (verified with
flutter test at the repo root and in example/):
$ flutter test
$ (cd example && flutter test)
All tests passed.
| File | Coverage |
|---|---|
flutter_api_client_test.dart |
Client instantiation, ApiResult convenience access, token storage, request options, cancel tokens, query building |
api_result_test.dart |
ApiResult mapping/accessors, successful parse failures, empty-body success semantics, ApiException subtypes |
mock_adapter_test.dart |
Mock routing/capture, retry, dedup, cache, auth refresh queue, cancellation |
retry_error_test.dart |
RetryInterceptor.onError behavior for network, timeout, exhaustion, and non-retryable HTTP errors |
cache_interceptor_test.dart |
networkFirst, cacheOnly, staleWhileRevalidate, ETag / 304, in-memory LRU behavior |
offline_queue_test.dart |
Queue store operations and offline enqueue rules |
logger_test.dart |
cURL / pretty logging, redaction, truncation, and error logging |
utilities_test.dart |
FormData, CancelToken, buildUri, CachedTokenStorage, auth edge cases |
spec_test.dart |
ApiSpec, SpecMockAdapter, generators, and schema validation |
graphql_test.dart |
GraphQL client flows, validation, status overrides, and generator output |
gen_cli_test.dart |
CLI generator selection and generated spec discovery |
test_generator_test.dart |
Generated test scaffold output |
default_http_adapter_test.dart |
Transport: body encoding, header handling, response coalescing |
bugfix_core_test.dart / bugfix_http_test.dart / bugfix_interceptors_test.dart / bugfix_spec_test.dart / fixes_test.dart |
Regression tests pinning fixed bugs (auth-token leak guard, single Content-Type merge, streaming error-path drain, APQ hash fallback, dedup-on-retry no deadlock) |
| Run with coverage: |
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
License #
MIT — © 2024 Dhiraj Nikam