Back-end angularforall.com

- GraphQL + Apollo Server : API optimisée Angular

Graphql Apollo Nodejs Angular Api Apollo-Client Apolloserver Typescript Caching
GraphQL + Apollo Server : API optimisée Angular

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.

Exemple :
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

Partager