netanchor 4.0.0
netanchor: ^4.0.0 copied to clipboard
A swappable, SOLID-compliant networking layer for Flutter & Dart. Adapter pattern over Dio with refresh-token, retry, cache, pagination, and DataKey unwrapping. Zero UI coupling.
netanchor #
A typed, swappable HTTP client facade for Dart and Flutter.
Apps depend on the facade. The facade depends on a transport adapter. The day you decide to swap Dio for
package:http, you write a new adapter and change one line — every repository, interceptor, and test keeps working.
At a glance #
import 'package:netanchor/netanchor.dart';
final netAnchor = NetAnchor.builder()
.baseUrl('https://api.example.com')
.build();
final result = await netAnchor.get<User>(
'/users/me',
decoder: Decoders.jsonObject(User.fromJson),
);
result.when(
success: (user) => print('Hi, ${user.name}'),
failure: (f) => print('Failed: ${f.localizedKey}'),
);
Installation #
dependencies:
netanchor: ^4.0.0
import 'package:netanchor/netanchor.dart';
Requires Dart >=3.3.0. The runtime package is pure Dart; Flutter apps use the
same API without pulling Flutter into the transport layer. Uses Dart 3 sealed
classes, switch expressions, and pattern matching — no codegen, no mirrors.
Migrating from 3.x to 4.0 #
Version 4.0 introduces one open failure contract shared by netanchor and your application:
abstract interface class Failure {
String get message;
}
sealed class NetworkFailure implements Failure { /* existing API */ }
This removes the need to translate every NetworkFailure into an equivalent
application failure merely to satisfy a repository return type. Dart's generic
covariance allows Result<User, NetworkFailure> to be returned as
Result<User, Failure> directly, and Result.map changes only the success
value while preserving the exact failure object:
typedef AppResult<T> = Result<T, Failure>;
Future<AppResult<User>> loadUser() async {
final NetworkResult<UserModel> result = await api.get(
'/user',
decoder: Decoders.jsonObject(UserModel.fromJson),
);
return result.map((model) => model.toEntity());
}
Applications remain responsible for their own non-network failures:
final class CacheFailure implements Failure {
const CacheFailure(this.message);
@override
final String message;
}
Result<User, Failure> cachedUser() =>
const Result.failure(CacheFailure('cache_miss'));
Result variant rename #
The name Failure previously belonged to the failed Result variant, so it
could not also describe the open failure contract. In 4.0 that variant is
renamed to FailureResult<S, F>. The factory API is unchanged.
// 3.x: direct construction and pattern matching
return Failure<User, NetworkFailure>(error);
case Failure(:final failure):
// 4.0
return FailureResult<User, NetworkFailure>(error);
case FailureResult(:final failure):
// Unchanged and recommended when constructing a result
return Result<User, NetworkFailure>.failure(error);
Migration is mechanical: update the dependency to ^4.0.0, replace direct
uses of the result variant Failure(...) with FailureResult(...), then run
the analyzer. NetworkResult<T>, Result.failure(...), failure properties,
and all network failure subtypes keep their existing contracts.
Migrating from 2.x to 3.0 #
Version 3.0 makes the response shape explicit. In 2.x, parser: was only a
callback, so this looked valid even though netanchor could not know whether
the endpoint returned one object, a list, a primitive, null, or no body:
// 2.x
await netAnchor.get<User>('/users/me', parser: User.fromJson);
In 3.0, choose the wire shape and pass a decoder:
// 3.0
await netAnchor.get<User>(
'/users/me',
decoder: Decoders.jsonObject(User.fromJson),
);
This change catches a wrong root shape before your model factory runs,
produces useful item paths such as $[2], and applies the same rules to live,
cached, retried, and authentication-replayed responses.
Why not allow decoder: User.fromJson for everything? That callback returns a
User for one object, but a list endpoint must return List<User>. Dart
cannot invoke a static factory on the element of a generic List<T> at
runtime. Guessing from the received payload would require dynamic, a model
registry, code generation, or runtime type switches—and would let the server
choose the contract. In 3.0 the application declares that contract explicitly
with jsonObject or jsonList, while the model stays a normal
Map<String, dynamic> model with no netanchor inheritance.
Quick migration table #
| 2.x | 3.0 |
|---|---|
parser: User.fromJson |
decoder: Decoders.jsonObject(User.fromJson) |
parser: User.fromList |
decoder: Decoders.jsonList(User.fromJson) |
parser: (data) => data as int |
decoder: Decoders.raw<int>() |
| nullable model callback | decoder: Decoders.nullable(Decoders.jsonObject(User.fromJson)) |
| custom callback | decoder: Decoders.custom((payload) => ...) |
getPaginated(parser: User.fromJson) |
getPaginated(itemDecoder: Decoders.jsonObject(User.fromJson)) |
NetworkResult<VoidResponse> |
NetworkResult<void> / sendVoid(...) |
Update conventional model factories to accept a JSON object:
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
);
Then update the dependency and let the analyzer point to every old parser:
call:
dependencies:
netanchor: ^3.0.0
dart pub get
dart analyze
dart test
There is deliberately no deprecated parser: alias: keeping both contracts
would preserve the ambiguity this major version removes. If an endpoint has a
non-standard shape, Decoders.custom is the explicit escape hatch and its
exceptions still become ParseFailure.
Real before-and-after examples #
One object:
// 2.x
parser: (data) => User.fromJson(
Map<String, dynamic>.from(data as Map),
),
// 3.0
decoder: Decoders.jsonObject(User.fromJson),
A list of objects:
// 2.x
parser: (data) => (data as List)
.map((item) => User.fromJson(
Map<String, dynamic>.from(item as Map),
))
.toList(),
// 3.0
decoder: Decoders.jsonList(User.fromJson),
A custom parser whose callback already accepts Object?:
// 2.x
parser: CustomModel.parse,
// 3.0
decoder: Decoders.custom(CustomModel.parse),
Migrating from 1.x to 2.0 #
Version 2.0 makes retries safe by default and tightens a few contracts that could previously cause duplicate mutations or mutable shared state. Most 1.x apps only need to update the dependency and run their tests. You need the extra steps below if you have a custom retry policy, intentionally retry a mutation, read headers from a request/response, or mutate a headers map.
Why is this a major release? #
The 1.x API allowed behavior that was convenient but unsafe at scale:
| Change | Why 2.0 changed it | Who needs to migrate? |
|---|---|---|
| Mutations no longer retry automatically | A repeated payment, check-in, or leave request can run twice on the server | Apps that intentionally retry POST/PUT/PATCH/DELETE |
RetryPolicy receives RetryContext |
A policy needs the HTTP method and request context to make a safe decision | Apps with a custom RetryPolicy |
| Headers are lowercase and immutable | Prevents case-dependent lookups and accidental mutation shared between requests | Code that reads or mutates header maps |
| Automatic cache keys changed | Prevents one authenticated user or tenant from seeing another user's cached response | Apps with a persistent custom CacheStore |
| Secrets are redacted from logs | Debug logging must not expose passwords or tokens, including nested JSON values | No code change required |
The five-minute migration #
If you use the built-in retry policy, do not mutate header maps, and do not need mutation retries, the usual migration is simply:
- Change the dependency to
netanchor: ^2.0.1. - Run
dart pub get(orflutter pub get). - Run your tests.
- Check every intentional mutation retry and opt in only when the backend supports idempotency.
Your existing GET, HEAD, authentication, parsing, pagination, and standard
root-level refresh-token flows continue to use the same public facade.
1. Update the dependency #
dependencies:
netanchor: ^2.0.1
Then run:
dart pub get
2. Decide explicitly which mutations may be retried #
In 1.x, a configured retry policy could retry every HTTP method. In 2.0,
GET and HEAD still use the retry policy automatically, but POST, PUT,
PATCH, and DELETE make one attempt unless you explicitly opt in.
For example, this attendance request still compiles without changes, but it is no longer retried automatically. This is the safe choice when the server cannot protect against duplicate check-ins:
await netAnchor.post(
'/attendance/check-in',
body: {'employee_id': employeeId},
);
If the endpoint supports idempotency, generate one operation ID in your app, send it as an idempotency key, and explicitly allow retries:
// Create this once for the check-in operation. Keep the same value if the
// request is retried; do not generate a new value inside the retry attempt.
final checkInOperationId = pendingCheckIn.id;
await netAnchor.post(
'/attendance/check-in',
headers: {'Idempotency-Key': checkInOperationId},
body: {'employee_id': employeeId},
retryMode: RetryMode.always,
);
The server must treat repeated requests with the same idempotency key as one
operation. RetryMode.always only gives netanchor permission to retry; it
cannot make a non-idempotent backend safe by itself. To disable retry for one
read request, pass retryMode: RetryMode.never.
3. Update custom retry policies #
The old policy received only the attempt number and failure:
// netanchor 1.x
@override
Future<RetryDecision> shouldRetry(
int attempt,
NetworkFailure failure,
) async {
// ...
}
In 2.0 it receives a RetryContext, so a policy can also inspect the method,
request headers, and whether the failure came from connectivity, transport,
or an HTTP response:
// netanchor 2.0
@override
Future<RetryDecision> shouldRetry(RetryContext context) async {
if (context.attempt >= 3 || !context.failure.isRetriable) {
return RetryDecision.stop;
}
return const RetryDecision(
retry: true,
delay: Duration(milliseconds: 300),
);
}
Replace attempt with context.attempt and failure with
context.failure. Existing ExponentialBackoffPolicy users do not need to
change the policy configuration.
4. Read normalized headers and do not mutate their maps #
Request and response header keys are now normalized to lowercase and exposed as unmodifiable snapshots. Supplying headers remains case-insensitive, so this is still valid:
headers: {'Authorization': 'Bearer $accessToken'}
When reading headers, use lowercase keys:
// Before
final contentType = response.headers['Content-Type'];
// After
final contentType = response.headers['content-type'];
If an interceptor needs to add or replace a header, return a copied request
instead of mutating request.headers:
final updatedRequest = request.copyWith(
headers: {
...request.headers,
'x-trace-id': traceId,
},
);
5. Security and refresh responses #
Traffic logging now recursively replaces sensitive header, query, and body
values with [REDACTED] by default, including tokens nested inside JSON. No
migration is required; custom sensitive keys can be added when configuring
the logger.
The default refresh implementation still understands tokens at the JSON root. A wrapped or otherwise custom response can now be handled without replacing the whole refresher:
{
"data": {
"access_token": "new-access-token",
"refresh_token": "new-refresh-token"
}
}
AuthConfig(
tokenStorage: storage,
refreshPath: '/auth/refresh',
refreshTokenExtractor: (response) {
final body = response.data as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>;
return RefreshedTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
);
},
// ...the rest of your auth configuration
);
A fully custom TokenRefresher remains supported for flows that need more
than response parsing.
6. Let 2.0 rebuild automatic cache entries #
Automatic cache keys are now opaque fingerprints and authenticated requests are isolated by access-token identity. Existing 1.x automatic entries will not be reused after upgrading; they can be cleared immediately or left for your cache backend's normal eviction policy.
For tenant-, company-, or locale-specific responses, add a stable scope:
await netAnchor.get<List<Product>>(
'/products',
cache: CacheConfig(
policy: CachePolicy.cacheFirst,
scope: 'tenant:$tenantId:locale:$languageCode',
),
);
If you supply CacheConfig.key, it remains the complete key and bypasses
automatic user/scope isolation. Include the relevant user or tenant identity
yourself when using that advanced override.
Migration checklist #
- Update the dependency to
^2.0.1. - Migrate custom
RetryPolicy.shouldRetryimplementations toRetryContext. - Add
RetryMode.alwaysonly to mutations backed by server-side idempotency. - Change header lookups to lowercase.
- Replace direct header-map mutations with
copyWith. - Clear old automatic cache entries and add
scopewhere responses vary by tenant or locale. - Run your unit/integration tests, especially mutation and token-refresh flows.
Why netanchor? #
Every Flutter team I've seen ships the same NetworkHandler over and over:
five copies of try { dio.get/post/put/delete } catch (DioException),
showToast and Navigator.push calls smuggled into the network layer, a
static _isLoggingOut flag because 401s race each other, Dio types leaking
into every repository, no retry, no cache, no proper refresh-token queue.
netanchor consolidates those into one principled architecture so you can stop rewriting them every project.
What you get #
- Adapter pattern — Dio default,
MockAdaptershipped, anything else pluggable. - Sealed
Result<T, NetworkFailure>with eleven failure cases — no nullable bodies, no magic casts, exhaustiveswitchenforced by the compiler. - Single-flight refresh-token queue — concurrent 401s share one refresh
call, then transparently retry. No
_isLoggingOutflag. - Listener pattern — toasts, navigation, and analytics never touch the
library. The library never imports
package:flutter. - Retry with exponential backoff + jitter — and
RateLimitFailurehonors theRetry-Afterheader (RFC 7231). - Pluggable cache with six strategies:
cacheFirst,staleWhileRevalidate,cacheElseNetwork,networkOnly,networkFirst(online → fresh, offline → last known),noCache. - Pagination — typed
Paginated<T>with offset/cursor/custom parsers. DataKey— auto-unwrap{ "data": {...} }envelopes; per-request override.- First-class progress callbacks for upload and download.
CancellationToken,silent: trueper request, dev/prod base URLs,head()anddownload()helpers,ValidationFailure.fromMap,failure.isRetriable,failure.localizedKey.- Pure-Dart RFC 7231 HTTP-date parser — works on Flutter Web (no
dart:ioleakage in the core).
Quick start #
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:netanchor/netanchor.dart';
final builder = NetAnchor.builder()
.baseUrls(
prod: 'https://api.example.com',
dev: 'https://dev.example.com',
)
.useDev(kDebugMode)
.dataKey(const DataKey('data')) // auto-unwrap envelopes
.listener(MyAppListener()) // toasts/navigation here
.retryPolicy(ExponentialBackoffPolicy(maxAttempts: 3))
.cacheStore(MemoryCacheStore());
// Per-request traffic log (`→ request`, `← response`, `✗ failure`).
// `.logger(...)` sets the *sink*; `.enableTrafficLog()` is the *switch*
// that produces the traffic. Secrets are redacted by default, but keeping
// production logging intentional still limits PII and log volume.
if (kDebugMode) builder.enableTrafficLog();
final netAnchor = builder.build();
final result = await netAnchor.get<User>(
'/users/me',
decoder: Decoders.jsonObject(User.fromJson),
);
Automatic failure retries are safe-by-default: GET and HEAD participate
in the configured retry policy, while POST, PUT, PATCH, and DELETE do
not. Opt a mutation in only when the endpoint is idempotent (preferably with
an idempotency key):
await netAnchor.post(
'/payments',
headers: {'Idempotency-Key': operationId},
body: payment,
retryMode: RetryMode.always,
);
Pass retryMode: RetryMode.never to disable retries for an individual read.
Custom retry policies receive the complete failed-attempt context:
class IdempotencyAwareRetryPolicy extends RetryPolicy {
const IdempotencyAwareRetryPolicy();
@override
Future<RetryDecision> shouldRetry(RetryContext context) async {
final hasKey = context.request.headers.containsKey('idempotency-key');
if (context.request.method == HttpMethod.post && !hasKey) {
return RetryDecision.stop;
}
if (context.attempt >= 3 || !context.failure.isRetriable) {
return RetryDecision.stop;
}
return const RetryDecision(
retry: true,
delay: Duration(milliseconds: 300),
);
}
}
RetryContext.attempt counts policy-visible failures only; transparent token
refreshes do not consume the retry budget. RetryCause distinguishes
connectivity, transport, and HTTP-response failures.
Response decoding #
Every successful response is decoded through one explicit
PayloadDecoder<T>. DataKey, when configured, unwraps the envelope first;
the decoder then runs once on the selected live, cached, retried, or
authentication-replayed response.
// One JSON object.
final user = await netAnchor.get<User>(
'/users/42',
decoder: Decoders.jsonObject(User.fromJson),
);
// A JSON array of model objects.
final users = await netAnchor.get<List<User>>(
'/users',
decoder: Decoders.jsonList(User.fromJson),
);
// A primitive list and a raw primitive.
final ids = await netAnchor.get<List<int>>(
'/user-ids',
decoder: Decoders.list(Decoders.raw<int>()),
);
final count = await netAnchor.get<int>(
'/users/count',
decoder: Decoders.raw<int>(),
);
// Null is accepted, but a non-null value must still be a valid User.
final optionalUser = await netAnchor.get<User?>(
'/users/current',
decoder: Decoders.nullable(Decoders.jsonObject(User.fromJson)),
);
// Empty response bodies use NetworkResult<void>.
final deleted = await netAnchor.sendVoid(
'/users/42',
method: HttpMethod.delete,
);
For a genuinely custom wire format, keep the conversion visible:
final total = await netAnchor.get<int>(
'/stats',
decoder: Decoders.custom((payload) {
final object = payload as Map<String, dynamic>;
return (object['summary'] as Map<String, dynamic>)['total'] as int;
}),
);
Wrong shapes and model factory exceptions become ParseFailure; they do not
escape the NetworkResult contract. Its rawError is a
PayloadDecodeException with expected, actualType, and a path such as
$[2]. The exception never stores or prints the response payload, so a parse
diagnostic cannot accidentally expose tokens or personal data.
Handling failures #
Every verb returns Future<NetworkResult<T>>, a sealed type alias for
Result<T, NetworkFailure>. There is no try / catch, no nullable body,
no boolean success flag.
switch (result) {
case Success(:final data):
showProfile(data);
case FailureResult(failure: NoInternetFailure()): showOfflineBanner();
case FailureResult(failure: TimeoutFailure()): showRetryButton();
case FailureResult(failure: UnauthorizedFailure()): /* listener navigates */;
case FailureResult(failure: ForbiddenFailure()): showLockedFeatureUI();
case FailureResult(failure: NotFoundFailure()): showEmptyState();
case FailureResult(failure: ValidationFailure(:final fieldErrors)):
for (final entry in fieldErrors.entries) {
formKey.currentState?.invalidateField(entry.key, entry.value.first);
}
case FailureResult(failure: RateLimitFailure(:final retryAfter)):
showRateLimitedBanner(retryAfter);
case FailureResult(failure: ServerFailure()): showServerDownBanner();
case FailureResult(failure: ParseFailure()): crashlytics.report(failure);
case FailureResult(failure: CancelledFailure()): /* user cancelled */;
case FailureResult(failure: UnknownFailure()): showGenericError();
}
Two convenience helpers on every failure:
if (failure.isRetriable) showRetryButton();
final diagnostic = failure.message; // server message/error when available
final i18nKey = failure.localizedKey; // "no_internet", "validation_failed", ...
message and localizedKey have deliberately separate roles. message is
the best available diagnostic: HTTP failures use the server's message or
error value when present, otherwise netanchor supplies a fallback. It may be
useful for logs or domain decisions, but it is not a stable localization key.
localizedKey depends only on the failure type and is the value intended for
translated UI.
Constructing a result yourself #
The verbs hand you a NetworkResult<T> — but you'll also build one
yourself inside repositories (wrapping the DTO into an entity), in fake
data sources, and in tests. Two equivalent ways:
// 1. The variant classes directly — explicit, spells out both type args.
return Success<User, NetworkFailure>(user);
return FailureResult<User, NetworkFailure>(const UnauthorizedFailure());
// 2. The factory constructors — no need to repeat `NetworkFailure`,
// and the type name matches the method's return type.
NetworkResult<User> loadUser() {
return NetworkResult<User>.success(user);
// ...or:
return NetworkResult<User>.failure(const UnauthorizedFailure());
}
Both compile to the exact same object. Pick whichever reads better. The
factories save you from repeating NetworkFailure for network calls; broader
repository results can instead use Result<T, Failure>.
Note —
Result.success/Result.failureare constructors, so they only help when building a result. To inspect one, you still pattern-match on theSuccess/FailureResultclasses (switch,is Success<...>,result.when(...)) — a type alias can't discriminate the variant at a check site.
Authentication & refresh tokens #
AuthConfig bundles every refresh-related setting in one place:
final netAnchor = NetAnchor.builder()
.baseUrl('https://api.example.com')
.auth(AuthConfig(
tokenStorage: MyKeychainTokenStorage(),
refreshPath: '/auth/refresh',
accessTokenPrefix: 'Bearer',
removeAccessTokenBeforeRefresh: true,
// Customise the refresh request
refreshBodyBuilder: (refreshToken) => {
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
},
refreshHeaders: const {'X-Client-Version': '1.0.0'},
// Override the JSON keys if your backend uses camelCase
refreshAccessTokenJsonKey: 'accessToken',
refreshTokenJsonKey: 'refreshToken',
// Hooks
onBeforeRefreshRequest: (req) => req.copyWith(
headers: {...req.headers, 'X-Trace-Id': uuid.v4()},
),
onTokenRefreshed: (tokens) async {
analytics.track('token_refreshed');
},
onRefreshFailed: (failure) async {
analytics.track('refresh_failed', failure.message);
},
))
.build();
If the refresh response is wrapped (or uses any non-standard shape), keep the default HTTP refresh flow and customize extraction only:
AuthConfig(
tokenStorage: storage,
refreshPath: '/auth/refresh',
refreshTokenExtractor: (response) {
final root = response.data as Map<String, dynamic>;
final data = root['data'] as Map<String, dynamic>;
return RefreshedTokens(
accessToken: data['access_token'] as String,
refreshToken: data['refresh_token'] as String?,
);
},
);
The hook receives the full HttpResponse, so it can support arbitrary
nesting or header-based tokens. For custom transport or multi-step refresh
flows, implementing TokenRefresher remains the lower-level extension point.
Public endpoints can opt out of automatic token attachment and the entire
401 refresh / unauthorized pipeline on a per-request basis:
final result = await netAnchor.post<AuthSession>(
'/auth/login',
body: credentials,
requiresAuth: false,
decoder: Decoders.jsonObject(AuthSession.fromJson),
);
requiresAuth defaults to true, so existing calls keep their current
behavior. When it is false, an explicitly supplied authentication header is
still preserved; only netanchor's automatic auth behavior is skipped.
What happens on a 401 #
- The library pauses the failing request.
- Concurrent 401s share a single in-flight refresh future — no thundering-herd.
tokenStorage.save(...)persists the new tokens.onTokenRefreshedfires for side-effects (analytics, sync).- The original request retries transparently with a fresh
Authorizationheader. - If refresh itself fails: tokens are cleared,
onRefreshFailedfires, listeners getonUnauthorized, the call returnsUnauthorizedFailure. - If the server returns 401 again after a successful refresh, the
library does not loop — the auth-retry cap is one. Subsequent 401s
fall through to
onUnauthorized.
Refresh path bypasses interceptors — by design #
Refresh requests go through the HttpAdapter directly, not the
interceptor chain (avoids accidental auth-on-auth loops). For dynamic
headers on refresh requests use onBeforeRefreshRequest, not a
DynamicHeaderInterceptor.
Custom refresh logic #
Need mTLS, device attestation, or anything exotic? Implement
TokenRefresher and pass it through AuthConfig.refresher:
class MyRefresher extends TokenRefresher {
@override
Future<RefreshedTokens> refresh(String refreshToken) async {
final tokens = await myCustomFlow(refreshToken);
return RefreshedTokens(
accessToken: tokens.access,
refreshToken: tokens.refresh,
);
}
}
NetAnchor.builder()
.auth(AuthConfig(tokenStorage: storage, refresher: MyRefresher()))
.build();
Caching #
final netAnchor = NetAnchor.builder()
.cacheStore(MemoryCacheStore())
.build();
final products = await netAnchor.get<List<Product>>(
'/products',
decoder: Decoders.jsonList(Product.fromJson),
cache: const CacheConfig(
policy: CachePolicy.staleWhileRevalidate,
ttl: Duration(minutes: 5),
),
);
| Policy | Reads cache? | Writes cache? | Triggers network? |
|---|---|---|---|
networkOnly (default) |
no | yes (on 2xx) | always |
cacheFirst |
yes (always) | yes (on 2xx) | only on miss |
cacheElseNetwork |
yes (if fresh) | yes (on 2xx) | on miss or expired |
staleWhileRevalidate |
yes (always) | yes (on 2xx, in background) | yes (background) |
networkFirst |
only on offline failure (even if expired) | yes (on 2xx) | always |
noCache |
no | no | always |
networkFirst is the "online → fresh, offline → last known" strategy:
it always tries the network first and returns the fresh response when the
server is reachable; only a connectivity/timeout failure makes it fall back
to the last cached entry (even an expired one). Other failures (4xx/5xx,
parse) pass through untouched — stale cache never masks a real error.
final feed = await netAnchor.get<List<Ad>>(
'/ads',
decoder: Decoders.jsonList(Ad.fromJson),
cache: const CacheConfig(policy: CachePolicy.networkFirst, ttl: Duration(days: 7)),
);
// Online: always fresh. Offline: the last successful response, if any.
cache: works on every verb (get, post, put, patch, delete).
Automatic keys are SHA-256 fingerprints of a canonical request description;
raw URLs, query values, headers, bodies, and tokens never become store keys.
Authenticated requests are partitioned by access-token identity, so cached
/users/me data cannot cross from one signed-in user to another. Headers
from both NetAnchorConfig.defaultHeaders and the individual request also
participate in the fingerprint.
Responses that also vary by tenant, company, locale, or another application
dimension through dynamic interceptors should provide a stable scope:
cache: CacheConfig(
policy: CachePolicy.cacheFirst,
scope: 'tenant:$tenantId:locale:$languageCode',
)
CacheConfig.key is the low-level escape hatch: it replaces the complete
automatic key, including authentication and scope isolation. Use it only
when the application constructs a safe, fully-partitioned key itself.
Custom cache backend #
CacheStore is a tiny interface — wire it up to Hive, SQLite, Drift,
file system, anything:
class HiveCacheStore extends CacheStore {
HiveCacheStore(this._box);
final Box<Map> _box;
@override
Future<CacheEntry?> read(String key) async {
final raw = _box.get(key);
if (raw == null) return null;
return CacheEntry(
data: raw['data'],
savedAt: DateTime.fromMillisecondsSinceEpoch(raw['savedAt'] as int),
);
}
@override
Future<void> write(String key, CacheEntry entry) =>
_box.put(key, {
'data': entry.data,
'savedAt': entry.savedAt.millisecondsSinceEpoch,
});
@override
Future<void> remove(String key) => _box.delete(key);
@override
Future<void> clear() => _box.clear();
}
Pagination #
final result = await netAnchor.getPaginated<User>(
'/users',
itemDecoder: Decoders.jsonObject(User.fromJson),
queryParameters: {'page': 1, 'per_page': 20},
);
result.when(
success: (page) {
print('${page.items.length} of ${page.total}; more=${page.hasMore}');
},
failure: (f) => print(f),
);
The default OffsetPaginationParser reads items/data/results/
records for the list and page/per_page/total/total_pages/
has_more for the metadata. Bare arrays work too — if your API
returns [ {...}, {...} ] directly, the pagination strategy treats it as a
one-page result with null metadata.
For Stripe-style cursor pagination:
NetAnchor.builder()
.paginationParser(const CursorPaginationParser())
.build();
For an exotic shape, implement PaginationParser yourself — three lines:
class JsonApiPaginationParser extends PaginationParser {
const JsonApiPaginationParser();
@override
Paginated<T> parse<T>(Object? data, PayloadDecoder<T> itemDecoder) {
final map = data! as Map;
return Paginated<T>(
items: Decoders.list(itemDecoder).decode(map['data']),
nextCursor: (map['links'] as Map?)?['next'] as String?,
raw: data,
);
}
}
File upload (with progress) #
await netAnchor.post<UploadResult>(
'/files',
body: UploadFile.path(
path: file.path,
filename: file.name,
contentType: 'image/jpeg',
),
decoder: Decoders.jsonObject(UploadResult.fromJson),
onSendProgress: (sent, total) {
final pct = total > 0 ? (sent / total * 100).round() : 0;
setState(() => _progress = pct);
},
);
For a multipart form with mixed fields and files:
await netAnchor.post<Album>(
'/albums',
body: {
'title': 'My album',
'cover': UploadFile.path(path: cover.path, filename: 'cover.jpg'),
'photos': [
for (final f in photos) UploadFile.path(path: f.path, filename: f.name),
],
},
decoder: Decoders.jsonObject(Album.fromJson),
);
Bytes (no file on disk):
await netAnchor.post<UploadResult>(
'/files',
body: UploadFile.bytes(
bytes: pngBytes,
filename: 'screenshot.png',
contentType: 'image/png',
),
decoder: Decoders.jsonObject(UploadResult.fromJson),
);
total may be -1 when the size is unknown (chunked encoding). Handle
that case in your UI.
Binary downloads #
final result = await netAnchor.download(
'/files/large.zip',
onReceiveProgress: (received, total) {
debugPrint('downloaded $received / $total bytes');
},
);
result.when(
success: (bytes) => File('./large.zip').writeAsBytes(bytes),
failure: (f) => print(f),
);
download() sets responseType: HttpResponseType.bytes so the body is
delivered as raw List<int>, not parsed as JSON or coerced to String.
Cancellation #
final token = CancellationToken();
final future = netAnchor.get<List<Post>>(
'/feed',
decoder: Decoders.jsonList(Post.fromJson),
cancellationToken: token,
);
@override
void dispose() {
token.cancel('user_left_screen');
super.dispose();
}
final result = await future;
// → FailureResult(CancelledFailure(reason: 'user_left_screen'))
The token is library-neutral. Adapters translate it into their native
equivalent (Dio's CancelToken for DioAdapter).
Logging #
netanchor ships three loggers behind a NetAnchorLogger interface, so
you can pick the one that fits your build flavor. Plug whichever you
want into the builder with .logger(...); the same sink also feeds
.enableTrafficLog(), which is the per-request → … ← … ✗ … switch.
Pretty — recommended for development #
PrettyNetAnchorLogger renders every request, response, and failure
as a bordered, color-coded box. Visually equivalent to
package:logger's PrettyPrinter,
wired into the netanchor pipeline.
final netAnchor = NetAnchor.builder()
.baseUrl('https://api.example.com')
.logger(const PrettyNetAnchorLogger(printTime: true))
.enableTrafficLog() // debug builds only
.build();
Traffic logs redact sensitive keys recursively in request headers, request
bodies, and response bodies in every build mode. Matching is case-insensitive
and covers common variants such as Authorization, password,
access_token, refreshToken, client_secret, cookies, and API keys.
Add project-specific keys through the builder:
.enableTrafficLog(
redactor: const SensitiveDataRedactor(
additionalSensitiveKeys: {'employee_pin'},
),
)
Implement LogRedactor when a custom body type needs special traversal.
Redaction never mutates the actual request or response data. It reduces secret
exposure but does not make arbitrary production traffic logging risk-free;
non-secret personal data can still be present.
Output (cyan for 2xx, yellow for 4xx, red for 5xx and transport failures):
┌─ REQUEST ───────────────────────────────────────────────
│ 12:30:40.550
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
│ at AuthRepository.login (package:myapp/auth/repo.dart:42:5)
│ at AuthCubit.signIn (package:myapp/auth/cubit.dart:18:7)
│ at LoginPage._submit (package:myapp/ui/login_page.dart:55:9)
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
│ 💡 → POST /api/auth/login
│ body: {
│ "email": "you@example.com"
│ }
└─────────────────────────────────────────────────────────
The caller-stack section filters out framework frames
(package:netanchor/, package:dio/, package:flutter/, dart:async,
…) and renders the last user-code frames in Dart's standard
(package:foo/bar.dart:42:5) format — Android Studio, VS Code, and
flutter logs render those as clickable links.
Knobs:
| Parameter | Default | Purpose |
|---|---|---|
colors |
true |
Wrap each line with ANSI color codes. Set false for plain CI logs or sinks that show codes as garbage. |
printEmojis |
true |
Prefix the message line with 💡 / ⚠️ / ⛔. |
printTime |
false |
Render an HH:mm:ss.SSS row at the top of the box. |
lineLength |
120 |
Total visible width of the box. |
callerMethodCount |
3 |
User-code frames in the caller section. 0 hides it. |
excludeFromCallerTrace |
netanchor / dio / flutter / dart | Path prefixes to drop from caller frames. |
errorMethodCount |
8 |
Frames included for error() stack traces. |
Android note. Per-line
print()means Android's logcat may interleave system rows (D/BufferQueueProducer,SurfaceView, …) between box rows. Open Android Studio's Logcat tab and filter bytag:flutter— the noise disappears and the box stays intact. (We tried batching the whole box into oneprint(); Android's ~1 KB log cap silently truncated the trailing border.)
Stdout — DevTools-friendly #
StdoutNetAnchorLogger routes through dart:developer.log, which is
what Flutter DevTools' Logging panel reads and what crash reporters
(Crashlytics, Sentry) pick up automatically:
.logger(const StdoutNetAnchorLogger(name: 'netanchor'))
Silent — production #
SilentNetAnchorLogger drops everything on the floor. Wire it for
release builds so request bodies and tokens never end up in production
logs:
.logger(cfg.isDebug
? const PrettyNetAnchorLogger()
: const SilentNetAnchorLogger())
Bringing your own #
Implement NetAnchorLogger. The three core methods are required;
override the optional log method too if you want titled, leveled
output (otherwise the base class routes it through info / warn /
error with the title inlined):
class MyLogger extends NetAnchorLogger {
@override
void info(String message) => myLogger.i(message);
@override
void warn(String message) => myLogger.w(message);
@override
void error(String message, [Object? error, StackTrace? stack]) =>
myLogger.e(message, error, stack);
// Optional — only override if you want REQUEST / RESPONSE / FAILURE
// titles on whatever surface you're rendering to.
@override
void log({
required LogSeverity level,
required String title,
required String message,
Object? error,
StackTrace? stackTrace,
}) {
// …
}
}
Decoupling UI: the listener pattern #
Networking never imports flutter. UI reactions go through
NetAnchorListener:
class MyAppListener extends NetAnchorListener {
@override
void onUnauthorized(NetworkEvent event) {
Session.clear();
AppRouter.go('/auth/login');
}
@override
void onError(NetworkFailure failure, NetworkEvent event) {
if (event.isSilent) return;
Toasts.error(I18n.of(failure.localizedKey));
}
@override
void onNoInternet(NetworkEvent event) {
if (event.isSilent) return;
Toasts.warn('You are offline'.tr());
}
@override
void onRetry(int attempt, NetworkEvent event) {
Analytics.track('http_retry', {'attempt': attempt, 'path': event.request.path});
}
}
Multiple listeners are allowed; events fan out to all of them.
onError and onNoInternet are terminal events: each fires at most once,
and only if the complete request still fails after retries and cache fallback.
A temporary timeout or offline attempt followed by a successful retry does
not produce a UI error. onRetry remains attempt-level and is the right place
for retry analytics.
Two events for two failure modes #
onError and onNoInternet are orthogonal:
| Failure source | onNoInternet |
onError |
|---|---|---|
ConnectivityChecker returned false (request never sent) |
✅ fires | ❌ skipped |
Adapter threw noInternet mid-flight |
❌ skipped | ✅ fires with NoInternetFailure |
If you want one hook for both, override both methods and dispatch the same UI.
Per-request silence #
For background polling, prefetching, or analytics calls, suppress UI feedback without changing the listener:
await netAnchor.get('/analytics/config', silent: true);
In your listener, check event.isSilent before showing UI.
Testing your repositories #
MockAdapter ships in the public API. No mocktail required:
import 'package:netanchor/netanchor.dart';
import 'package:test/test.dart';
void main() {
test('login persists tokens', () async {
final adapter = MockAdapter()
..when(
method: HttpMethod.post,
path: '/auth/login',
bodyMatcher: (body) =>
body is Map && body['email'] == 'a@b.c',
).thenReturn(HttpResponse(
statusCode: 200,
data: {'accessToken': 'TOKEN', 'user': {'id': 1, 'name': 'Ada'}},
headers: const {},
));
final netAnchor = NetAnchor.builder()
.httpAdapter(adapter)
.baseUrl('https://api.test')
.build();
final result = await AuthRepo(netAnchor).login('a@b.c', 'pw');
expect(result.isSuccess, isTrue);
expect(adapter.recordedRequests, hasLength(1));
expect(adapter.lastRequest!.body, {'email': 'a@b.c', 'password': 'pw'});
});
}
when(...) accepts:
method,path,pathContains— basic verb + URL filters.bodyMatcher: (body) => bool— assert on the payload before answering.headerMatcher: (headers) => bool— assert on headers.queryMatcher: (query) => bool— assert on query parameters.
Then chain one of:
.thenReturn(response)— same response every time..thenThrow(exception)—HttpAdapterExceptionfor transport failures..thenReturnInOrder([r1, r2, r3])— cycles each response, sticks on the last. Great for retry tests..thenRespond([MockReply.success, MockReply.failure, ...])— mix successes and exceptions..withDelay(duration)— pre-delay before answering.
For deterministic retry timing use package:fake_async. For deterministic
jitter, pass a seeded Random to ExponentialBackoffPolicy.
Swapping the HTTP client #
The HttpAdapter interface is small:
abstract class HttpAdapter {
Future<HttpResponse> send(HttpRequest request);
void close({bool force = false});
}
A bare package:http adapter looks like:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:netanchor/netanchor.dart';
class HttpPackageAdapter implements HttpAdapter {
final _client = http.Client();
@override
Future<HttpResponse> send(HttpRequest request) async {
final uri = Uri.parse(request.path).replace(
queryParameters: request.queryParameters
.map((k, v) => MapEntry(k, '$v')),
);
final req = http.Request(request.method.value, uri)
..headers.addAll(request.headers);
if (request.body != null) req.body = jsonEncode(request.body);
final streamed = await _client.send(req);
final body = await streamed.stream.bytesToString();
return HttpResponse(
statusCode: streamed.statusCode,
data: body.isEmpty ? null : jsonDecode(body),
headers: streamed.headers.map((k, v) => MapEntry(k.toLowerCase(), [v])),
);
}
@override
void close({bool force = false}) => _client.close();
}
NetAnchor.builder()
.httpAdapter(HttpPackageAdapter())
.build();
Every interceptor, repository, listener, and test — untouched.
Architecture in one picture #
┌──────────────────┐
│ Your App / VM │
└────────┬─────────┘
▼
┌──────────────────┐ ┌─────────────────┐
│ NetAnchor │ ──────► │ listeners[] │ ← UI hooks
│ (facade) │ └─────────────────┘
└────────┬─────────┘
┌─────────────────┼─────────────────┬─────────────┐
▼ ▼ ▼ ▼
┌────────┐ ┌────────────┐ ┌──────────┐ ┌────────────────┐
│Inter- │ │RetryPolicy │ │CacheStore│ │PaginationParser│
│ceptors │ └────────────┘ └──────────┘ └────────────────┘
└───┬────┘
▼
┌──────────────┐ ┌──────────────────┐
│ HttpAdapter │ ──────► │ DioAdapter │
│ (interface) │ │ MockAdapter │
└──────────────┘ │ HttpPackageAdapter (yours)
└──────────────────┘
A request enters NetAnchor, walks the interceptor chain on the way out,
crosses the HttpAdapter boundary, and the response walks the chain in
reverse. The pipeline emits side-effects to listeners at every meaningful
step. Failures are mapped into one of eleven typed cases; successes are
unwrapped via the optional DataKey and decoded by your PayloadDecoder.
See PLAN.md for the architectural deep-dive and roadmap.
Migrating from a hand-rolled NetworkHandler #
| Old | New |
|---|---|
try { dio.get(...) } catch (DioException) |
await netAnchor.get<T>(...) returns Result<T, NetworkFailure> |
showToast inside the handler |
NetAnchorListener.onError |
Modular.to.navigate('/login') |
NetAnchorListener.onUnauthorized |
_isLoggingOut static flag |
Built-in single-flight refresh queue |
Dio types in repository signatures |
NetAnchor (no Dio types leak) |
responseType is ListMappable ? ... cast |
explicit PayloadDecoder<T> |
Contributing #
netanchor is not accepting external contributions at this time. The API is still settling and there's planned work in flight that would conflict with most PRs. Bug reports filed as issues are appreciated; please don't open pull requests yet.
This notice will be removed once the project opens for external contributors — watch the repo for the announcement.
License #
MIT
Author #
|
Mahmoud Basuony Software Engineer If netanchor saved you a few hours of boilerplate, a ⭐ on the repo is appreciated. |