api_provider
A thin layer over Dio for Flutter apps that talk to a bearer-token REST API. You get the things every app ends up writing by hand:
- Bearer auth - the token is attached to every request automatically.
- Automatic token refresh - on a
401the token is refreshed (once, even if ten requests fail together) and the request is replayed. - Secure token storage - optionally persisted in the platform keychain/keystore, restored automatically on next launch.
- Session state for your UI -
authTokenListenabledrives redirects and sign-in buttons. - Friendly errors -
e.userMessage,e.isTimeout,e.isUnauthorized... instead of digging throughDioException. Translatable. - Testable - inject your own storage or Dio; no globals.
If this package saves you time, you can buy me a coffee.
Why use it?
Every app with a login ends up writing the same fragile code. Without this package:
// Attach the token to every request, refresh on 401 - but only once if five
// requests fail together, queue the others, replay them, don't loop if the
// refresh endpoint itself fails, persist the new token, sign out on failure,
// keep the token out of logs, turn DioException into something a user can read...
That is usually 150-300 lines of interceptor code, and the concurrency bugs only show up in production. With this package:
final api = ApiProvider(
baseUrl: 'https://api.example.com/',
refreshTokenProvider: RefreshTokenProvider.from(refreshMyToken),
onUnauthorized: () => router.go('/login'),
);
Use it if your app talks to a REST API with a bearer token (JWT, OAuth access token, API key).
If you only call public endpoints, plain dio or http is enough.
Works on Android, iOS, web, macOS, Windows and Linux. No Dio import needed for everyday use - the common types are re-exported.
Install
dependencies:
api_provider: ^2.0.0
Quick start
import 'package:api_provider/api_provider.dart';
final api = ApiProvider(baseUrl: 'https://api.example.com/');
// Public endpoint (login): no token needed.
final login = await api.post<Map<String, dynamic>>(
'auth/login',
data: {'email': email, 'password': password},
options: skipAuth(),
);
// Save the token; isLocalSaved keeps it across app restarts.
await api.persistToken(token: login.data!['token'], isLocalSaved: true);
// Authenticated calls just work.
final me = await api.get<Map<String, dynamic>>('users/me');
A saved login is restored automatically before the first authenticated request. If you need to know earlier (to pick the first screen), ask:
final token = await api.hasAuthToken(); // null -> show the login screen
Sign out:
await api.deleteAuthToken();
Handling errors
try {
await api.get('orders');
} on DioException catch (e) {
showSnackBar(e.userMessage); // "The request timed out", the server's own message, ...
if (e.isUnauthorized) { /* ... */ }
}
userMessage uses the server's own message when the body has one, and a sensible default otherwise.
It understands the common shapes: {message}, {error}, OAuth {error_description}, {detail},
JSON:API / GraphQL {errors: [...]}, Laravel/Rails {errors: {field: [...]}} and FastAPI {detail: [{msg}]}.
HTML error pages are never shown.
Also available: statusCode, serverMessage, isTimeout, isNetworkError, isCancelled, isBadRequest,
isUnauthorized, isForbidden, isNotFound, isConflict, isRateLimited, isClientError, isServerError, isMissingToken.
Translating error messages
// Once, at start-up:
ApiErrorMessages.current = const ApiErrorMessages(
timeout: 'Zeitüberschreitung. Bitte erneut versuchen.',
noConnection: 'Keine Verbindung zum Server.',
// ...any text you leave out stays in English
);
// Or per call, e.g. from your AppLocalizations:
Text(e.userMessageWith(myMessagesFrom(context)));
If your backend's error texts aren't meant for end users, pass preferServerMessage: false.
An authenticated request made without a token fails immediately with a DioException whose error is a MissingAuthTokenException - it never reaches the network. Use skipAuth() for public endpoints.
Refreshing expired tokens
Give the provider a function that returns the new token:
// The explicit type is needed because the closure refers to `api` itself.
final ApiProvider api = ApiProvider(
baseUrl: 'https://api.example.com/',
refreshTokenProvider: RefreshTokenProvider.from(() async {
final res = await api.post<Map<String, dynamic>>(
'auth/refresh',
data: {'refresh': await loadRefreshToken()},
options: skipAuth(), // the refresh call is public
);
return res.data?['accessToken'] as String?;
}),
onUnauthorized: () => router.go('/login'), // refresh failed: the session is over
);
(Prefer a class? extends RefreshTokenProvider and override onRefresh() works too.)
When a request gets a 401:
onRefresh()runs once, however many requests failed at the same time. Requests started meanwhile wait for it.- The new token is stored (and persisted if the login was persisted).
- Failed requests are replayed with the new token - same method, body, query and cancel token.
- If
onRefresh()returnsnullor throws, the token is cleared,onUnauthorizedis called and the original401is thrown.
If your backend uses a different status (e.g. 419), set refreshOnStatusCodes: {401, 419}.
Forgot skipAuth() on the refresh call? It fails with the 401 instead of hanging your app.
Reacting to sign-in and sign-out
authTokenListenable changes on sign-in, refresh, restore and sign-out:
// go_router: re-run redirects whenever the session changes.
GoRouter(
refreshListenable: api.authTokenListenable,
redirect: (context, state) => api.isAuthenticated ? null : '/login',
routes: [...],
);
// Or in a widget:
ValueListenableBuilder<String?>(
valueListenable: api.authTokenListenable,
builder: (context, token, _) => token == null ? const LoginButton() : const ProfileButton(),
);
Not a Bearer token?
ApiProvider(baseUrl: ..., authScheme: 'Token'); // Authorization: Token abc
ApiProvider(baseUrl: ..., authHeader: 'X-Api-Key', authScheme: ''); // X-Api-Key: abc
Organising your API calls
Subclass BaseProvider per feature and share the client (and token) of one ApiProvider:
class UsersProvider extends BaseProvider {
UsersProvider(ApiProvider api) : super(httpClient: api.baseApiClient, name: 'Users');
Future<User> me() async {
final res = await get<Map<String, dynamic>>('users/me');
return User.fromJson(res.data!);
}
}
Options
ApiProvider(
baseUrl: 'https://api.example.com/',
headers: {'X-App-Version': '1.4.0'}, // sent with every request
connectTimeout: const Duration(seconds: 15), // defaults shown
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
enableLogging: true, // default: only in debug builds
interceptors: [MyInterceptor()], // your own Dio interceptors
tokenStorage: MyTokenStorage(), // default: flutter_secure_storage
dio: myDio, // bring your own Dio (its timeouts are kept)
);
Your interceptors run after the auth interceptor, so they see the auth header.
Logging prints URLs, status codes and bodies - never request headers, so the bearer token stays out of your logs.
Need something this wrapper doesn't cover? api.dio is the underlying Dio.
Testing your app
Use MemoryTokenStorage so tests don't touch the keychain, and swap the HTTP adapter so they don't touch the network.
Everything you need is exported by package:api_provider/api_provider.dart:
class FakeAdapter implements HttpClientAdapter {
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? body, Future<void>? cancel) async =>
ResponseBody.fromString('{"name": "Ada"}', 200, headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
});
@override
void close({bool force = false}) {}
}
final api = ApiProvider(baseUrl: 'https://test/', tokenStorage: MemoryTokenStorage(), enableLogging: false);
api.dio.httpClientAdapter = FakeAdapter();
Using the default secure storage in a widget test? Call FlutterSecureStorage.setMockInitialValues({}) in setUp.
See example/test/widget_test.dart for a full widget test.
Migrating from 1.x
RefreshTokenProvider.onRefresh()must now beFuture<String?>and return the new token.ApiProvidertoken methods (persistToken,hasAuthToken,deleteAuthToken,updateAuthToken) are unchanged.- Default timeouts changed to 15 s connect / 30 s receive.
- Users stay signed in: tokens saved by 1.x are read and migrated (this is why
flutter_secure_storageis pinned to 10.x). - No more native plugin code; nothing to do on your side.
See the CHANGELOG for the full list.
Using an AI coding assistant?
The package ships an llms.txt: a compact, AI-readable guide to the API and its common mistakes.
Point your assistant at it (e.g. "read llms.txt in the api_provider package, then add login and token refresh to my app").
Contributing
Issues and pull requests are welcome at github.com/iamjunaidkhokhar/api_provider.
Run flutter analyze && flutter test (and the same in example/) before sending a PR.
License
See LICENSE.
Libraries
- api_provider
- Dio-based API client with bearer-token auth, refresh-and-retry and secure token storage.