Modern Backend Engineering
Chapitre 7
Chapitre 07 — GraphQL
Chapitre 07 — GraphQL
Cours complet — GraphQL
1. Schema & Types
Qu'est-ce que GraphQL ?
- Langage de requête pour les APIs (développé par Facebook en 2012, open source en 2015)
- Un seul endpoint : POST /graphql
- Le client décide des données retournées (pas de over/under-fetching)
- Typage fort : tout est typé dans le schema
Schéma GraphQL
# schema.graphql
type Query {
users(limit: Int, cursor: String): UserConnection!
user(id: ID!): User
posts(limit: Int, cursor: String): PostConnection!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
}
type Subscription {
postCreated: Post!
userUpdated(id: ID!): User!
}
type User {
id: ID!
name: String!
email: String!
posts(limit: Int, cursor: String): PostConnection!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String
published: Boolean!
author: User!
tags: [Tag!]!
createdAt: DateTime!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
name: String
email: String
}
scalar DateTime
Types de base
| Type | Description |
|---|---|
| Scalar | Int, Float, String, Boolean, ID |
| Object | Structure avec champs |
| Input | Objet passé en argument |
| Enum | Ensemble de valeurs |
| Union | Un type parmi plusieurs |
| Interface | Contrat de champs communs |
Schema Definition Language (SDL)
# Enum
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# Interface
interface Node {
id: ID!
createdAt: DateTime!
}
# Union
type TextPost implements Node {
id: ID!
content: String!
createdAt: DateTime!
}
type ImagePost implements Node {
id: ID!
imageUrl: String!
caption: String
createdAt: DateTime!
}
union MediaContent = TextPost | ImagePost
# Implémentation
type Query {
searchContent(term: String!): [MediaContent!]!
}
2. Resolvers
Structure des resolvers
const resolvers = {
Query: {
users: async (parent, args, context, info) => {
// parent : valeur du parent (pour les champs imbriqués)
// args : arguments de la requête
// context : contexte partagé (DB, auth, loaders)
// info : informations sur l'exécution
return context.db.user.findMany()
},
user: async (_, { id }, { db }) => {
return db.user.findUnique({ where: { id } })
},
},
User: {
posts: async (parent, { limit, cursor }, { db }) => {
// parent = l'utilisateur courant
return db.post.findMany({
where: { authorId: parent.id },
take: limit,
cursor: cursor ? { id: cursor } : undefined,
})
},
},
Mutation: {
createUser: async (_, { input }, { db }) => {
return db.user.create({ data: input })
},
},
}
Context
import { ApolloServer } from '@apollo/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const server = new ApolloServer({
typeDefs,
resolvers,
})
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
// Auth
const token = req.headers.authorization?.replace('Bearer ', '')
const user = token ? await verifyToken(token) : null
return {
db: prisma,
user,
// DataLoaders
loaders: {
userLoader: new DataLoader(ids => batchUsers(ids)),
postLoader: new DataLoader(ids => batchPosts(ids)),
},
}
},
})
3. DataLoader (N+1 Problem)
Le problème N+1
{
users {
name
posts { # Pour chaque utilisateur, une requête DB
title
}
}
}
Sans DataLoader : 1 query (users) + N queries (posts) = N+1 queries.
Solution DataLoader
import DataLoader from 'dataloader'
// Batch function (reçoit un tableau d'IDs, retourne un tableau ordonné)
async function batchUsers(ids: readonly number[]) {
const users = await prisma.user.findMany({
where: { id: { in: ids as number[] } },
})
// Doit retourner dans le même ordre que ids
return ids.map(id => users.find(u => u.id === id))
}
// Dans le contexte
const userLoader = new DataLoader(batchUsers)
// Cache activé par défaut (per request)
// Disable cache : { cache: false }
// Dans le resolver
User: {
author: async (parent, _, { loaders }) => {
// DataLoader groupe automatiquement les appels
return loaders.userLoader.load(parent.authorId)
},
}
Fonctionnement interne
Requête : users[0].author + users[1].author + users[2].author
↓
DataLoader accumule : [1, 2, 3]
↓
batchUsers([1, 2, 3]) → une seule query
↓
Résultat distribué à chaque appel
DataLoader avec relations
// Posts par utilisateur (hasMany)
async function batchPostsByUserIds(userIds: readonly number[]) {
const posts = await prisma.post.findMany({
where: { authorId: { in: userIds as number[] } },
})
// Grouper par userId
const grouped = new Map()
for (const post of posts) {
if (!grouped.has(post.authorId)) grouped.set(post.authorId, [])
grouped.get(post.authorId).push(post)
}
return userIds.map(id => grouped.get(id) || [])
}
const postsByUserLoader = new DataLoader(batchPostsByUserIds)
// Resolver
User: {
posts: (parent, _, { loaders }) => {
return loaders.postsByUserLoader.load(parent.id)
},
}
4. Mutations
Design patterns
type Mutation {
# 1. Input type pour les arguments complexes
createUser(input: CreateUserInput!): UserPayload!
# 2. Payload type (évolutif)
updateUser(id: ID!, input: UpdateUserInput!): UserPayload!
# 3. Retourner les erreurs dans le payload
deleteUser(id: ID!): DeleteUserPayload!
}
type UserPayload {
user: User
errors: [UserError!]
}
type UserError {
field: String!
message: String!
code: String!
}
type DeleteUserPayload {
deletedId: ID
errors: [UserError!]
}
Implémentation mutation
Mutation: {
createUser: async (_, { input }, { db }) => {
// Validation
const errors = await validateUserInput(input)
if (errors.length > 0) {
return { user: null, errors }
}
try {
const user = await db.user.create({ data: input })
return { user, errors: [] }
} catch (err) {
return {
user: null,
errors: [{ field: 'email', message: 'Already exists', code: 'CONFLICT' }],
}
}
},
}
5. Subscriptions (WebSocket)
Schema
type Subscription {
postCreated: Post!
userUpdated(id: ID!): User!
notificationReceived: Notification!
}
type Notification {
id: ID!
type: String!
message: String!
createdAt: DateTime!
}
Implémentation (Apollo Server avec WebSocket)
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import { WebSocketServer } from 'ws'
import { useServer } from 'graphql-ws/lib/use/ws'
import express from 'express'
import http from 'http'
const app = express()
const httpServer = http.createServer(app)
// WebSocket server
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
})
const serverCleanup = useServer({ schema, context }, wsServer)
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose()
},
}
},
},
],
})
// PubSub pour les événements
import { PubSub } from 'graphql-subscriptions'
const pubsub = new PubSub()
// Résolver Subscription
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
},
userUpdated: {
subscribe: (_, { id }) => pubsub.asyncIterator([`USER_UPDATED:${id}`]),
},
}
// Dans une mutation
Mutation: {
createPost: async (_, { input }, { db }) => {
const post = await db.post.create({ data: input })
pubsub.publish('POST_CREATED', { postCreated: post })
return post
},
}
6. Apollo Server
Apollo Server 4 (2022+)
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { buildSubgraphSchema } from '@apollo/subgraph'
const server = new ApolloServer({
typeDefs,
resolvers,
// Plugins
plugins: [
ApolloServerPluginInlineTrace(),
ApolloServerPluginUsageReporting({ fieldLevelInstrumentation: () => true }),
],
// Validation
validationRules: [depthLimit(10), costLimit(1000)],
// Formatage des erreurs
formatError: (formattedError, error) => {
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
delete formattedError.extensions.stacktrace
}
return formattedError
},
})
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
db: prisma,
user: await authenticate(req),
}),
})
Plugins recommandés
- ApolloServerPluginDrainHttpServer : graceful shutdown
- ApolloServerPluginInlineTrace : Apollo Studio tracing
- ApolloServerPluginLandingPageLocal : Apollo Sandbox (dev)
- ApolloServerPluginCacheControl : cache HTTP
- responseCachePlugin : cache Apollo (RESTDataSource)
7. Federation
Architecture microservices GraphQL
Diagramme en cours de génération...
Subgraph (Users Service)
# users.graphql
extend type Query {
users: [User!]!
user(id: ID!): User
}
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
Subgraph (Posts Service)
# posts.graphql
extend type Query {
posts: [Post!]!
post(id: ID!): Post
}
type Post @key(fields: "id") {
id: ID!
title: String!
content: String
authorId: ID!
author: User! @requires(fields: "authorId")
}
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
Gateway
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway'
import { ApolloServer } from '@apollo/server'
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://users-service:4001/graphql' },
{ name: 'posts', url: 'http://posts-service:4002/graphql' },
{ name: 'reviews', url: 'http://reviews-service:4003/graphql' },
],
}),
})
const server = new ApolloServer({
gateway,
plugins: [ApolloServerPluginUsageReporting()],
})
8. Security
Validation & sanitization
// Depth limiting (évite les requêtes profondes)
import depthLimit from 'graphql-depth-limit'
const validationRules = [depthLimit(10)]
// Query complexity (coût par champ)
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity'
const complexityRule = createComplexityRule({
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
maximumComplexity: 1000,
onComplete: (complexity) => {
console.log(`Query complexity: ${complexity}`)
},
})
Rate limiting
import { useResponseCache } from '@envelop/response-cache'
import { useRateLimiter } from '@envelop/rate-limiter'
const rateLimiter = useRateLimiter({
points: 100,
duration: 60,
keyFn: (context) => context.user?.id || context.ip,
})
// Per-field rate limiting
type Query {
expensiveQuery: Data @rateLimit(limit: 10, duration: 60)
}
Authentication & Authorization
type Query {
me: User! @auth(requires: AUTHENTICATED)
adminData: [Data!]! @auth(requires: ADMIN)
}
# Directive personnalisée
directive @auth(requires: Role!) on FIELD_DEFINITION
enum Role { AUTHENTICATED ADMIN MODERATOR }
9. Cost Analysis
Query complexity
# Requête qui coûte cher
{
users { # 1
posts { # 100 (si 100 users)
tags { # 500 (si 5 tags par post)
name
}
}
}
}
# Coût total : 1 + 100 + 500 = 601
Stratégies
- Depth limit : pas de requêtes de profondeur > 10
- Complexity limit : coût max par requête (ex: 1000)
- Rate limiting : X requêtes par minute
- Timeout : couper les requêtes longues (> 10s)
- Persisted queries : requêtes pré-approuvées
- Query whitelist : seulement les requêtes connues
Outils
// Persisted queries
import { createPersistedQueryPlugin } from 'graphql-persisted-queries'
const persistedQueryPlugin = createPersistedQueryPlugin({
ttl: 86400, // Cache 24h
generateHash: ({ query, variables }) => hash(query),
})
Références
- GraphQL Specification (spec.graphql.org)
- Apollo Server Documentation (apollographql.com/docs)
- dataLoader (github.com/graphql/dataloader)
- How to GraphQL (howtographql.com)