Microservices avec WordPress : Découpler Frontend et Backend
Introduction L'architecture microservices permet de scaler WordPress en découplant le frontend du backend, offrant flexibilité,…
GraphQL a révolutionné la façon dont nous concevons des APIs en 2025, avec plus de 62% des nouvelles APIs adoptant GraphQL pour sa flexibilité et son efficacité. Contrairement à REST qui retourne des structures de données fixes, GraphQL permet aux clients de demander exactement les données dont ils ont besoin. Ce guide complet couvre la conception de schémas GraphQL performants, l’optimisation des resolvers et les patterns de production éprouvés.
Problème REST: Over-fetching
// REST: Récupère trop de données
GET /api/users/123
{
"id": 123,
"username": "jdupont",
"email": "j.dupont@exemple.com",
"bio": "...",
"avatarurl": "...",
"followerscount": 1234,
"followingcount": 567,
"posts": [...], // Non nécessaire
"settings": {...}, // Non nécessaire
"notifications": [...] // Non nécessaire
}
Solution GraphQL: Demander exactement ce dont on a besoin
query {
user(id: 123) {
id
username
avatarurl
}
}
# Réponse
{
"data": {
"user": {
"id": 123,
"username": "jdupont",
"avatarurl": "https://..."
}
}
}
Problème REST: Under-fetching (N+1 requests)
// REST: Nécessite plusieurs requêtes
GET /api/users/123
GET /api/users/123/posts
GET /api/posts/1/comments
GET /api/posts/2/comments
// ...
Solution GraphQL: Une seule requête
query {
user(id: 123) {
username
posts {
title
comments {
content
author {
username
}
}
}
}
}
# schema.graphql
# Scalaires personnalisés
scalar DateTime
scalar Email
scalar URL
scalar JSON
# Énumérations
enum UserRole {
ADMIN
MODERATOR
USER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# Types d'objet
type User {
id: ID!
username: String!
email: Email!
role: UserRole!
avatar: URL
bio: String
createdAt: DateTime!
# Relations
posts(
first: Int = 10
offset: Int = 0
status: PostStatus
): PostConnection!
followers(first: Int = 10): UserConnection!
following(first: Int = 10): UserConnection!
# Champs calculés
postsCount: Int!
followersCount: Int!
isFollowing: Boolean!
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
excerpt: String
status: PostStatus!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
# Relations
author: User!
comments(first: Int = 10): CommentConnection!
tags: [Tag!]!
# Champs calculés
commentsCount: Int!
likesCount: Int!
viewsCount: Int!
readingTime: Int!
isLiked: Boolean!
}
type Comment {
id: ID!
content: String!
createdAt: DateTime!
updatedAt: DateTime!
author: User!
post: Post!
parent: Comment
replies(first: Int = 10): CommentConnection!
repliesCount: Int!
}
type Tag {
id: ID!
name: String!
slug: String!
posts(first: Int = 10): PostConnection!
postsCount: Int!
}
# Pagination avec Relay Cursor Connections
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
node: Comment!
cursor: String!
}
# Input types pour les mutations
input CreatePostInput {
title: String!
content: String!
excerpt: String
status: PostStatus = DRAFT
tagIds: [ID!]
}
input UpdatePostInput {
title: String
content: String
excerpt: String
status: PostStatus
tagIds: [ID!]
}
input CreateCommentInput {
postId: ID!
content: String!
parentId: ID
}
# Types de résultat pour les mutations
type CreatePostPayload {
post: Post
errors: [Error!]
}
type UpdatePostPayload {
post: Post
errors: [Error!]
}
type DeletePostPayload {
success: Boolean!
errors: [Error!]
}
type Error {
field: String
message: String!
}
# Query root
type Query {
# Utilisateurs
user(id: ID!): User
users(
first: Int = 10
offset: Int = 0
role: UserRole
search: String
): UserConnection!
me: User
# Posts
post(id: ID, slug: String): Post
posts(
first: Int = 10
offset: Int = 0
status: PostStatus
authorId: ID
tagId: ID
search: String
): PostConnection!
# Tags
tag(id: ID, slug: String): Tag
tags(first: Int = 50): [Tag!]!
# Recherche
search(
query: String!
first: Int = 10
types: [SearchType!]
): SearchResult!
}
enum SearchType {
USER
POST
TAG
}
union SearchResult = User | Post | Tag
# Mutation root
type Mutation {
# Authentification
register(
username: String!
email: Email!
password: String!
): AuthPayload!
login(
email: Email!
password: String!
): AuthPayload!
logout: Boolean!
# Posts
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): DeletePostPayload!
# Comments
createComment(input: CreateCommentInput!): Comment!
deleteComment(id: ID!): Boolean!
# Interactions
likePost(postId: ID!): Post!
unlikePost(postId: ID!): Post!
followUser(userId: ID!): User!
unfollowUser(userId: ID!): User!
}
type AuthPayload {
token: String
user: User
errors: [Error!]
}
# Subscription root
type Subscription {
postCreated(authorId: ID): Post!
commentAdded(postId: ID!): Comment!
userFollowed(userId: ID!): User!
}
// Installation // npm install apollo-server graphql dataloader const { ApolloServer } = require('apollo-server'); const { makeExecutableSchema } = require('@graphql-tools/schema'); const { readFileSync } = require('fs'); const DataLoader = require('dataloader'); // Charger le schéma const typeDefs = readFileSync('./schema.graphql', 'utf-8'); // Resolvers const resolvers = { // Scalaires personnalisés DateTime: require('./scalars/DateTime'), Email: require('./scalars/Email'), URL: require('./scalars/URL'), JSON: require('./scalars/JSON'), // Query resolvers Query: { user: async (, { id }, context) => { return context.loaders.user.load(id); }, users: async (, { first, offset, role, search }, context) => { const { users, total } = await context.dataSources.userAPI.getUsers({ limit: first, offset, role, search }); return { edges: users.map(user => ({ node: user, cursor: Buffer.from(]); } return context.pubsub.asyncIterator(['POSTCREATED']); } }, commentAdded: { subscribe: (, { postId }, context) => { return context.pubsub.asyncIterator([user:${user.id}).toString('base64') })), pageInfo: { hasNextPage: offset + first < total, hasPreviousPage: offset > 0, startCursor: users.length > 0 ? Buffer.from(user:${users[0].id}).toString('base64') : null, endCursor: users.length > 0 ? Buffer.from(user:${users[users.length - 1].id}).toString('base64') : null }, totalCount: total }; }, me: async (, , context) => { if (!context.user) { throw new Error('Not authenticated'); } return context.loaders.user.load(context.user.id); }, post: async (, { id, slug }, context) => { if (id) { return context.loaders.post.load(id); } return context.dataSources.postAPI.getPostBySlug(slug); }, posts: async (, args, context) => { const { posts, total } = await context.dataSources.postAPI.getPosts(args); return { edges: posts.map(post => ({ node: post, cursor: Buffer.from(post:${post.id}).toString('base64') })), pageInfo: { hasNextPage: args.offset + args.first < total, hasPreviousPage: args.offset > 0, startCursor: posts.length > 0 ? Buffer.from(post:${posts[0].id}).toString('base64') : null, endCursor: posts.length > 0 ? Buffer.from(post:${posts[posts.length - 1].id}).toString('base64') : null }, totalCount: total }; }, search: async (, { query, first, types }, context) => { return context.dataSources.searchAPI.search(query, first, types); } }, // Mutation resolvers Mutation: { register: async (, { username, email, password }, context) => { try { const user = await context.dataSources.authAPI.register({ username, email, password }); const token = context.dataSources.authAPI.generateToken(user); return { token, user, errors: [] }; } catch (error) { return { token: null, user: null, errors: [{ message: error.message }] }; } }, login: async (, { email, password }, context) => { try { const user = await context.dataSources.authAPI.login(email, password); const token = context.dataSources.authAPI.generateToken(user); return { token, user, errors: [] }; } catch (error) { return { token: null, user: null, errors: [{ message: error.message }] }; } }, createPost: async (, { input }, context) => { if (!context.user) { return { post: null, errors: [{ message: 'Not authenticated' }] }; } try { const post = await context.dataSources.postAPI.createPost({ ...input, authorId: context.user.id }); // Publier pour les subscriptions context.pubsub.publish('POSTCREATED', { postCreated: post }); return { post, errors: [] }; } catch (error) { return { post: null, errors: [{ message: error.message }] }; } }, updatePost: async (, { id, input }, context) => { if (!context.user) { return { post: null, errors: [{ message: 'Not authenticated' }] }; } try { const post = await context.loaders.post.load(id); if (post.authorId !== context.user.id && context.user.role !== 'ADMIN') { return { post: null, errors: [{ message: 'Not authorized' }] }; } const updatedPost = await context.dataSources.postAPI.updatePost(id, input); // Invalider le cache context.loaders.post.clear(id); return { post: updatedPost, errors: [] }; } catch (error) { return { post: null, errors: [{ message: error.message }] }; } }, deletePost: async (, { id }, context) => { if (!context.user) { return { success: false, errors: [{ message: 'Not authenticated' }] }; } try { const post = await context.loaders.post.load(id); if (post.authorId !== context.user.id && context.user.role !== 'ADMIN') { return { success: false, errors: [{ message: 'Not authorized' }] }; } await context.dataSources.postAPI.deletePost(id); context.loaders.post.clear(id); return { success: true, errors: [] }; } catch (error) { return { success: false, errors: [{ message: error.message }] }; } }, likePost: async (, { postId }, context) => { if (!context.user) { throw new Error('Not authenticated'); } await context.dataSources.postAPI.likePost(postId, context.user.id); context.loaders.post.clear(postId); return context.loaders.post.load(postId); } }, // Type resolvers User: { posts: async (user, args, context) => { const { posts, total } = await context.dataSources.postAPI.getPosts({ ...args, authorId: user.id }); return { edges: posts.map(post => ({ node: post, cursor: Buffer.from(post:${post.id}).toString('base64') })), pageInfo: { hasNextPage: args.offset + args.first < total, hasPreviousPage: args.offset > 0 }, totalCount: total }; }, followers: async (user, args, context) => { return context.dataSources.userAPI.getFollowers(user.id, args); }, following: async (user, args, context) => { return context.dataSources.userAPI.getFollowing(user.id, args); }, postsCount: async (user, , context) => { return context.loaders.userPostsCount.load(user.id); }, followersCount: async (user, , context) => { return context.loaders.userFollowersCount.load(user.id); }, isFollowing: async (user, , context) => { if (!context.user) return false; return context.loaders.isFollowing.load({ userId: context.user.id, followingId: user.id }); } }, Post: { author: async (post, , context) => { return context.loaders.user.load(post.authorId); }, comments: async (post, args, context) => { return context.dataSources.commentAPI.getComments({ ...args, postId: post.id }); }, tags: async (post, , context) => { return context.loaders.postTags.load(post.id); }, commentsCount: async (post, , context) => { return context.loaders.postCommentsCount.load(post.id); }, likesCount: async (post, , context) => { return context.loaders.postLikesCount.load(post.id); }, viewsCount: async (post, , context) => { return context.loaders.postViewsCount.load(post.id); }, readingTime: (post) => { // Calculer le temps de lecture (mots / 200 mots par minute) const words = post.content.split(/s+/).length; return Math.ceil(words / 200); }, isLiked: async (post, , context) => { if (!context.user) return false; return context.loaders.postIsLiked.load({ postId: post.id, userId: context.user.id }); } }, Comment: { author: async (comment, , context) => { return context.loaders.user.load(comment.authorId); }, post: async (comment, , context) => { return context.loaders.post.load(comment.postId); }, parent: async (comment, , context) => { if (!comment.parentId) return null; return context.loaders.comment.load(comment.parentId); }, replies: async (comment, args, context) => { return context.dataSources.commentAPI.getComments({ ...args, parentId: comment.id }); }, repliesCount: async (comment, , context) => { return context.loaders.commentRepliesCount.load(comment.id); } }, Tag: { posts: async (tag, args, context) => { return context.dataSources.postAPI.getPosts({ ...args, tagId: tag.id }); }, postsCount: async (tag, , context) => { return context.loaders.tagPostsCount.load(tag.id); } }, // Union resolvers SearchResult: { resolveType(obj) { if (obj.username) return 'User'; if (obj.title) return 'Post'; if (obj.slug && !obj.title) return 'Tag'; return null; } }, // Subscription resolvers Subscription: { postCreated: { subscribe: (, { authorId }, context) => { if (authorId) { return context.pubsub.asyncIterator([POSTCREATED${authorId}COMMENTADDED${postId}]); } } } }; module.exports = { typeDefs, resolvers };
// loaders/index.js const DataLoader = require('dataloader'); // Batch function pour charger plusieurs utilisateurs const batchUsers = async (ids, { db }) => { const users = await db.users.findAll({ where: { id: ids } }); // Maintenir l'ordre des IDs const userMap = new Map(users.map(u => [u.id, u])); return ids.map(id => userMap.get(id) || null); }; // Batch function pour charger plusieurs posts const batchPosts = async (ids, { db }) => { const posts = await db.posts.findAll({ where: { id: ids } }); const postMap = new Map(posts.map(p => [p.id, p])); return ids.map(id => postMap.get(id) || null); }; // Batch function pour charger les tags d'un post const batchPostTags = async (postIds, { db }) => { const postTags = await db.query(` SELECT pt.postid, t. FROM posttags pt JOIN tags t ON t.id = pt.tagid WHERE pt.postid IN (?) `, [postIds]); // Grouper par postid const tagsByPost = new Map(); postIds.forEach(id => tagsByPost.set(id, [])); postTags.forEach(({ postid, ...tag }) => { tagsByPost.get(postid).push(tag); }); return postIds.map(id => tagsByPost.get(id)); }; // Batch function pour compter const batchPostsCount = async (userIds, { db }) => { const counts = await db.query(` SELECT authorid, COUNT() as count FROM posts WHERE authorid IN (?) GROUP BY authorid `, [userIds]); const countMap = new Map(counts.map(c => [c.authorid, c.count])); return userIds.map(id => countMap.get(id) || 0); }; const batchFollowersCount = async (userIds, { db }) => { const counts = await db.query(` SELECT followingid, COUNT(*) as count FROM userfollows WHERE followingid IN (?) GROUP BY followingid `, [userIds]); const countMap = new Map(counts.map(c => [c.followingid, c.count])); return userIds.map(id => countMap.get(id) || 0); }; const batchIsFollowing = async (pairs, { db }) => { // pairs = [{ userId, followingId }, ...] const conditions = pairs.map(p =>).join(' OR '); const follows = await db.query(` SELECT userid, followingid FROM userfollows WHERE ${conditions} `); const followMap = new Set( follows.map(f =>(userid = ${p.userId} AND followingid = ${p.followingId})${f.userid}:${f.followingid}) ); return pairs.map(p => followMap.has(${p.userId}:${p.followingId}) ); }; // Créer les loaders const createLoaders = (context) => ({ user: new DataLoader(ids => batchUsers(ids, context), { cacheKeyFn: (key) => key.toString() }), post: new DataLoader(ids => batchPosts(ids, context), { cacheKeyFn: (key) => key.toString() }), comment: new DataLoader(ids => batchComments(ids, context)), postTags: new DataLoader(ids => batchPostTags(ids, context)), userPostsCount: new DataLoader(ids => batchPostsCount(ids, context)), userFollowersCount: new DataLoader(ids => batchFollowersCount(ids, context)), postCommentsCount: new DataLoader(ids => batchCommentsCount(ids, context)), postLikesCount: new DataLoader(ids => batchLikesCount(ids, context)), postViewsCount: new DataLoader(ids => batchViewsCount(ids, context)), isFollowing: new DataLoader(pairs => batchIsFollowing(pairs, context), { cacheKeyFn: (pair) =>${pair.userId}:${pair.followingId}}), postIsLiked: new DataLoader(pairs => batchPostIsLiked(pairs, context), { cacheKeyFn: (pair) =>${pair.postId}:${pair.userId}}) }); module.exports = createLoaders;
// server.js
const { ApolloServer } = require('apollo-server-express');
const { createServer } = require('http');
const express = require('express');
const { execute, subscribe } = require('graphql');
const { SubscriptionServer } = require('subscriptions-transport-ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { PubSub } = require('graphql-subscriptions');
const jwt = require('jsonwebtoken');
const { typeDefs, resolvers } = require('./schema');
const createLoaders = require('./loaders');
const dataSources = require('./dataSources');
const db = require('./config/database');
const pubsub = new PubSub();
// Créer le schéma
const schema = makeExecutableSchema({ typeDefs, resolvers });
// Context function
const context = async ({ req, connection }) => {
// Pour les WebSocket (subscriptions)
if (connection) {
return {
...connection.context,
pubsub
};
}
// Pour les requêtes HTTP
let user = null;
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWTSECRET);
user = await db.users.findByPk(decoded.sub);
} catch (error) {
console.error('Token verification failed:', error);
}
}
return {
user,
db,
loaders: createLoaders({ db, user }),
dataSources: dataSources({ db }),
pubsub
};
};
// Créer l'application Express
const app = express();
// Créer Apollo Server
const apolloServer = new ApolloServer({
schema,
context,
dataSources,
formatError: (error) => {
console.error('GraphQL Error:', error);
return {
message: error.message,
code: error.extensions?.code,
path: error.path
};
},
plugins: [
{
async requestDidStart() {
const start = Date.now();
return {
async willSendResponse({ response }) {
const duration = Date.now() - start;
console.log(Query executed in ${duration}ms);
// Ajouter des métriques
if (duration > 1000) {
console.warn('Slow query detected:', duration);
}
}
};
}
}
],
introspection: process.env.NODEENV !== 'production',
playground: process.env.NODEENV !== 'production'
});
// Appliquer le middleware Apollo
await apolloServer.start();
apolloServer.applyMiddleware({ app, path: '/graphql' });
// Créer le serveur HTTP
const httpServer = createServer(app);
// Configuration des subscriptions
SubscriptionServer.create(
{
schema,
execute,
subscribe,
onConnect: async (connectionParams) => {
const token = connectionParams.authorization?.replace('Bearer ', '');
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWTSECRET);
const user = await db.users.findByPk(decoded.sub);
return { user };
} catch (error) {
throw new Error('Invalid token');
}
}
return {};
},
onDisconnect: () => {
console.log('Client disconnected');
}
},
{
server: httpServer,
path: '/graphql'
}
);
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
console.log(🚀 Server ready at http://localhost:${PORT}${apolloServer.graphqlPath});
console.log(🚀 Subscriptions ready at ws://localhost:${PORT}${apolloServer.graphqlPath});
});
// plugins/queryComplexity.js
const { getComplexity, simpleEstimator, fieldExtensionsEstimator } = require('graphql-query-complexity');
const queryComplexityPlugin = {
requestDidStart: () => ({
didResolveOperation({ request, document, schema }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
});
const maxComplexity = 1000;
if (complexity > maxComplexity) {
throw new Error(
Query is too complex: ${complexity}. Maximum allowed complexity: ${maxComplexity}
);
}
console.log('Query Complexity:', complexity);
}
})
};
// Dans le schéma, définir la complexité
const typeDefs = `
type Query {
posts(first: Int = 10): [Post!]! @complexity(value: 10, multipliers: ["first"])
user(id: ID!): User @complexity(value: 1)
}
type User {
posts: [Post!]! @complexity(value: 10)
}
`;
// Côté client: générer un hash de la query
const crypto = require('crypto');
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
username
email
}
}
`;
const queryHash = crypto
.createHash('sha256')
.update(query)
.digest('hex');
// Envoyer uniquement le hash
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
extensions: {
persistedQuery: {
version: 1,
sha256Hash: queryHash
}
},
variables: { id: '123' }
})
});
// Côté serveur
const { ApolloServerPluginPersistedQueries } = require('apollo-server-core');
const { createHash } = require('crypto');
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginPersistedQueries({
cache: new Map(), // ou Redis en production
generateHash: ({ documentString }) => {
return createHash('sha256').update(documentString).digest('hex');
}
})
]
});
// plugins/caching.js
const Redis = require('ioredis');
const { createHash } = require('crypto');
const redis = new Redis();
const cachePlugin = {
requestDidStart: () => ({
async willSendResponse({ request, response, context }) {
// Ne cacher que les queries (pas les mutations)
if (request.operationName && !request.query.includes('mutation')) {
const cacheKey = createHash('sha256')
.update(JSON.stringify({
query: request.query,
variables: request.variables
}))
.digest('hex');
await redis.setex(
graphql:${cacheKey},
300, // 5 minutes
JSON.stringify(response.data)
);
}
},
async executionDidStart({ request, context }) {
if (request.operationName && !request.query.includes('mutation')) {
const cacheKey = createHash('sha256')
.update(JSON.stringify({
query: request.query,
variables: request.variables
}))
.digest('hex');
const cached = await redis.get(graphql:${cacheKey});
if (cached) {
return {
data: JSON.parse(cached)
};
}
}
}
})
};
// tests/graphql.test.js
const { createTestClient } = require('apollo-server-testing');
const { ApolloServer } = require('apollo-server');
const { typeDefs, resolvers } = require('../schema');
describe('GraphQL API', () => {
let server;
let query, mutate;
beforeAll(() => {
server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({
user: { id: 1, role: 'ADMIN' },
db: mockDatabase,
loaders: createLoaders({ db: mockDatabase })
})
});
const testClient = createTestClient(server);
query = testClient.query;
mutate = testClient.mutate;
});
describe('Queries', () => {
it('devrait récupérer un utilisateur', async () => {
const GETUSER = `
query GetUser($id: ID!) {
user(id: $id) {
id
username
email
}
}
`;
const { data, errors } = await query({
query: GETUSER,
variables: { id: '1' }
});
expect(errors).toBeUndefined();
expect(data.user).toMatchObject({
id: '1',
username: expect.any(String),
email: expect.any(String)
});
});
it('devrait récupérer des posts avec pagination', async () => {
const GETPOSTS = `
query GetPosts($first: Int!, $offset: Int!) {
posts(first: $first, offset: $offset) {
edges {
node {
id
title
author {
username
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
totalCount
}
}
`;
const { data } = await query({
query: GETPOSTS,
variables: { first: 10, offset: 0 }
});
expect(data.posts.edges).toHaveLength(10);
expect(data.posts.totalCount).toBeGreaterThan(0);
expect(data.posts.pageInfo.hasNextPage).toBeDefined();
});
});
describe('Mutations', () => {
it('devrait créer un post', async () => {
const CREATEPOST = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
content
}
errors {
message
}
}
}
`;
const { data } = await mutate({
mutation: CREATEPOST,
variables: {
input: {
title: 'Test Post',
content: 'Test content'
}
}
});
expect(data.createPost.post).toMatchObject({
title: 'Test Post',
content: 'Test content'
});
expect(data.createPost.errors).toHaveLength(0);
});
});
describe('Authorization', () => {
it('devrait rejeter sans authentification', async () => {
const serverNoAuth = new ApolloServer({
typeDefs,
resolvers,
context: () => ({ user: null })
});
const { query } = createTestClient(serverNoAuth);
const GETME = `
query {
me {
id
}
}
`;
const { errors } = await query({ query: GETME });
expect(errors).toBeDefined();
expect(errors[0].message).toContain('Not authenticated');
});
});
});
// Installation: npm install @apollo/client graphql
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
useQuery,
useMutation,
gql
} from '@apollo/client';
// Configuration du client
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['status', 'authorId'],
merge(existing = { edges: [] }, incoming) {
return {
...incoming,
edges: [...existing.edges, ...incoming.edges]
};
}
}
}
}
}
}),
headers: {
authorization: Bearer ${localStorage.getItem('token')}
}
});
// Queries
const GETPOSTS = gql`
query GetPosts($first: Int!, $offset: Int!) {
posts(first: $first, offset: $offset) {
edges {
node {
id
title
excerpt
author {
username
avatar
}
commentsCount
likesCount
}
}
pageInfo {
hasNextPage
}
totalCount
}
}
`;
const CREATEPOST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
content
}
errors {
field
message
}
}
}
`;
// Composant React
function PostList() {
const { loading, error, data, fetchMore } = useQuery(GETPOSTS, {
variables: { first: 10, offset: 0 }
});
if (loading) return Loading...;
if (error) return Error: {error.message};
return (
{data.posts.edges.map(({ node: post }) => (
## {post.title}
{post.excerpt}
By {post.author.username}
))}
{data.posts.pageInfo.hasNextPage && (
)}
);
}
function CreatePost() {
const [createPost, { loading, error }] = useMutation(CREATEPOST, {
refetchQueries: [{ query: GET_POSTS, variables: { first: 10, offset: 0 } }]
});
const handleSubmit = async (e) => {
e.preventDefault();
const { data } = await createPost({
variables: {
input: {
title: e.target.title.value,
content: e.target.content.value
}
}
});
if (data.createPost.errors.length === 0) {
alert('Post created!');
}
};
return (
);
}
function App() {
return (
);
}
GraphQL offre une flexibilité inégalée pour les APIs modernes en 2025. Les points clés:
Mots-clés: GraphQL, Apollo Server, DataLoader, schema design, resolvers, N+1 problem, pagination, subscriptions, query complexity, GraphQL optimization, GraphQL caching, React Apollo Client
Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.