netanchor 1.2.0
netanchor: ^1.2.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: ^1.2.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 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 — 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) |
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',
parser: Ad.fromList,
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).
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 #
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. |