backend / graphql
Resolvers : implémenter la logique serveur
Explication
Ce que vous allez apprendre
- Comprendre qu'un resolver est simplement une fonction associée à un champ du schéma
- Lire la signature universelle d'un resolver :
(parent, args, context, info) - Comprendre pourquoi un resolver de champ n'est appelé que si le client le demande
- Écrire des resolvers asynchrones pour les opérations d'entrée/sortie (base de données, réseau)
- Utiliser le
contextpour transporter l'utilisateur authentifié et les connexions partagées
Dans quel contexte ?
Après avoir défini un schéma GraphQL (leçon 2) et vu comment le client l'interroge (leçons 3 et 4), il manque encore la pièce la plus importante côté serveur : le code qui va réellement chercher les données. Sans resolver, un champ du schéma n'est qu'une déclaration vide. Un développeur backend qui reçoit une requête produit(id: "42") { nom, categorie { nom } } doit écrire une fonction pour produit (qui va chercher un produit en base) et une autre pour categorie (qui va chercher la catégorie associée).
D'abord, l'idée la plus simple : un champ égale une fonction
Chaque champ d'un type, y compris les champs de Query et Mutation, peut avoir sa propre fonction de résolution. En Strawberry (Python), on associe un resolver à un champ avec un simple décorateur (@strawberry.field) posé sur une méthode ; en Apollo Server (JavaScript), on remplit un objet resolvers structuré par type et par champ.
Une fois ce principe posé, il faut comprendre la signature universelle
Peu importe le langage, un resolver reçoit toujours quatre informations : le parent (le résultat déjà résolu du niveau au-dessus — par exemple l'objet Produit quand on résout son champ categorie), les args (les arguments passés au champ dans la requête), le context (partagé par tous les resolvers d'une même requête HTTP) et l'info (métadonnées sur l'exécution en cours, plus rarement utilisée).
Un point qui surprend souvent au début
Un resolver de champ n'est exécuté QUE si le client demande explicitement ce champ dans sa requête. Si la requête ne sélectionne pas categorie, le resolver de categorie n'est jamais appelé — ce qui est très différent d'une API REST, où l'endpoint renvoie systématiquement tous les champs de la ressource, utilisés ou non.
| Élément de la signature | Rôle | Exemple |
|---|---|---|
parent | Résultat du niveau parent, déjà résolu | L'objet Produit pour résoudre son champ categorie |
args | Arguments du champ dans la requête | { limite: 20 } pour produits(limite: 20) |
context | Partagé entre tous les resolvers d'une requête | Utilisateur authentifié, connexion DB, DataLoaders |
info | Métadonnées d'exécution | Chemin du champ, AST de la requête |
Ensuite, une question pratique : synchrone ou asynchrone ?
Dès qu'un resolver fait une opération d'entrée/sortie — une requête base de données, un appel HTTP vers un autre service — il doit être asynchrone (async def en Python, fonction async en JavaScript). Un resolver synchrone qui bloque sur une opération lente bloque tout le traitement de la requête GraphQL en cours, voire d'autres requêtes selon le serveur utilisé.
Prérequis
Il faut être à l'aise avec la notion de fonction asynchrone (async/await) dans le langage choisi (Python ou JavaScript) : la quasi-totalité des resolvers réels en dépendent dès qu'ils touchent une base de données.
Il reste un dernier point essentiel : où mettre l'utilisateur authentifié ?
Le context, créé une fois par requête HTTP entrante, est l'endroit standard pour transporter l'utilisateur courant, la session de base de données et, comme tu le verras à la leçon sur le problème N+1, les DataLoaders. Un resolver de mutation vérifie typiquement context.utilisateur avant d'autoriser une écriture.
Piège fréquent
Écrire un resolver de champ imbriqué (comme categorie sur Produit) qui fait une requête base de données individuelle, sans réfléchir au nombre de fois où il sera appelé sur une liste de résultats, mène directement au problème dit du "N+1" : une leçon entière lui est dédiée un peu plus loin dans ce cours.
Maintenant que tu sais où vit la logique serveur, la prochaine leçon détaille comment les arguments et les variables circulent jusqu'à ces resolvers, y compris sur des champs qui ne sont pas à la racine du schéma.
Commandes & code
Resolvers : implémenter la logique serveur
# Exemple avec Strawberry (Python, s'intègre bien à FastAPI)
import strawberry
from typing import Optional
@strawberry.type
class Produit:
id: strawberry.ID
nom: str
prix: float
@strawberry.type
class Query:
# Un resolver est simplement une fonction Python associée à un champ du schéma
@strawberry.field
def produit(self, id: strawberry.ID) -> Optional[Produit]:
donnees = base_de_donnees.trouver_produit(id)
if donnees is None:
return None
return Produit(id=donnees.id, nom=donnees.nom, prix=donnees.prix)
@strawberry.field
def produits(self, limite: int = 20) -> list[Produit]:
return [
Produit(id=p.id, nom=p.nom, prix=p.prix)
for p in base_de_donnees.lister_produits(limite=limite)
]
schema = strawberry.Schema(query=Query)# Resolver de champ imbriqué : chaque champ d'un type peut avoir son propre resolver
@strawberry.type
class Produit:
id: strawberry.ID
nom: str
categorie_id: strawberry.Private[str] # champ interne, pas exposé dans le schéma
# Ce resolver n'est appelé QUE si le client demande explicitement le champ "categorie"
@strawberry.field
def categorie(self) -> "Categorie":
return base_de_donnees.trouver_categorie(self.categorie_id)
# Resolver asynchrone : indispensable pour les I/O (DB, appels HTTP) sans bloquer
@strawberry.field
async def avis(self) -> list["Avis"]:
return await base_de_donnees.lister_avis_async(self.id)// Équivalent avec Apollo Server (Node.js) : un objet resolvers structuré par type
const resolvers = {
Query: {
produit: async (_parent, { id }, context) => {
// context contient typiquement l'utilisateur authentifié, les dataloaders, la DB
return context.dataSources.produits.trouverParId(id);
},
produits: async (_parent, { limite = 20 }, context) => {
return context.dataSources.produits.lister(limite);
},
},
Produit: {
// resolver de champ : "parent" est l'objet Produit déjà résolu par Query.produit
categorie: async (parent, _args, context) => {
return context.dataSources.categories.trouverParId(parent.categorieId);
},
},
Mutation: {
creerProduit: async (_parent, { input }, context) => {
if (!context.utilisateur) {
throw new GraphQLError("Authentification requise", {
extensions: { code: "UNAUTHENTICATED" },
});
}
return context.dataSources.produits.creer(input);
},
},
};Résumé
- La signature universelle d'un resolver est
(parent, args, context, info): parent est le résultat du niveau au-dessus. - Un resolver n'est exécuté QUE si le champ correspondant est présent dans la requête du client.
- Les resolvers d'I/O (DB, appels réseau) doivent être asynchrones pour ne pas bloquer le serveur.
- Le
contextest le lieu standard pour transporter l'utilisateur authentifié, les dataloaders et les connexions DB.
Exercices pratiques
Mission : diagnostiquer un resolver de mutation qui bloque le serveur
Objectif : Corriger un resolver synchrone bloquant en une version asynchrone correcte, et raisonner sur le rôle exact de chaque paramètre de la signature d'un resolver.
Contexte
Le resolver avis sur Produit a été écrit en synchrone par un développeur pressé : il appelle directement base_de_donnees.lister_avis(self.id) sans await. Sous faible charge, personne ne remarque rien. Le jour où le trafic augmente, chaque requête sur ce champ bloque le traitement d'autres requêtes en cours sur le même serveur.