netanchor 0.4.0
netanchor: ^0.4.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 netAnchor = 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
.addInterceptor(LoggingInterceptor.pretty())
.retryPolicy(ExponentialBackoffPolicy(maxAttempts: 3))
.cacheStore(MemoryCacheStore())
.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", ...
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).
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