mk_graphql 1.2.1-dev.0
mk_graphql: ^1.2.1-dev.0 copied to clipboard
A clean, modern, simplified GraphQL client for Dart & Flutter with normalized caching, composable links, and stream-based operation execution.
mk_graphql #
A clean, modern, simplified GraphQL client for Dart and Flutter with normalized caching, composable links, typed operation execution, file uploads, SSE, WebSockets, automatic pagination, refetch, and granular validation error handling.
π Full documentation β client API, link pipeline, caching internals, and pagination guides.
β¨ Features #
- π Composable Link Architecture: Chain
AuthLink,DioLink,SseLink,WebSocketLink,HttpLink,BatchLink, andTypenameLink. - π First-Class Authentication: Automatic 401 handling, thread-safe asynchronous token refreshing, token queueing, and revocation hooks.
- π‘ Multiple Transports:
- HTTP/REST: Built-in
DioLinkand standardHttpLink. - Server-Sent Events (SSE):
SseLinkwith live subscription streaming, dynamic auth headers, and 401 refresh retries. - WebSockets:
WebSocketLinksupporting modern subscriptions.
- HTTP/REST: Built-in
- π Multipart File Uploads: Seamless GraphQL Multipart Request specification compliance for
Upload/MultipartFile. - π Request Cancellation: Direct integration with Dio's
CancelToken. - πΎ Normalized Caching: Fast in-memory cache with canonical JSON key ordering, optimistic responses, and configurable fetch policies (
cacheFirst,networkOnly,cacheAndNetwork,cacheOnly,noCache). - β‘ Stream & Future APIs: Single-shot
client.future()or reactiveclient.stream(). - π Direct Refetching:
client.refetch(req)andreq.refetch(client)with immediate cache synchronization. - π Automatic Pagination (Zero Manual Append):
client.fetchMoreandreq.fetchMoreautomatically merge lists and Relay connections;GraphQLPaginatormanages reactive infinite scroll. - π‘οΈ Validation Errors: Specialized
ValidationExceptionextracting field-level validation messages across Apollo, Laravel/Lighthouse, NestJS, and Spring DGS. - πͺ΅ Built-in Logging: Pretty console logging with custom options and privacy masking.
π¦ Installation #
Add mk_graphql to your pubspec.yaml:
dependencies:
mk_graphql: ^1.1.0
Then run:
flutter pub get
π Step-by-Step Integration #
1. Initialize GraphQLClient #
import 'package:mk_graphql/mk_graphql.dart';
final client = GraphQLClient(
url: 'https://api.example.com/graphql',
// Optional: SSE or WebSocket URL for subscriptions
sseUrl: 'https://api.example.com/graphql/sse',
// wsUrl: 'wss://api.example.com/graphql',
// The client persists tokens itself (via flutter_secure_storage) and
// attaches the Authorization header to every request automatically.
// Just tell it how to perform a refresh β no header/storage plumbing needed.
refreshCallback: (refreshClient, refreshToken) async {
if (refreshToken == null) return null; // nothing to refresh with β revoke
final refreshed = await refreshClient.future(RefreshTokenRequest(refreshToken));
return AuthToken(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
);
},
// Default caching policy: cacheFirst, networkOnly, cacheAndNetwork, cacheOnly, noCache
defaultFetchPolicy: FetchPolicy.cacheFirst,
// Default error policy: none (throw if errors), ignore (return partial data), all (return data with errors)
defaultErrorPolicy: ErrorPolicy.none,
);
// After a successful login mutation, persist the tokens the client will use
// for every subsequent request:
await client.setAuthToken(
AuthToken(accessToken: loginResult.accessToken, refreshToken: loginResult.refreshToken),
);
// On logout:
await client.clearAuthToken();
// Listen for auth state changes anywhere in the app (login, refresh, logout,
// or a revoked refresh) β e.g. to redirect to the login screen:
client.onAuthTokenChange.listen((token) {
if (token == null) router.go('/login');
});
refreshCallback is the single source of truth for refresh β it receives the currently stored refresh token (always non-null: with nothing stored yet, the client revokes immediately without calling it) and a plain GraphQLClient (no auth link attached) to run the refresh request via its normal typed API. Return the new AuthToken to persist, or null/throw to revoke β AuthLink.registerRevokeTokenListener(...) fires either way so you can navigate to a login screen.
However many requests fail at once, only one refreshCallback call is made β AuthLink queues the rest behind the in-flight refresh and retries all of them once it resolves.
If you'd rather supply the header yourself (static/custom, not from tokenStorage), pass tokenHeader β it overrides the header side only and can be combined with refreshCallback.
2. Execute Queries & Mutations #
client.future(req) executes the operation once, handles caching according to fetchPolicy, and returns typed TData directly (or throws a typed GraphQLException):
try {
// Using a generated or custom GraphQLRequest
final data = await client.future(getUserReq);
print('User: ${data.user.name}');
} on ValidationException catch (e) {
// Field-specific validation failures
print('Field errors: ${e.validationErrors}');
if (e.hasError('email')) {
print('Email error: ${e.getFirstError('email')}');
}
} on UnAuthorizedException {
// Session expired or unauthenticated
navigateToLogin();
} on GraphQLException catch (e) {
// Network, server, or execution error
print('GraphQL Error: ${e.message}');
}
Or execute directly on the request object:
final data = await getUserReq.execute(client);
3. Reactive Streams & Cache Watching #
Watch cache updates reactively:
// Stream network emissions according to FetchPolicy (e.g. cacheAndNetwork emits cache then network)
final subscription = client.stream(getUserReq).listen((data) {
print('Received data: ${data.user.name}');
});
// Or watch normalized cache directly whenever any operation updates it
client.watchQuery(getUserReq).listen((cachedData) {
print('Cache updated: ${cachedData?.user.name}');
});
4. Refetching Queries #
Bypass cache with FetchPolicy.networkOnly, retrieve fresh network data, and update the cache in-place:
// Using client:
final freshData = await client.refetch(getUserReq);
// Using request extension:
final freshData2 = await getUserReq.refetch(client);
5. Automatic Pagination (Zero Manual Append Required) #
You do not need to manually concatenate lists ([...previous, ...incoming]).
Option A: client.fetchMore / req.fetchMore
Automatically merges response JSON trees (concatenating list fields and merging Relay connections) and updates the cache under the initial request:
// Fetch next page and merge automatically:
final mergedData = await client.fetchMore(
originalRequest: page1Req,
nextRequest: page2Req,
);
// Or via request extension:
final mergedData2 = await page1Req.fetchMore(client, nextRequest: page2Req);
// Active listeners on page1Req automatically emit the accumulated data!
Option B: GraphQLPaginator
For infinite scroll lists with reactive state management:
// 1. Create a paginator (page-number, offset/limit, or Relay cursor):
final paginator = client.paginatePage(
initialRequest: GetAnimePageReq(
variables: const GetAnimePageVars(page: 1, perPage: 20),
),
getItems: (data) => data.page?.media ?? [],
updatePage: (vars, nextPage) => vars.copyWith(page: nextPage),
pageSize: 20,
getPage: (data) => data.page?.pageInfo?.currentPage,
hasNextPage: (data) => data.page?.pageInfo?.hasNextPage ?? false,
);
// 2. Load initial page (returns List<TItem> directly)
final firstPageItems = await paginator.loadInitial();
// 3. Load next page (automatically accumulates and returns complete list)
final accumulatedItems = await paginator.loadNext();
// 4. Dynamically apply search queries or filters (restarts page 1 without wiping other criteria):
paginator.applyVariables((currentVars) => currentVars.copyWith(
search: 'Naruto',
genre: 'Action',
));
// 5. Pull-to-refresh (resets query back to initialRequest):
await paginator.refetch();
// 6. Reactive State Stream:
paginator.stream.listen((state) {
print('Items: ${state.items.length}, LoadingMore: ${state.isLoadingMore}');
});
Flutter BLoC Integration (emit.forEach):
Pipe paginator.stream directly into BLoC state without manual subscriptions:
class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
final GraphQLPaginator _paginator;
AnimeListBloc(this._paginator) : super(const AnimeListState()) {
on<AnimeListStarted>((event, emit) async {
await emit.forEach<PaginationState<MediaPartialModel>>(
_paginator.stream,
onData: (pState) => state.copyWith(
items: pState.items,
isLoading: pState.isLoading,
isLoadingMore: pState.isLoadingMore,
hasMore: pState.hasMore,
error: pState.error,
),
);
});
on<SearchChanged>((event, emit) {
_paginator.applyVariables((v) => v.copyWith(search: event.query));
});
on<FetchNextPage>((event, emit) => _paginator.loadNext());
on<Refreshed>((event, emit) => _paginator.refetch());
}
}
6. Subscriptions (SSE or WebSockets) #
final client = GraphQLClient(
url: 'https://api.example.com/graphql',
sseUrl: 'https://api.example.com/graphql/sse',
tokenHeader: () async => {'Authorization': 'Bearer $token'},
);
client.stream(mySubscriptionRequest).listen((data) {
print('Real-time event: $data');
});
7. Multipart File Uploads #
Conforms to the standard GraphQL Multipart Request specification:
import 'package:dio/dio.dart';
import 'package:mk_graphql/mk_graphql.dart';
final file = MultipartFile.fromFileSync(
'/path/to/avatar.png',
filename: 'avatar.png',
);
final uploadReq = UploadAvatarReq(
variables: UploadAvatarVars(file: file),
);
final result = await client.future(uploadReq);
8. Request Cancellation with CancelToken #
final cancelToken = CancelToken();
// Cancel request when user navigates away
void onDispose() {
cancelToken.cancel('User navigated away');
}
try {
final data = await client.future(myRequest, cancelToken: cancelToken);
} on RequestCancelledException catch (e) {
print('Request cancelled: ${e.message}');
}
π Full Documentation #
- Client, Requests & ErrorPolicy
- Normalized Cache & Optimistic UI
- Link Pipeline (Auth, SSE, WS, Uploads)
- Pagination Guide
- Code Generator (
mk_graphql_generator)
π License #
This project is licensed under the MIT License - see the LICENSE file for details.