mk_graphql

pub package license

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, and TypenameLink.
  • πŸ” First-Class Authentication: Automatic 401 handling, thread-safe asynchronous token refreshing, token queueing, and revocation hooks.
  • πŸ“‘ Multiple Transports:
    • HTTP/REST: Built-in DioLink and standard HttpLink.
    • Server-Sent Events (SSE): SseLink with live subscription streaming, dynamic auth headers, and 401 refresh retries.
    • WebSockets: WebSocketLink supporting modern subscriptions.
  • πŸ“ 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 reactive client.stream().
  • πŸ”„ Direct Refetching: client.refetch(req) and req.refetch(client) with immediate cache synchronization.
  • πŸ“‘ Automatic Pagination (Zero Manual Append): client.fetchMore and req.fetchMore automatically merge lists and Relay connections; GraphQLPaginator manages reactive infinite scroll.
  • πŸ›‘οΈ Validation Errors: Specialized ValidationException extracting 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',

  // Attach dynamic authentication token to every outgoing request
  tokenHeader: () async {
    final token = await getStoredToken();
    return token != null ? {'Authorization': 'Bearer $token'} : {};
  },

  // Called automatically on 401 responses to refresh token and retry
  onTokenRefresh: (dioLink) async {
    final newToken = await refreshUserToken();
    return newToken != null ? {'Authorization': 'Bearer $newToken'} : null;
  },

  // 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,
);

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


πŸ“„ License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries

mk_graphql