netanchor 1.1.0
netanchor: ^1.1.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', parser: User.fromJson);
result.when(
success: (user) => print('Hi, ${user.name}'),
failure: (f) => print('Failed: ${f.localizedKey}'),
);
Installation #
dependencies:
netanchor: ^0.4.0
import 'package:netanchor/netanchor.dart';
Requires Dart >=3.3.0 and Flutter >=3.19.0. Uses Dart 3 sealed
classes, switch expressions, and pattern matching — no codegen, no mirrors.
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 five strategies:
cacheFirst,staleWhileRevalidate,cacheElseNetwork,networkOnly,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 — keep it debug-only so you don't leak
// request bodies into release logs.
if (kDebugMode) builder.enableTrafficLog();
final netAnchor = builder.build();
final result = await netAnchor.get<User>(
'/users/me',
parser: User.fromJson,
);
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 Failure(failure: NoInternetFailure()): showOfflineBanner();
case Failure(failure: TimeoutFailure()): showRetryButton();
case Failure(failure: UnauthorizedFailure()): /* listener navigates */;
case Failure(failure: ForbiddenFailure()): showLockedFeatureUI();
case Failure(failure: NotFoundFailure()): showEmptyState();
case Failure(failure: ValidationFailure(:final fieldErrors)):
for (final entry in fieldErrors.entries) {
formKey.currentState?.invalidateField(entry.key, entry.value.first);
}
case Failure(failure: RateLimitFailure(:final retryAfter)):
showRateLimitedBanner(retryAfter);
case Failure(failure: ServerFailure()): showServerDownBanner();
case Failure(failure: ParseFailure()): crashlytics.report(failure);
case Failure(failure: CancelledFailure()): /* user cancelled */;
case Failure(failure: UnknownFailure()): showGenericError();
}
Two convenience helpers on every failure:
if (failure.isRetriable) showRetryButton();
final i18nKey = failure.localizedKey; // "no_internet", "validation_failed", ...
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 Failure<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 to
you — the factories just save you from writing NetworkFailure on every
line, since in netanchor the failure type is always NetworkFailure.
Note —
Result.success/Result.failureare constructors, so they only help when building a result. To inspect one, you still pattern-match on theSuccess/Failureclasses (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();
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',
parser: Product.fromList,
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) |
noCache |
no | no | always |
cache: works on every verb (get, post, put, patch, delete).
Cache keys for non-GET requests include a stable jsonEncode of the
body, so two POSTs with structurally-equal payloads share the same slot
without colliding with a third that sends different data.
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',
parser: 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 parser 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, ResponseParser<T> itemParser) {
final map = data! as Map;
return Paginated<T>(
items: (map['data'] as List).map(itemParser).toList(),
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',
),
parser: 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),
],
},
parser: Album.fromJson,
);
Bytes (no file on disk):
await netAnchor.post<UploadResult>(
'/files',
body: UploadFile.bytes(
bytes: pngBytes,
filename: 'screenshot.png',
contentType: 'image/png',
),
parser: 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',
parser: Post.fromList,
cancellationToken: token,
);
@override
void dispose() {
token.cancel('user_left_screen');
super.dispose();
}
final result = await future;
// → Failure(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();
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.
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/event', body: payload, 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:flutter_test/flutter_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 parsed by your parser.
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 |
parser: T Function(Object?) |
Contributing #
Issues and PRs welcome. Read PLAN.md before opening one —
it lists the architectural invariants the project keeps. The PR
template's checklist (dart format, flutter analyze --fatal-infos,
flutter test) runs in CI on every push.
License #
MIT