Construisez une API GraphQL scalable avec Apollo Server et Node.js, intégrez Apollo Client dans Angular : requêtes déclaratives, mutations, caching, DataLoader, authentification JWT.
1. REST vs GraphQL : concepts fondamentaux
REST (Representational State Transfer) est un modèle d'API basé sur des ressources et des endpoints fixés.
Chaque endpoint retourne une structure prédéfinie : GET /users/123 retourne TOUTES les données utilisateur, que vous les utilisiez ou non.
Cela crée deux problèmes majeurs :
- Over-fetching : le client reçoit plus de données que nécessaire
- Under-fetching : le client doit faire plusieurs requêtes pour obtenir toutes les données liées
GraphQL offre une alternative : le client déclare EXACTEMENT quelles données il veut, et le serveur retourne exactement ça. Une seule requête peut remplacer N requêtes REST.
REST:
GET /users/123 → { id, name, email, phone, address, createdAt, ... } (70 champs inutiles)GraphQL:
query { user(id: 123) { id, name, email } } → { id, name, email } (exactement ce qu'il faut)
Avantages GraphQL :
- ✅ Requêtes déclaratives et optimisées
- ✅ Une endpoint unique au lieu de N
- ✅ Schéma auto-documenté (introspection)
- ✅ Moins de charge réseau
- ✅ Évolution API sans breaking changes
Quand utiliser GraphQL vs REST :
- ✅ GraphQL : données complexes, relations variées, clients divers (web, mobile, desktop)
- ✅ REST : CRUD simple, microservices découplés, équipes en silos
2. Installation Apollo Server
Créer un serveur Apollo GraphQL avec Express :
# Créer le projet
mkdir graphql-api && cd graphql-api
npm init -y
# Installer dépendances
npm install apollo-server express @apollo/server graphql cors dotenv
npm install --save-dev nodemon @types/node
Créer server.js :
// server.js - Serveur Apollo + Express minimal
const { ApolloServer } = require('@apollo/server');
const { startStandaloneServer } = require('@apollo/server/standalone');
const express = require('express');
const cors = require('cors');
require('dotenv').config();
// 1️⃣ DÉFINIR LE SCHÉMA GraphQL
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
# Récupérer un utilisateur par ID
user(id: ID!): User
# Lister tous les utilisateurs
users: [User!]!
# Rechercher des posts
posts(limit: Int = 10): [Post!]!
}
type Mutation {
# Créer un utilisateur
createUser(name: String!, email: String!): User!
# Créer un post
createPost(title: String!, content: String!, authorId: ID!): Post!
}
`;
// 2️⃣ DONNÉES (simulées - remplacer par BD en prod)
const users = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
];
const posts = [
{ id: '1', title: 'GraphQL Guide', content: 'Article sur GraphQL...', authorId: '1' },
{ id: '2', title: 'Node.js Best Practices', content: 'Bonnes pratiques Node.js...', authorId: '2' },
];
// 3️⃣ RÉSOLVEURS (implémentation des requêtes/mutations)
const resolvers = {
Query: {
// Récupérer un utilisateur
user: (parent, args) => {
return users.find((user) => user.id === args.id);
},
// Lister tous les utilisateurs
users: () => {
return users;
},
// Récupérer les posts avec pagination
posts: (parent, args) => {
return posts.slice(0, args.limit);
},
},
Mutation: {
// Créer un utilisateur
createUser: (parent, args) => {
const newUser = {
id: String(users.length + 1),
name: args.name,
email: args.email,
};
users.push(newUser);
return newUser;
},
// Créer un post
createPost: (parent, args) => {
const newPost = {
id: String(posts.length + 1),
title: args.title,
content: args.content,
authorId: args.authorId,
};
posts.push(newPost);
return newPost;
},
},
// Résolveurs pour les relations (fields imbriquées)
User: {
posts: (parent) => {
return posts.filter((post) => post.authorId === parent.id);
},
},
Post: {
author: (parent) => {
return users.find((user) => user.id === parent.authorId);
},
},
};
// 4️⃣ CRÉER LE SERVEUR APOLLO
const server = new ApolloServer({
typeDefs,
resolvers,
});
// 5️⃣ DÉMARRER
async function startServer() {
const { url } = await startStandaloneServer(server, {
listen: { port: process.env.PORT || 4000 },
});
console.log(`🚀 Apollo Server lancé sur ${url}`);
}
startServer().catch(console.error);
Tester le serveur :
npm start
# Output: 🚀 Apollo Server lancé sur http://localhost:4000/
Accéder à http://localhost:4000 ouvre Apollo Sandbox (IDE GraphQL).
Vous pouvez écrire des requêtes GraphQL directement dans le navigateur !
3. Schéma GraphQL et types
Le schéma est le contrat entre client et serveur. Il définit les types de données, les requêtes disponibles et les mutations.
Syntaxe de base :
# Définir un type User
type User {
id: ID! # ! = obligatoire (non-null)
name: String! # Champ obligatoire
email: String!
age: Int # Optionnel (peut être null)
posts: [Post!]! # Liste de posts, liste obligatoire mais posts optionnels
createdAt: DateTime!
}
# Énumération
enum Role {
ADMIN
USER
GUEST
}
# Interface (contrats multiples)
interface Node {
id: ID!
createdAt: DateTime!
}
# Implémenter une interface
type Post implements Node {
id: ID!
title: String!
createdAt: DateTime!
}
# Requête racine
type Query {
# Requête simple
user(id: ID!): User
# Requête avec arguments multiples
posts(limit: Int = 10, offset: Int = 0, status: PostStatus): [Post!]!
# Requête avec variables (côté client)
search(query: String!): SearchResult
}
# Type union pour résultats polymorphes
union SearchResult = User | Post | Comment
# Mutation (modification de données)
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User
deleteUser(id: ID!): Boolean!
}
# Input type (pour requêtes complexes)
input CreateUserInput {
name: String!
email: String!
role: Role = USER
}
input UpdateUserInput {
name: String
email: String
}
# Subscription (temps réel)
type Subscription {
userCreated: User!
postUpdated(authorId: ID!): Post!
}
Meilleure pratique : Organiser les types dans des fichiers séparés
schema/
├── user.graphql # Types User, Query.user, Mutation.createUser
├── post.graphql # Types Post, mutations posts
├── comment.graphql # Types Comment
└── index.js # Combiner tous les schémas
4. Résolveurs et requêtes
Les résolveurs sont des fonctions qui retournent les données pour chaque field du schéma.
// resolvers/user.js - Résolveurs utilisateurs
const db = require('../database'); // Supposé = connexion BD
module.exports = {
Query: {
// Résolveur pour Query.user(id: ID!)
user: async (parent, args, context) => {
// parent = null pour les requêtes racines
// args = arguments passés (ex: { id: '123' })
// context = données partagées (user, db, etc.)
try {
const user = await db.User.findById(args.id);
return user;
} catch (error) {
throw new Error(`Utilisateur non trouvé: ${args.id}`);
}
},
// Résolveur pour Query.users
users: async (parent, args, context) => {
// Vérifier que l'utilisateur est authentifié (voir section auth)
if (!context.user) {
throw new Error('Non authentifié');
}
// Récupérer et filtrer
const users = await db.User.find();
return users;
},
// Résolveur avec filtrage
posts: async (parent, args, context) => {
let query = db.Post.find();
// Filtrer par statut si fourni
if (args.status) {
query = query.where('status').equals(args.status);
}
// Pagination
const posts = await query
.limit(args.limit)
.skip(args.offset)
.exec();
return posts;
},
},
Mutation: {
// Résolveur pour Mutation.createUser
createUser: async (parent, args, context) => {
// Validation côté serveur (IMPORTANT!)
if (!args.input.email.includes('@')) {
throw new Error('Email invalide');
}
// Vérifier que l'email n'existe pas
const existing = await db.User.findOne({ email: args.input.email });
if (existing) {
throw new Error('Email déjà utilisé');
}
// Créer l'utilisateur
const user = await db.User.create({
name: args.input.name,
email: args.input.email,
role: args.input.role || 'USER',
});
return user;
},
},
// Résolveurs pour les fields imbriquées
User: {
// Résolveur pour User.posts
posts: async (parent, args, context) => {
// parent = l'utilisateur en cours
// Récupérer les posts de cet utilisateur
return await db.Post.find({ authorId: parent.id });
},
// Résolveur avec DataLoader (voir section 7 pour optimisation)
comments: async (parent, args, context) => {
return await db.Comment.find({ userId: parent.id });
},
},
Post: {
// Résolveur pour Post.author
author: async (parent, args, context) => {
return await db.User.findById(parent.authorId);
},
},
};
Exemple requête côté client :
query {
# Récupérer un utilisateur avec ses posts
user(id: "123") {
id
name
email
posts {
id
title
}
}
}
Réponse serveur :
{
"data": {
"user": {
"id": "123",
"name": "Alice",
"email": "alice@example.com",
"posts": [
{ "id": "1", "title": "GraphQL Guide" },
{ "id": "2", "title": "Node.js Tips" }
]
}
}
}
5. Mutations et subscriptions
Mutations = modifications de données (CREATE, UPDATE, DELETE)
mutation CreatePost($title: String!, $content: String!, $authorId: ID!) {
createPost(
title: $title
content: $content
authorId: $authorId
) {
id
title
author {
name
}
}
}
Variables (côté client) :
{
"title": "Mon Article",
"content": "Contenu complet...",
"authorId": "123"
}
Subscriptions = mises à jour temps réel (WebSocket)
# S'abonner aux nouveaux posts d'un auteur
subscription OnPostCreated($authorId: ID!) {
postCreated(authorId: $authorId) {
id
title
createdAt
}
}
Implémenter côté serveur :
module.exports = {
Subscription: {
// S'abonner aux nouveaux posts
postCreated: {
subscribe: async function* (parent, args, context) {
// Créer un pubsub pour notifications temps réel
const asyncIterator = context.pubSub.asyncIterator(['POST_CREATED']);
for await (const event of asyncIterator) {
// Filter par authorId si fourni
if (!args.authorId || event.post.authorId === args.authorId) {
yield event;
}
}
},
resolve: (parent) => parent.post,
},
},
Mutation: {
createPost: async (parent, args, context) => {
const newPost = await db.Post.create({
title: args.title,
content: args.content,
authorId: args.authorId,
});
// Publier l'événement (notifier les subscribers)
context.pubSub.publish('POST_CREATED', { post: newPost });
return newPost;
},
},
};
6. Apollo Client dans Angular
Installer Apollo Client pour Angular :
ng add apollo-angular
npm install graphql apollo-angular @apollo/client graphql-tag
Configurer graphql.module.ts :
import { NgModule } from '@angular/core';
import { ApolloClientOptions, InMemoryCache, ApolloLink } from '@apollo/client/core';
import { HttpLink } from 'apollo-angular/http';
import { ApolloModule, Apollo } from 'apollo-angular';
import { setContext } from '@apollo/client/link/context';
import { HttpClientModule } from '@angular/common/http';
import { onError } from '@apollo/client/link/error';
// Options de configuration Apollo
export function createApollo(
httpLink: HttpLink,
): ApolloClientOptions<any> {
// Link HTTP vers serveur GraphQL
const http = httpLink.create({
uri: 'http://localhost:4000/graphql', // Endpoint GraphQL
});
// Middleware: ajouter le token JWT aux headers
const auth = setContext((operation, { headers }) => {
const token = localStorage.getItem('auth_token');
// Cloner les headers et ajouter Authorization
return {
headers: headers ? {
...headers,
Authorization: token ? `Bearer ${token}` : '',
} : {
Authorization: token ? `Bearer ${token}` : '',
},
};
});
// Gestion des erreurs
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions }) => {
console.error(`[GraphQL] ${message}`, extensions);
});
}
if (networkError) {
console.error('[Network]', networkError);
}
});
// Composer les links
const link = ApolloLink.from([errorLink, auth, http]);
// Cache Apollo (important pour la performance)
const cache = new InMemoryCache();
return {
link,
cache,
defaultOptions: {
watchQuery: {
errorPolicy: 'all', // Retourner les données même avec erreurs partielles
},
},
};
}
@NgModule({
imports: [ApolloModule, HttpClientModule],
providers: [
{
provide: APOLLO_OPTIONS,
useFactory: createApollo,
deps: [HttpLink],
},
],
})
export class GraphQLModule {}
Créer un service pour les requêtes GraphQL :
import { Injectable } from '@angular/core';
import { Apollo, gql } from 'apollo-angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// Définir la requête GraphQL
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
posts {
id
title
}
}
}
`;
const CREATE_USER = gql`
mutation CreateUser($name: String!, $email: String!) {
createUser(input: { name: $name, email: $email }) {
id
name
email
}
}
`;
@Injectable({
providedIn: 'root'
})
export class UserService {
constructor(private apollo: Apollo) {}
// Récupérer tous les utilisateurs
getUsers(): Observable<any[]> {
return this.apollo
.watchQuery<any>({
query: GET_USERS,
})
.valueChanges.pipe(
map((result) => result.data.users)
);
}
// Créer un utilisateur
createUser(name: string, email: string): Observable<any> {
return this.apollo
.mutate<any>({
mutation: CREATE_USER,
variables: { name, email },
})
.pipe(
map((result) => result.data.createUser)
);
}
}
Utiliser dans un composant :
@Component({
selector: 'app-users',
template: `
<div *ngIf="(users$ | async) as users; else loading">
<div *ngFor="let user of users" class="user-card">
{{ user.name }} ({{ user.email }})
</div>
</div>
<ng-template #loading>
Chargement...
</ng-template>
`
})
export class UsersComponent {
users$: Observable<any[]>;
constructor(private userService: UserService) {
this.users$ = this.userService.getUsers();
}
}
7. Performance et DataLoader
Un problème courant en GraphQL : les N+1 queries. Si vous fetchez 10 utilisateurs, alors chaque utilisateur appelle 1 requête pour ses posts = 11 requêtes totales!
query {
users { # 1 requête
id
name
posts { # 10 requêtes supplémentaires (N+1)!
id
title
}
}
}
Solution : DataLoader
npm install dataloader
// loaders/postLoader.js
const DataLoader = require('dataloader');
const db = require('../database');
// DataLoader batch et cache les requêtes
const createPostLoader = () => {
return new DataLoader(async (userIds) => {
// Requête UNIQUE pour tous les userIds au lieu de N requêtes
const posts = await db.Post.find({
authorId: { $in: userIds }
});
// Retourner dans l'ordre des userIds
return userIds.map((userId) =>
posts.filter((post) => post.authorId === userId)
);
});
};
module.exports = { createPostLoader };
Utiliser dans les résolveurs :
// resolvers/user.js
module.exports = {
User: {
// AVEC DataLoader (optimisé)
posts: (parent, args, context) => {
return context.postLoader.load(parent.id);
},
},
};
// server.js
const { createPostLoader } = require('./loaders/postLoader');
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => ({
user: req.user,
postLoader: createPostLoader(), // 1 loader par requête
}),
});
Caching côté client :
Apollo Client cache automatiquement les résultats. Configurer le cache :
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'], // Clé de cache unique
fields: {
posts: {
merge(existing = [], incoming) {
// Comportement de fusion du cache
return [...existing, ...incoming];
},
},
},
},
},
});
8. Authentification JWT en GraphQL
Passer le token JWT dans les headers Authorization et valider côté serveur :
// middleware/auth.js
const jwt = require('jsonwebtoken');
function authenticateToken(req) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return null; // Non authentifié (optionnel)
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded; // { id, email, roles, ... }
} catch (error) {
throw new Error('Token invalide');
}
}
module.exports = { authenticateToken };
Utiliser dans Apollo :
// server.js
const { authenticateToken } = require('./middleware/auth');
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
// Authentifier la requête
const user = authenticateToken(req);
return {
user, // null ou objet utilisateur
isAuthenticated: !!user,
};
},
});
Vérifier l'authentification dans les résolveurs :
module.exports = {
Query: {
// Cette requête nécessite une authentification
me: (parent, args, context) => {
if (!context.user) {
throw new Error('Non authentifié');
}
return context.user;
},
},
Mutation: {
updateUser: (parent, args, context) => {
if (!context.user) {
throw new Error('Non authentifié');
}
// Vérifier que l'utilisateur modifie ses propres données
if (context.user.id !== args.id && !context.user.roles.includes('ADMIN')) {
throw new Error('Non autorisé');
}
return db.User.findByIdAndUpdate(args.id, args.input);
},
},
};
Côté Angular :
// Ajouter le token aux headers (voir section 6)
const auth = setContext((operation, { headers }) => {
const token = localStorage.getItem('auth_token'); // Stocké après login
return {
headers: headers ? {
...headers,
Authorization: token ? `Bearer ${token}` : '',
} : {
Authorization: token ? `Bearer ${token}` : '',
},
};
});
Conclusion
GraphQL et Apollo Server offrent une alternative moderne et puissante aux APIs REST. Grâce à l'introspection, aux requêtes déclaratives et à la gestion avancée du cache, vous pouvez construire des APIs performantes et maintenables pour des applications complexes.
📋 Checklist avant production
- ✅ Schéma validé : types bien définis, descriptions pour chaque field
- ✅ Authentification : JWT + vérification dans context et résolveurs
- ✅ Validation : vérifier inputs côté serveur (jamais faire confiance au client)
- ✅ DataLoader : éviter N+1 queries avec batch loading
- ✅ Caching : configurer cache Apollo côté client et serveur
- ✅ Rate limiting : limiter les requêtes par utilisateur/IP
- ✅ Logging : logger les requêtes, mutations et erreurs
- ✅ Tests : tester queries, mutations, sécurité (authorization)
- ✅ Performance : profiler avec Apollo Sandbox, optimiser résolveurs lents
- ✅ Versioning : ajouter champs sans briser les clients anciens