Intégrations tierces

Avec GraphQL

Envelopper les clients GraphQL graphql ou ferry dans des fetchers Qora pour une gestion fluide des requêtes avec stale-while-revalidate, invalidation de cache et pagination.

Les APIs GraphQL bénéficient du pattern stale-while-revalidate de Qora, de l'invalidation automatique et de l'intégration avec le cache normalisé. Ce guide couvre l'encapsulation des packages graphql et ferry dans des fetchers Qora, la gestion d'erreurs et la pagination par curseur.


Configuration

pubspec.yaml
dependencies:
  qora: ^1.3.1
  gql: ^1.0.0            # pour ferry: gql_http_link, ferry, ferry_cache

Option A: Package graphql

Le package graphql fournit un GraphQLClient qui exécute les requêtes et mutations. Enveloppez chaque opération dans un fetcher Qora:

lib/data/graphql_client.dart
import 'package:graphql/client.dart';
import 'package:qora/qora.dart';

final gqlClient = GraphQLClient(
  link: HttpLink('https://api.example.com/graphql'),
  cache: GraphQLCache(),
);

final queryOptions = QueryOptions(
  document: gql(r'''
    query GetUser($id: ID!) {
      user(id: $id) {
        id
        name
        email
      }
    }
  '''),
);
lib/data/user_api.dart
class UserApi {
  UserApi(this._client);
  final GraphQLClient _client;

  Future<User> getUser(String id) async {
    final result = await _client.query(
      QueryOptions(
        document: gql(r'''
          query GetUser($id: ID!) {
            user(id: $id) { id name email }
          }
        '''),
        variables: {'id': id},
      ),
    );

    if (result.hasException) throw result.exception!;
    return User.fromJson(result.data!['user']);
  }
}
lib/features/user_screen.dart
QoraBuilder<User>(
  queryKey: ['user', userId],
  fetcher: () => userApi.getUser(userId),
  builder: (context, state, _) => switch (state) {
    Success(:final data) => UserCard(data),
    Loading() => const CircularProgressIndicator(),
    Failure(:final error) => ErrorView(error),
    _ => const SizedBox.shrink(),
  },
)

Exemple de Mutation

lib/data/user_api.dart
Future<User> updateUser(String id, String name) async {
  final result = await _client.mutate(
    MutationOptions(
      document: gql(r'''
        mutation UpdateUser($id: ID!, $name: String!) {
          updateUser(id: $id, name: $name) { id name email }
        }
      '''),
      variables: {'id': id, 'name': name},
    ),
  );

  if (result.hasException) throw result.exception!;
  return User.fromJson(result.data!['updateUser']);
}
lib/features/user_edit_screen.dart
QoraMutationBuilder<User, String, void>(
  mutator: (name) => userApi.updateUser(userId, name),
  options: MutationOptions(
    invalidates: [['user', userId]],
  ),
  builder: (context, state, mutate) { ... },
)

Option B: Package Ferry

Ferry fournit un client GraphQL typé et généré par code avec un cache normalisé intégré. Utilisez Qora comme couche de cache pour l'état de vue (réactivité UI, invalidation, SWR) tandis que Ferry gère le transport GraphQL et le stockage normalisé des entités:

lib/data/ferry_client.dart
import 'package:ferry/ferry.dart';
import 'package:gql_http_link/gql_http_link.dart';
import 'package:qora/qora.dart';

final ferryClient = Client(
  link: HttpLink('https://api.example.com/graphql'),
  cache: Cache(),
);

Fetcher de Requête

lib/data/user_api.dart
class UserApi {
  UserApi(this._client);
  final Client _client;

  Future<User> getUser(String id) async {
    final req = GGetUserReq((b) => b..vars.id = id);
    final result = await _client.request(req).first;

    if (result.hasErrors) {
      throw result.graphqlErrors!.first;
    }

    return User.fromFragment(result.data!.user);
  }

  Future<List<User>> listUsers({int first = 20, String? cursor}) async {
    final req = GListUsersReq((b) => b
      ..vars.first = first
      ..vars.after = cursor);
    final result = await _client.request(req).first;

    if (result.hasErrors) throw result.graphqlErrors!.first;

    return result.data!.users
        .map((u) => User.fromFragment(u))
        .toList();
  }
}
lib/features/user_list_screen.dart
QoraBuilder<List<User>>(
  queryKey: ['users', first, cursor],
  fetcher: () => userApi.listUsers(first: first, cursor: cursor),
  options: const QoraOptions(staleTime: Duration(minutes: 2)),
  builder: (context, state, _) { ... },
)

Mutation avec Mise à Jour Optimiste

lib/data/user_api.dart
Future<User> updateUserName(String id, String name) async {
  final req = GUpdateUserNameReq((b) => b
    ..vars.id = id
    ..vars.name = name);

  final result = await _client.request(req).first;

  if (result.hasErrors) throw result.graphqlErrors!.first;
  return User.fromFragment(result.data!.updateUserName);
}
lib/features/user_edit_screen.dart
QoraMutationBuilder<User, String, User?>(
  mutator: (name) => userApi.updateUserName(userId, name),
  options: MutationOptions(
    onMutate: (name) {
      // Optimiste: appliquer le nouveau nom immédiatement à l'utilisateur en cache.
      final previous = context.qora.getQueryData<User>(['user', userId]);
      context.qora.setQueryData(['user', userId],
          previous?.copyWith(name: name) ?? User(name: name));
      return previous;
    },
    onError: (error, variables, previous) {
      // Rollback en cas d'échec.
      if (previous != null) {
        context.qora.restoreQueryData(['user', userId], previous);
      }
    },
  ),
  builder: (context, state, mutate) { ... },
)

Interaction avec le Cache Normalisé

Qora ne remplace pas le cache normalisé de Ferry ou Apollo: il vit au-dessus. Utilisez le cache basé sur les clés de Qora pour l'état UI (les données sont-elles fraîches? dois-je refetcher au montage?), tandis que le cache GraphQL gère le stockage normalisé et l'identité des entités.

Stratégie

CoucheResponsabilité
Cache QoraSWR, invalidation, retry, polling, gestion de la péremption
Cache GraphQL (Ferry/Apollo)Entités normalisées, identité des données, déduplication des objets imbriqués

Un pattern courant: après qu'une mutation écrit dans le cache GraphQL, invalidez la clé Qora correspondante pour que le widget refetch:

lib/features/user_edit_screen.dart
onSuccess: () {
  // Le cache GraphQL est déjà mis à jour. Invalider Qora
  // pour que l'arbre de widgets récupère les données fraîches au prochain rebuild.
  context.qora.invalidate(['user', userId]);
},

Cache en écriture directe avec Ferry

Cache.writeFragment de Ferry peut mettre à jour les entités normalisées immédiatement. Combinez avec setQueryData de Qora pour une mise à jour UI instantanée:

lib/data/user_api.dart
void optimisticUpdate(String userId, String name) {
  // Écrire dans le cache normalisé de Ferry.
  _client.cache.writeFragment(
    GUserFragmentReq((b) => b..idFields = {'id': userId}).createRequest(),
    {'id': userId, 'name': name, '__typename': 'User'},
  );
}

Gestion d'Erreurs

Les erreurs GraphQL viennent sous deux formes: exceptions au niveau transport et objets GraphQLError dans le corps de la réponse. Mappez les deux vers des erreurs domaine:

lib/data/graphql_error_mapper.dart
import 'package:graphql/client.dart';
import 'package:qora/qora.dart';

/// `errorMapper` doit retourner une `QoraException`, d'où l'héritage.
class GraphQLAppError extends QoraException {
  final bool isNetwork;
  const GraphQLAppError(super.message, {this.isNetwork = false, super.originalError});
}

QoraException mapGraphQLError(Object error, StackTrace? stackTrace) {
  if (error is OperationException) {
    if (error.graphqlErrors.isNotEmpty) {
      final messages = error.graphqlErrors.map((e) => e.message).join(', ');
      return GraphQLAppError(messages, originalError: error);
    }
    if (error.linkException != null) {
      return GraphQLAppError(
        error.linkException.toString(),
        isNetwork: true,
        originalError: error,
      );
    }
  }
  if (error is QoraException) return error;
  return QoraException(error.toString(), originalError: error, stackTrace: stackTrace);
}

Enregistrez avec l'errorMapper global de Qora:

lib/core/qora_setup.dart
final client = QoraClient(
  config: QoraClientConfig(
    errorMapper: mapGraphQLError,
  ),
);

Pagination par Curseur

Utilisez le support de pagination de Qora avec les connexions par curseur:

lib/data/user_api.dart
Future<Connection<User>> listUsers({int first = 20, String? after}) async {
  final req = GListUsersReq((b) => b
    ..vars.first = first
    ..vars.after = after);
  final result = await _client.request(req).first;

  if (result.hasErrors) throw result.graphqlErrors!.first;

  final users = result.data!.users
      .map((u) => User.fromFragment(u))
      .toList();
  final pageInfo = result.data!.usersConnection.pageInfo;

  return Connection(
    nodes: users,
    hasNextPage: pageInfo.hasNextPage,
    endCursor: pageInfo.endCursor,
  );
}
lib/features/user_list_screen.dart
class UserListScreen extends StatefulWidget { ... }

class _UserListScreenState extends State<UserListScreen> {
  String? _cursor;

  @override
  Widget build(BuildContext context) {
    return QoraBuilder<Connection<User>>(
      queryKey: ['users', 20, _cursor],
      fetcher: () => userApi.listUsers(after: _cursor),
      keepPreviousData: true,
      builder: (context, state, _) {
        final users = state.dataOrNull?.nodes ?? [];
        return ListView.builder(
          itemCount: users.length + 1,
          itemBuilder: (_, i) {
            if (i == users.length) {
              return TextButton(
                onPressed: () => setState(() =>
                    _cursor = state.dataOrNull?.endCursor),
                child: const Text('Charger plus'),
              );
            }
            return UserTile(users[i]);
          },
        );
      },
    );
  }
}

Tester les Fetchers GraphQL

Mockez le lien de transport du client GraphQL pour éviter les appels réseau réels:

test/data/user_api_test.dart
import 'package:graphql/client.dart';
import 'package:qora/qora.dart';
import 'package:test/test.dart';

void main() {
  late GraphQLClient gqlClient;
  late QoraClient client;

  setUp(() {
    gqlClient = GraphQLClient(
      link: _MockLink(), // implémente Link
      cache: GraphQLCache(),
    );
    client = QoraClient();
  });

  test('le fetcher retourne un User parsé en cas de succès', () async {
    final api = UserApi(gqlClient);
    final result = await client.fetchQuery<User>(
      key: ['user', '1'],
      fetcher: () => api.getUser('1'),
    );

    expect(result.name, 'Test User');
  });
}

Étapes Suivantes

  • Mutations: Opérations d'écriture avec mises à jour optimistes et rollback automatique
  • Gestion d'Erreurs: errorMapper global et transformError par requête
  • Requêtes Infinies: Pagination bi-directionnelle avec InfiniteQueryObserver
  • Avec Dio: Contrepartie REST pour les équipes utilisant REST et GraphQL
Copyright © 2026