MFormations
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

TypeDescription
ScalarInt, Float, String, Boolean, ID
ObjectStructure avec champs
InputObjet passé en argument
EnumEnsemble de valeurs
UnionUn type parmi plusieurs
InterfaceContrat 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

  1. Depth limit : pas de requêtes de profondeur > 10
  2. Complexity limit : coût max par requête (ex: 1000)
  3. Rate limiting : X requêtes par minute
  4. Timeout : couper les requêtes longues (> 10s)
  5. Persisted queries : requêtes pré-approuvées
  6. 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)