netanchor 0.4.0 copy "netanchor: ^0.4.0" to clipboard
netanchor: ^0.4.0 copied to clipboard

unlisted

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 #

pub license

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, MockAdapter shipped, anything else pluggable.
  • Sealed Result<T, NetworkFailure> with eleven failure cases — no nullable bodies, no magic casts, exhaustive switch enforced by the compiler.
  • Single-flight refresh-token queue — concurrent 401s share one refresh call, then transparently retry. No _isLoggingOut flag.
  • Listener pattern — toasts, navigation, and analytics never touch the library. The library never imports package:flutter.
  • Retry with exponential backoff + jitter — and RateLimitFailure honors the Retry-After header (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: true per request, dev/prod base URLs, head() and download() helpers, ValidationFailure.fromMap, failure.isRetriable, failure.localizedKey.
  • Pure-Dart RFC 7231 HTTP-date parser — works on Flutter Web (no dart:io leakage 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 #

  1. The library pauses the failing request.
  2. Concurrent 401s share a single in-flight refresh future — no thundering-herd.
  3. tokenStorage.save(...) persists the new tokens.
  4. onTokenRefreshed fires for side-effects (analytics, sync).
  5. The original request retries transparently with a fresh Authorization header.
  6. If refresh itself fails: tokens are cleared, onRefreshFailed fires, listeners get onUnauthorized, the call returns UnauthorizedFailure.
  7. 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)HttpAdapterException for 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

4
likes
0
points
51
downloads

Publisher

verified publisherbasuony.com

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#networking #http #dio #rest #api

License

unknown (license)

Dependencies

dio, flutter, meta

More

Packages that depend on netanchor