Retour au blog

GraphQL en 2025 : Guide Complet avec Apollo Server Pour APIs Optimisées

Salut HaWkers, GraphQL continue de gagner en adoption en 2025 comme une alternative puissante au REST traditionnel. Si vous n'avez pas encore exploré cette technologie ou souhaitez approfondir vos connaissances, c'est le moment idéal.

Vous êtes-vous déjà frustré avec des APIs REST qui retournent trop ou pas assez de données ? GraphQL résout exactement ce problème, permettant au client de demander uniquement les données dont il a besoin.

Pourquoi GraphQL en 2025

GraphQL n'est pas venu pour remplacer REST complètement, mais offre des avantages significatifs dans des scénarios spécifiques.

GraphQL

Avantages de GraphQL

Pour le Client :

  • Demande exactement les données nécessaires
  • Une seule requête pour des données liées
  • Typage fort et documentation automatique
  • Introspection du schema

Pour le Serveur :

  • Schema comme contrat d'API
  • Validation automatique des queries
  • Évolution de l'API sans versioning
  • Meilleure observabilité d'usage

Quand Utiliser GraphQL

Utilisez GraphQL quand :

  • Différents clients ont besoin de données différentes
  • Plusieurs ressources liées sont nécessaires
  • La performance réseau est critique (mobile)
  • API publique avec beaucoup de consommateurs

Préférez REST quand :

  • Opérations CRUD simples
  • Le caching HTTP est prioritaire
  • L'équipe n'a pas d'expérience avec GraphQL
  • Intégration avec des systèmes legacy

Configuration d'Apollo Server

Créons une API GraphQL complète de zéro avec Apollo Server 4 et TypeScript.

Setup Initial

// src/index.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext, Context } from './context';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  const server = new ApolloServer<Context>({
    typeDefs,
    resolvers,
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
    introspection: process.env.NODE_ENV !== 'production'
  });

  await server.start();

  app.use(
    '/graphql',
    cors<cors.CorsRequest>(),
    express.json(),
    expressMiddleware(server, {
      context: createContext
    })
  );

  const PORT = process.env.PORT || 4000;

  await new Promise<void>((resolve) =>
    httpServer.listen({ port: PORT }, resolve)
  );

  console.log(`🚀 Serveur prêt sur http://localhost:${PORT}/graphql`);
}

startServer().catch(console.error);

Définition du Schema

// src/schema.ts
export const typeDefs = `#graphql
  # Types de base
  type Utilisateur {
    id: ID!
    nom: String!
    email: String!
    avatar: String
    posts: [Post!]!
    commentaires: [Commentaire!]!
    creeA: String!
    miseAJourA: String!
  }

  type Post {
    id: ID!
    titre: String!
    contenu: String!
    publie: Boolean!
    auteur: Utilisateur!
    categories: [Categorie!]!
    commentaires: [Commentaire!]!
    vues: Int!
    creeA: String!
    miseAJourA: String!
  }

  type Categorie {
    id: ID!
    nom: String!
    slug: String!
    posts: [Post!]!
  }

  type Commentaire {
    id: ID!
    contenu: String!
    auteur: Utilisateur!
    post: Post!
    creeA: String!
  }

  # Inputs pour mutations
  input CreerUtilisateurInput {
    nom: String!
    email: String!
    motDePasse: String!
    avatar: String
  }

  input MettreAJourUtilisateurInput {
    nom: String
    avatar: String
  }

  input CreerPostInput {
    titre: String!
    contenu: String!
    publie: Boolean = false
    categorieIds: [ID!]
  }

  input MettreAJourPostInput {
    titre: String
    contenu: String
    publie: Boolean
    categorieIds: [ID!]
  }

  input FiltrePost {
    publie: Boolean
    auteurId: ID
    categorieId: ID
    recherche: String
  }

  # Pagination
  type InfoPagination {
    total: Int!
    page: Int!
    parPage: Int!
    totalPages: Int!
    aSuivant: Boolean!
    aPrecedent: Boolean!
  }

  type PostsPagines {
    donnees: [Post!]!
    pagination: InfoPagination!
  }

  # Queries
  type Query {
    # Utilisateurs
    utilisateurs: [Utilisateur!]!
    utilisateur(id: ID!): Utilisateur
    utilisateurParEmail(email: String!): Utilisateur

    # Posts
    posts(
      filtre: FiltrePost
      page: Int = 1
      parPage: Int = 10
    ): PostsPagines!
    post(id: ID!): Post

    # Catégories
    categories: [Categorie!]!
    categorie(slug: String!): Categorie
  }

  # Mutations
  type Mutation {
    # Utilisateurs
    creerUtilisateur(input: CreerUtilisateurInput!): Utilisateur!
    mettreAJourUtilisateur(id: ID!, input: MettreAJourUtilisateurInput!): Utilisateur!
    supprimerUtilisateur(id: ID!): Boolean!

    # Posts
    creerPost(input: CreerPostInput!): Post!
    mettreAJourPost(id: ID!, input: MettreAJourPostInput!): Post!
    supprimerPost(id: ID!): Boolean!
    publierPost(id: ID!): Post!

    # Commentaires
    creerCommentaire(postId: ID!, contenu: String!): Commentaire!
    supprimerCommentaire(id: ID!): Boolean!
  }

  # Subscriptions (optionnel)
  type Subscription {
    nouveauCommentaire(postId: ID!): Commentaire!
    postMisAJour(id: ID!): Post!
  }
`;

Implémentation des Resolvers

// src/resolvers/index.ts
import { utilisateurResolvers } from './utilisateur';
import { postResolvers } from './post';
import { categorieResolvers } from './categorie';
import { commentaireResolvers } from './commentaire';

export const resolvers = {
  Query: {
    ...utilisateurResolvers.Query,
    ...postResolvers.Query,
    ...categorieResolvers.Query
  },
  Mutation: {
    ...utilisateurResolvers.Mutation,
    ...postResolvers.Mutation,
    ...commentaireResolvers.Mutation
  },
  // Field resolvers
  Utilisateur: utilisateurResolvers.Utilisateur,
  Post: postResolvers.Post,
  Categorie: categorieResolvers.Categorie,
  Commentaire: commentaireResolvers.Commentaire
};

// src/resolvers/post.ts
import { Context } from '../context';
import { GraphQLError } from 'graphql';

interface FiltrePost {
  publie?: boolean;
  auteurId?: string;
  categorieId?: string;
  recherche?: string;
}

export const postResolvers = {
  Query: {
    posts: async (
      _: unknown,
      args: { filtre?: FiltrePost; page?: number; parPage?: number },
      { db, utilisateur }: Context
    ) => {
      const { filtre = {}, page = 1, parPage = 10 } = args;
      const offset = (page - 1) * parPage;

      // Construire query dynamique
      let where: any = {};

      if (filtre.publie !== undefined) {
        where.publie = filtre.publie;
      }

      if (filtre.auteurId) {
        where.auteurId = filtre.auteurId;
      }

      if (filtre.recherche) {
        where.OR = [
          { titre: { contains: filtre.recherche, mode: 'insensitive' } },
          { contenu: { contains: filtre.recherche, mode: 'insensitive' } }
        ];
      }

      const [posts, total] = await Promise.all([
        db.post.findMany({
          where,
          skip: offset,
          take: parPage,
          orderBy: { creeA: 'desc' }
        }),
        db.post.count({ where })
      ]);

      const totalPages = Math.ceil(total / parPage);

      return {
        donnees: posts,
        pagination: {
          total,
          page,
          parPage,
          totalPages,
          aSuivant: page < totalPages,
          aPrecedent: page > 1
        }
      };
    },

    post: async (_: unknown, { id }: { id: string }, { db }: Context) => {
      const post = await db.post.findUnique({ where: { id } });

      if (!post) {
        throw new GraphQLError('Post non trouvé', {
          extensions: { code: 'NOT_FOUND' }
        });
      }

      // Incrémenter les vues
      await db.post.update({
        where: { id },
        data: { vues: { increment: 1 } }
      });

      return post;
    }
  },

  Mutation: {
    creerPost: async (
      _: unknown,
      { input }: { input: any },
      { db, utilisateur }: Context
    ) => {
      if (!utilisateur) {
        throw new GraphQLError('Non autorisé', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      const { categorieIds, ...donnees } = input;

      return db.post.create({
        data: {
          ...donnees,
          auteurId: utilisateur.id,
          categories: categorieIds
            ? { connect: categorieIds.map((id: string) => ({ id })) }
            : undefined
        }
      });
    },

    mettreAJourPost: async (
      _: unknown,
      { id, input }: { id: string; input: any },
      { db, utilisateur }: Context
    ) => {
      if (!utilisateur) {
        throw new GraphQLError('Non autorisé', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      const post = await db.post.findUnique({ where: { id } });

      if (!post) {
        throw new GraphQLError('Post non trouvé', {
          extensions: { code: 'NOT_FOUND' }
        });
      }

      if (post.auteurId !== utilisateur.id) {
        throw new GraphQLError('Pas de permission pour modifier ce post', {
          extensions: { code: 'FORBIDDEN' }
        });
      }

      const { categorieIds, ...donnees } = input;

      return db.post.update({
        where: { id },
        data: {
          ...donnees,
          categories: categorieIds
            ? { set: categorieIds.map((catId: string) => ({ id: catId })) }
            : undefined,
          miseAJourA: new Date()
        }
      });
    },

    publierPost: async (
      _: unknown,
      { id }: { id: string },
      { db, utilisateur }: Context
    ) => {
      if (!utilisateur) {
        throw new GraphQLError('Non autorisé', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }

      return db.post.update({
        where: { id },
        data: { publie: true, miseAJourA: new Date() }
      });
    }
  },

  // Field resolvers pour les relations
  Post: {
    auteur: (post: any, _: unknown, { loaders }: Context) => {
      return loaders.utilisateur.load(post.auteurId);
    },

    categories: (post: any, _: unknown, { db }: Context) => {
      return db.categorie.findMany({
        where: { posts: { some: { id: post.id } } }
      });
    },

    commentaires: (post: any, _: unknown, { db }: Context) => {
      return db.commentaire.findMany({
        where: { postId: post.id },
        orderBy: { creeA: 'desc' }
      });
    }
  }
};

DataLoader : Éviter le N+1

Le problème N+1 est l'un des plus grands ennemis de la performance en GraphQL. DataLoader résout cela avec le batching.

// src/loaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';

export function createLoaders(db: PrismaClient) {
  return {
    utilisateur: new DataLoader(async (ids: readonly string[]) => {
      const utilisateurs = await db.utilisateur.findMany({
        where: { id: { in: [...ids] } }
      });

      // Mapper pour maintenir l'ordre
      const utilisateurMap = new Map(utilisateurs.map((u) => [u.id, u]));
      return ids.map((id) => utilisateurMap.get(id) || null);
    }),

    post: new DataLoader(async (ids: readonly string[]) => {
      const posts = await db.post.findMany({
        where: { id: { in: [...ids] } }
      });

      const postMap = new Map(posts.map((p) => [p.id, p]));
      return ids.map((id) => postMap.get(id) || null);
    }),

    // Loader pour posts par auteur (batch par auteurId)
    postsParAuteur: new DataLoader(async (auteurIds: readonly string[]) => {
      const posts = await db.post.findMany({
        where: { auteurId: { in: [...auteurIds] } }
      });

      // Grouper par auteurId
      const postsParAuteur = new Map<string, any[]>();
      posts.forEach((post) => {
        const liste = postsParAuteur.get(post.auteurId) || [];
        liste.push(post);
        postsParAuteur.set(post.auteurId, liste);
      });

      return auteurIds.map((id) => postsParAuteur.get(id) || []);
    }),

    // Loader pour comptage de commentaires
    comptageCommentaires: new DataLoader(async (postIds: readonly string[]) => {
      const comptages = await db.commentaire.groupBy({
        by: ['postId'],
        where: { postId: { in: [...postIds] } },
        _count: { id: true }
      });

      const comptageMap = new Map(
        comptages.map((c) => [c.postId, c._count.id])
      );

      return postIds.map((id) => comptageMap.get(id) || 0);
    })
  };
}

// src/context.ts
import { PrismaClient } from '@prisma/client';
import { createLoaders } from './loaders';
import { verifierToken } from './auth';

const db = new PrismaClient();

export interface Context {
  db: PrismaClient;
  loaders: ReturnType<typeof createLoaders>;
  utilisateur: { id: string; email: string } | null;
}

export async function createContext({ req }: { req: any }): Promise<Context> {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const utilisateur = token ? await verifierToken(token) : null;

  return {
    db,
    loaders: createLoaders(db), // Nouveaux loaders par requête
    utilisateur
  };
}

Queries et Mutations en Pratique

Voyons comment utiliser l'API côté client.

Queries

# Récupérer liste de posts paginée
query ListerPosts($filtre: FiltrePost, $page: Int) {
  posts(filtre: $filtre, page: $page, parPage: 10) {
    donnees {
      id
      titre
      contenu
      publie
      vues
      creeA
      auteur {
        id
        nom
        avatar
      }
      categories {
        id
        nom
        slug
      }
    }
    pagination {
      total
      page
      totalPages
      aSuivant
      aPrecedent
    }
  }
}

# Récupérer post avec tous les détails
query RecupererPost($id: ID!) {
  post(id: $id) {
    id
    titre
    contenu
    publie
    vues
    creeA
    miseAJourA
    auteur {
      id
      nom
      email
      avatar
    }
    categories {
      id
      nom
      slug
    }
    commentaires {
      id
      contenu
      creeA
      auteur {
        id
        nom
        avatar
      }
    }
  }
}

# Récupérer utilisateur avec ses posts
query ProfilUtilisateur($id: ID!) {
  utilisateur(id: $id) {
    id
    nom
    email
    avatar
    creeA
    posts {
      id
      titre
      publie
      vues
      creeA
    }
  }
}

Mutations

# Créer nouveau post
mutation CreerPost($input: CreerPostInput!) {
  creerPost(input: $input) {
    id
    titre
    contenu
    publie
    creeA
  }
}

# Mettre à jour post existant
mutation MettreAJourPost($id: ID!, $input: MettreAJourPostInput!) {
  mettreAJourPost(id: $id, input: $input) {
    id
    titre
    contenu
    publie
    miseAJourA
  }
}

# Publier post
mutation PublierPost($id: ID!) {
  publierPost(id: $id) {
    id
    publie
    miseAJourA
  }
}

# Ajouter commentaire
mutation AjouterCommentaire($postId: ID!, $contenu: String!) {
  creerCommentaire(postId: $postId, contenu: $contenu) {
    id
    contenu
    creeA
    auteur {
      id
      nom
    }
  }
}

Optimisations Avancées

Persisted Queries

Pour la production, les persisted queries améliorent la sécurité et la performance :

// src/plugins/persistedQueries.ts
import { ApolloServerPlugin } from '@apollo/server';
import { createHash } from 'crypto';

const queryStore = new Map<string, string>();

export const persistedQueriesPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document }) {
        if (request.extensions?.persistedQuery) {
          const { sha256Hash } = request.extensions.persistedQuery;

          // Vérifier si la query est enregistrée
          if (!queryStore.has(sha256Hash)) {
            if (request.query) {
              // Enregistrer nouvelle query
              const hash = createHash('sha256')
                .update(request.query)
                .digest('hex');

              if (hash === sha256Hash) {
                queryStore.set(sha256Hash, request.query);
              }
            }
          }
        }
      }
    };
  }
};

Query Complexity

Limiter la complexité des queries évite les abus :

// src/plugins/queryComplexity.ts
import { GraphQLError } from 'graphql';
import {
  fieldExtensionsEstimator,
  getComplexity,
  simpleEstimator
} from 'graphql-query-complexity';

const MAX_COMPLEXITY = 1000;

export function validateQueryComplexity(schema: any, query: any, variables: any) {
  const complexity = getComplexity({
    schema,
    query,
    variables,
    estimators: [
      fieldExtensionsEstimator(),
      simpleEstimator({ defaultComplexity: 1 })
    ]
  });

  if (complexity > MAX_COMPLEXITY) {
    throw new GraphQLError(
      `Query trop complexe : ${complexity}. Maximum autorisé : ${MAX_COMPLEXITY}`,
      { extensions: { code: 'QUERY_TOO_COMPLEX' } }
    );
  }

  return complexity;
}

Caching avec Redis

// src/plugins/caching.ts
import Redis from 'ioredis';
import { createHash } from 'crypto';

const redis = new Redis(process.env.REDIS_URL);

export async function cacheResolver<T>(
  key: string,
  ttl: number,
  resolver: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const result = await resolver();

  await redis.setex(key, ttl, JSON.stringify(result));

  return result;
}

// Usage dans les resolvers
posts: async (_: unknown, args: any, { db }: Context) => {
  const cacheKey = `posts:${createHash('md5').update(JSON.stringify(args)).digest('hex')}`;

  return cacheResolver(cacheKey, 300, async () => {
    return db.post.findMany({ /* ... */ });
  });
}

Gestion des Erreurs

// src/errors.ts
import { GraphQLError } from 'graphql';

export class ErreurValidation extends GraphQLError {
  constructor(message: string, champ?: string) {
    super(message, {
      extensions: {
        code: 'VALIDATION_ERROR',
        champ
      }
    });
  }
}

export class ErreurAuthentification extends GraphQLError {
  constructor(message = 'Non autorisé') {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' }
    });
  }
}

export class ErreurPermission extends GraphQLError {
  constructor(message = 'Pas de permission') {
    super(message, {
      extensions: { code: 'FORBIDDEN' }
    });
  }
}

export class ErreurNonTrouve extends GraphQLError {
  constructor(ressource: string) {
    super(`${ressource} non trouvé`, {
      extensions: { code: 'NOT_FOUND' }
    });
  }
}

Si vous voulez en apprendre davantage sur les architectures backend modernes, consultez l'article Architecture Serverless avec JavaScript qui complète très bien GraphQL.

C'est parti ! 🦅

Commentaires (0)

Cet article n'a pas encore de commentaires. Soyez le premier!

Ajouter des commentaires