Avec GraphQL
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
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:
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
}
}
'''),
);
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']);
}
}
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
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']);
}
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:
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
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();
}
}
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
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);
}
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
| Couche | Responsabilité |
|---|---|
| Cache Qora | SWR, 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:
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:
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:
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:
final client = QoraClient(
config: QoraClientConfig(
errorMapper: mapGraphQLError,
),
);
Pagination par Curseur
Utilisez le support de pagination de Qora avec les connexions par curseur:
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,
);
}
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:
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:
errorMapperglobal ettransformErrorpar requête - Requêtes Infinies: Pagination bi-directionnelle avec
InfiniteQueryObserver - Avec Dio: Contrepartie REST pour les équipes utilisant REST et GraphQL