AzDev

VI - Accès aux bases de données avec Prisma ORM dans Express

L'une des tâches les plus courantes lors du développement d'applications web avec Express est l'interaction avec les bases de données. Pour simplifier cette interaction et améliorer la productivité des développeurs, les ORM (Object-Relational Mapping) et ODM (Object-Document Mapping) sont devenus des outils incontournables dans l'écosystème Node.js.

Publié le
VI - Accès aux bases de données avec Prisma ORM dans Express

1. Introduction

Qu'est-ce que Prisma ? Prisma est un ORM/ODM de nouvelle génération qui simplifie l'accès aux bases de données en proposant une approche déclarative de la modélisation des données et une API type-safe pour interagir avec elles.

Prisma se compose de trois outils principaux :

  • Prisma Schema : Un langage de définition de schéma (SDL) déclaratif pour modéliser vos données
  • Prisma Client : Un client de base de données auto-généré et type-safe basé sur votre schéma
  • Prisma CLI : Un outil en ligne de commande pour initialiser, générer et migrer votre base de données

Cet ORM moderne pour Node.js et TypeScript se distingue par sa simplicité d'utilisation, ses fonctionnalités avancées et sa capacité à travailler avec différents types de bases de données (relationnelles comme MySQL et non-relationnelles comme MongoDB). Il permet aux développeurs de définir leurs modèles de données dans un schéma déclaratif, et génère ensuite un client type-safe qui offre une API intuitive pour interagir avec la base de données.

Dans ce chapitre, nous explorerons comment intégrer Prisma dans une application Express, en couvrant à la fois son utilisation avec MySQL (ORM) et avec MongoDB (ODM). Nous aborderons la configuration, la modélisation des données, les opérations CRUD (Create, Read, Update, Delete), les relations entre entités, et les bonnes pratiques pour optimiser les performances de votre application.

2. Présentation de Prisma

2.1. Avantages de Prisma

  • Type-safety : Détection des erreurs à la compilation plutôt qu'à l'exécution
  • Auto-complétion dans les éditeurs de code comme VS Code
  • Requêtes intuitives avec une API fluide
  • Relations simplifiées entre les entités
  • Migrations automatisées de la base de données
  • Support multi-bases de données (MySQL, PostgreSQL, SQLite, SQL Server et MongoDB)
  • Performance optimisée grâce à un pooling de connexions intelligent

2.2. Installation de Prisma

Pour commencer avec Prisma dans un projet Express, installez les dépendances nécessaires :

1# Initialiser un projet Node.js si ce n'est pas déjà fait
2npm init -y
3
4# Installer Express
5npm install express
6
7# Installer Prisma comme dépendance de développement
8npm install prisma --save-dev
9
10# Installer le client Prisma comme dépendance de production
11npm install @prisma/client

Initialisez ensuite Prisma dans votre projet :

1npx prisma init

Cette commande crée un dossier prisma avec un fichier schema.prisma initial et un fichier .env pour stocker vos variables d'environnement, notamment l'URL de connexion à votre base de données.

3. Utilisation de Prisma avec MySQL (ORM)

3.1. Configuration pour MySQL

Pour configurer Prisma avec MySQL, modifiez le fichier .env pour définir l'URL de connexion :

1DATABASE_URL="mysql://utilisateur:motdepasse@localhost:3306/nom_base_de_donnees"

Ensuite, dans le fichier schema.prisma, spécifiez le fournisseur de base de données :

1generator client {
2  provider = "prisma-client-js"
3}
4
5datasource db {
6  provider = "mysql"
7  url      = env("DATABASE_URL")
8}

3.2. Définition des modèles

Définissez vos modèles dans le fichier schema.prisma en utilisant la syntaxe de Prisma Schema :

1model User {
2  id        Int      @id @default(autoincrement())
3  email     String   @unique
4  name      String?
5  password  String
6  createdAt DateTime @default(now()) @map("created_at")
7  updatedAt DateTime @updatedAt @map("updated_at")
8  posts     Post[]
9
10  @@map("users")
11}
12
13model Post {
14  id        Int      @id @default(autoincrement())
15  title     String
16  content   String?
17  published Boolean  @default(false)
18  createdAt DateTime @default(now()) @map("created_at")
19  updatedAt DateTime @updatedAt @map("updated_at")
20  authorId  Int      @map("author_id")
21  author    User     @relation(fields: [authorId], references: [id])
22
23  @@map("posts")
24}

Dans cet exemple :

  • Nous définissons deux modèles : User et Post
  • Nous spécifions les champs, leurs types et les attributs (comme @id, @unique, @default)
  • Nous établissons une relation un-à-plusieurs entre User et Post (un utilisateur peut avoir plusieurs posts)
  • Nous utilisons @@map pour définir le nom des tables dans la base de données

3.3. Génération du client et migration

Après avoir défini vos modèles, générez le client Prisma et effectuez la migration de la base de données :

1# Générer le client Prisma (Optionnel) voir ↓
2npx prisma generate
3
4# Créer une migration et l'appliquer
5npx prisma migrate dev --name init

La commande migrate dev effectue plusieurs actions :

  1. Sauvegarde vos migrations dans le dossier prisma/migrations
  2. Applique les migrations à votre base de données
  3. Régénère le client Prisma

3.4. Intégration avec Express

Voici comment intégrer Prisma dans une application Express pour effectuer des opérations CRUD :

1const express = require('express');
2const { PrismaClient } = require('@prisma/client');
3
4const app = express();
5const prisma = new PrismaClient();
6
7app.use(express.json());
8
9// Middleware pour gérer les erreurs Prisma
10function handlePrismaError(error, res) {
11  console.error('Erreur de base de données:', error);
12  
13  if (error.code === 'P2002') {
14    return res.status(409).json({ 
15      error: 'Une entrée avec cette valeur existe déjà' 
16    });
17  }
18  
19  if (error.code === 'P2025') {
20    return res.status(404).json({ 
21      error: 'Ressource non trouvée' 
22    });
23  }
24  
25  return res.status(500).json({ 
26    error: 'Erreur interne du serveur' 
27  });
28}
29
30// Créer un utilisateur
31app.post('/users', async (req, res) => {
32  const { email, name, password } = req.body;
33  
34  try {
35    const user = await prisma.user.create({
36      data: {
37        email,
38        name,
39        password, // Dans une vraie application, hashez le mot de passe!
40      },
41    });
42    
43    res.status(201).json(user);
44  } catch (error) {
45    handlePrismaError(error, res);
46  }
47});
48
49// Récupérer tous les utilisateurs
50app.get('/users', async (req, res) => {
51  try {
52    const users = await prisma.user.findMany({
53      select: {
54        id: true,
55        email: true,
56        name: true,
57        createdAt: true,
58        // Ne pas sélectionner le mot de passe pour des raisons de sécurité
59      },
60    });
61    
62    res.json(users);
63  } catch (error) {
64    handlePrismaError(error, res);
65  }
66});
67
68// Récupérer un utilisateur par ID
69app.get('/users/:id', async (req, res) => {
70  const { id } = req.params;
71  
72  try {
73    const user = await prisma.user.findUnique({
74      where: {
75        id: parseInt(id),
76      },
77      select: {
78        id: true,
79        email: true,
80        name: true,
81        createdAt: true,
82        posts: true, // Inclure les posts de l'utilisateur
83      },
84    });
85    
86    if (!user) {
87      return res.status(404).json({ error: 'Utilisateur non trouvé' });
88    }
89    
90    res.json(user);
91  } catch (error) {
92    handlePrismaError(error, res);
93  }
94});
95
96// Mettre à jour un utilisateur
97app.put('/users/:id', async (req, res) => {
98  const { id } = req.params;
99  const { email, name } = req.body;
100  
101  try {
102    const user = await prisma.user.update({
103      where: {
104        id: parseInt(id),
105      },
106      data: {
107        email,
108        name,
109      },
110    });
111    
112    res.json(user);
113  } catch (error) {
114    handlePrismaError(error, res);
115  }
116});
117
118// Supprimer un utilisateur
119app.delete('/users/:id', async (req, res) => {
120  const { id } = req.params;
121  
122  try {
123    await prisma.user.delete({
124      where: {
125        id: parseInt(id),
126      },
127    });
128    
129    res.status(204).end();
130  } catch (error) {
131    handlePrismaError(error, res);
132  }
133});
134
135// Routes pour les posts
136app.post('/posts', async (req, res) => {
137  const { title, content, published, authorId } = req.body;
138  
139  try {
140    const post = await prisma.post.create({
141      data: {
142        title,
143        content,
144        published: published || false,
145        author: {
146          connect: { id: parseInt(authorId) },
147        },
148      },
149    });
150    
151    res.status(201).json(post);
152  } catch (error) {
153    handlePrismaError(error, res);
154  }
155});
156
157app.get('/posts', async (req, res) => {
158  try {
159    const posts = await prisma.post.findMany({
160      include: {
161        author: {
162          select: {
163            id: true,
164            name: true,
165            email: true,
166          },
167        },
168      },
169    });
170    
171    res.json(posts);
172  } catch (error) {
173    handlePrismaError(error, res);
174  }
175});
176
177// Démarrer le serveur
178const PORT = process.env.PORT || 3000;
179app.listen(PORT, () => {
180  console.log(`Serveur en écoute sur le port ${PORT}`);
181});
182
183// Gérer la fermeture propre de la connexion Prisma
184process.on('SIGINT', async () => {
185  await prisma.$disconnect();
186  process.exit();
187});

3.5. Opérations avancées avec Prisma et MySQL

Requêtes avec filtres et tri

1// Récupérer les posts publiés, triés par date de création décroissante
2app.get('/posts/published', async (req, res) => {
3  try {
4    const posts = await prisma.post.findMany({
5      where: {
6        published: true,
7      },
8      orderBy: {
9        createdAt: 'desc',
10      },
11      include: {
12        author: {
13          select: {
14            name: true,
15          },
16        },
17      },
18    });
19    
20    res.json(posts);
21  } catch (error) {
22    handlePrismaError(error, res);
23  }
24});

Pagination

1app.get('/posts/paginated', async (req, res) => {
2  const { page = 1, limit = 10 } = req.query;
3  const skip = (parseInt(page) - 1) * parseInt(limit);
4  
5  try {
6    const [posts, total] = await Promise.all([
7      prisma.post.findMany({
8        skip,
9        take: parseInt(limit),
10        orderBy: {
11          createdAt: 'desc',
12        },
13      }),
14      prisma.post.count(),
15    ]);
16    
17    res.json({
18      posts,
19      meta: {
20        total,
21        page: parseInt(page),
22        limit: parseInt(limit),
23        pages: Math.ceil(total / parseInt(limit)),
24      },
25    });
26  } catch (error) {
27    handlePrismaError(error, res);
28  }
29});

Transactions

Les transactions sont essentielles pour maintenir l'intégrité des données lors d'opérations interdépendantes :

1app.post('/users/:id/posts', async (req, res) => {
2  const { id } = req.params;
3  const { title, content } = req.body;
4  
5  try {
6    // Utiliser une transaction pour s'assurer que les deux opérations réussissent ou échouent ensemble
7    const result = await prisma.$transaction(async (tx) => {
8      // Vérifier que l'utilisateur existe
9      const user = await tx.user.findUnique({
10        where: { id: parseInt(id) },
11      });
12      
13      if (!user) {
14        throw new Error('Utilisateur non trouvé');
15      }
16      
17      // Créer le post
18      const post = await tx.post.create({
19        data: {
20          title,
21          content,
22          author: {
23            connect: { id: parseInt(id) },
24          },
25        },
26      });
27      
28      // Mettre à jour le compteur de posts de l'utilisateur (exemple hypothétique: le champ postCount n'existe pas)
29      await tx.user.update({
30        where: { id: parseInt(id) },
31        data: {
32          postCount: { increment: 1 },
33        },
34      });
35      
36      return post;
37    });
38    
39    res.status(201).json(result);
40  } catch (error) {
41    console.error('Erreur de transaction:', error);
42    res.status(400).json({ error: error.message });
43  }
44});

4. Utilisation de Prisma avec MongoDB (ODM)

4.1. Configuration pour MongoDB

Pour utiliser Prisma avec MongoDB, modifiez votre fichier .env :

DATABASE_URL="mongodb+srv://utilisateur:motdepasse@cluster0.mongodb.net/nom_base_de_donnees?retryWrites=true&w=majority"

Et mettez à jour le fichier schema.prisma :

1generator client {
2  provider = "prisma-client-js"
3}
4
5datasource db {
6  provider = "mongodb"
7  url      = env("DATABASE_URL")
8}

4.2. Définition des modèles pour MongoDB

Les modèles pour MongoDB sont similaires à ceux pour les bases de données relationnelles, mais avec quelques différences importantes, notamment l'utilisation d'identifiants sous forme de chaînes de caractères (ObjectId) :

1model User {
2  id        String   @id @default(auto()) @map("_id") @db.ObjectId
3  email     String   @unique
4  name      String?
5  password  String
6  createdAt DateTime @default(now())
7  updatedAt DateTime @updatedAt
8  posts     Post[]
9}
10
11model Post {
12  id        String   @id @default(auto()) @map("_id") @db.ObjectId
13  title     String
14  content   String?
15  published Boolean  @default(false)
16  createdAt DateTime @default(now())
17  updatedAt DateTime @updatedAt
18  authorId  String   @db.ObjectId
19  author    User     @relation(fields: [authorId], references: [id])
20  tags      String[]
21}

Notez les différences par rapport au modèle MySQL :

  • Utilisation de @db.ObjectId pour les identifiants MongoDB
  • Mapping de _id avec @map("_id") pour suivre la convention MongoDB
  • Ajout d'un champ tags de type array, spécifique à MongoDB

4.3. Synchronisation du schéma

Avec MongoDB, au lieu d'utiliser des migrations, vous pouvez utiliser db push pour synchroniser votre schéma :

1npx prisma db push

Cette commande synchronise votre schéma avec la base de données MongoDB sans créer de fichiers de migration.

4.4. Intégration avec Express pour MongoDB

L'intégration avec Express est très similaire à celle pour MySQL, avec quelques adaptations pour les spécificités de MongoDB :

1const express = require('express');
2const { PrismaClient } = require('@prisma/client');
3
4const app = express();
5const prisma = new PrismaClient();
6
7app.use(express.json());
8
9// Créer un utilisateur
10app.post('/users', async (req, res) => {
11  const { email, name, password } = req.body;
12  
13  try {
14    const user = await prisma.user.create({
15      data: {
16        email,
17        name,
18        password, // À hasher dans une vraie application
19      },
20    });
21    
22    res.status(201).json(user);
23  } catch (error) {
24    console.error(error);
25    res.status(500).json({ error: 'Erreur lors de la création de l\'utilisateur' });
26  }
27});
28
29// Récupérer un utilisateur avec ses posts
30app.get('/users/:id', async (req, res) => {
31  const { id } = req.params;
32  
33  try {
34    const user = await prisma.user.findUnique({
35      where: {
36        id,
37      },
38      include: {
39        posts: true,
40      },
41    });
42    
43    if (!user) {
44      return res.status(404).json({ error: 'Utilisateur non trouvé' });
45    }
46    
47    res.json(user);
48  } catch (error) {
49    console.error(error);
50    res.status(500).json({ error: 'Erreur lors de la récupération de l\'utilisateur' });
51  }
52});
53
54// Créer un post avec des tags (spécifique à MongoDB)
55app.post('/posts', async (req, res) => {
56  const { title, content, published, authorId, tags } = req.body;
57  
58  try {
59    const post = await prisma.post.create({
60      data: {
61        title,
62        content,
63        published: published || false,
64        author: {
65          connect: { id: authorId },
66        },
67        tags: tags || [],
68      },
69    });
70    
71    res.status(201).json(post);
72  } catch (error) {
73    console.error(error);
74    res.status(500).json({ error: 'Erreur lors de la création du post' });
75  }
76});
77
78// Rechercher des posts par tag (spécifique à MongoDB)
79app.get('/posts/tags/:tag', async (req, res) => {
80  const { tag } = req.params;
81  
82  try {
83    const posts = await prisma.post.findMany({
84      where: {
85        tags: {
86          has: tag,
87        },
88      },
89      include: {
90        author: {
91          select: {
92            name: true,
93          },
94        },
95      },
96    });
97    
98    res.json(posts);
99  } catch (error) {
100    console.error(error);
101    res.status(500).json({ error: 'Erreur lors de la recherche des posts' });
102  }
103});
104
105// Démarrer le serveur
106const PORT = process.env.PORT || 3000;
107app.listen(PORT, () => {
108  console.log(`Serveur en écoute sur le port ${PORT}`);
109});

4.5. Opérations avancées avec Prisma et MongoDB

Recherche texte

MongoDB offre des capacités de recherche texte que vous pouvez exploiter via Prisma :

1app.get('/posts/search', async (req, res) => {
2  const { query } = req.query;
3  
4  if (!query) {
5    return res.status(400).json({ error: 'Un terme de recherche est requis' });
6  }
7  
8  try {
9    // Recherche dans les titres et contenus
10    const posts = await prisma.post.findMany({
11      where: {
12        OR: [
13          { title: { contains: query, mode: 'insensitive' } },
14          { content: { contains: query, mode: 'insensitive' } },
15        ],
16      },
17      include: {
18        author: {
19          select: {
20            name: true,
21          },
22        },
23      },
24    });
25    
26    res.json(posts);
27  } catch (error) {
28    console.error(error);
29    res.status(500).json({ error: 'Erreur lors de la recherche' });
30  }
31});

Agrégations

Bien que Prisma ne prenne pas directement en charge toutes les opérations d'agrégation de MongoDB, vous pouvez utiliser $queryRaw pour des requêtes complexes :

1app.get('/posts/stats', async (req, res) => {
2  try {
3    // Utiliser $queryRaw pour exécuter une agrégation MongoDB
4    const stats = await prisma.$queryRaw`
5      db.Post.aggregate([
6        {
7          $group: {
8            _id: "$authorId",
9            count: { $sum: 1 },
10            averageContentLength: { $avg: { $strLenCP: "$content" } }
11          }
12        },
13        {
14          $lookup: {
15            from: "User",
16            localField: "_id",
17            foreignField: "_id",
18            as: "author"
19          }
20        },
21        {
22          $unwind: "$author"
23        },
24        {
25          $project: {
26            _id: 0,
27            authorId: "$_id",
28            authorName: "$author.name",
29            postCount: "$count",
30            averageContentLength: 1
31          }
32        }
33      ]
34    `;
35    
36    res.json(stats);
37  } catch (error) {
38    console.error(error);
39    res.status(500).json({ error: 'Erreur lors de l\'obtention des statistiques' });
40  }
41});

5. Bonnes pratiques avec Prisma

5.1. Gestion des instances Prisma

Il est recommandé de créer une seule instance de PrismaClient pour toute votre application :

1// db.js
2const { PrismaClient } = require('@prisma/client');
3
4const prisma = new PrismaClient();
5
6module.exports = prisma;
1// userRoutes.js
2const express = require('express');
3const router = express.Router();
4const prisma = require('./db');
5
6router.get('/users', async (req, res) => {
7  const users = await prisma.user.findMany();
8  res.json(users);
9});
10
11module.exports = router;

5.2. Middleware Prisma

Prisma permet de définir des middlewares pour intercepter et modifier les requêtes :

1// Middleware pour le logging
2prisma.$use(async (params, next) => {
3  const before = Date.now();
4  
5  const result = await next(params);
6  
7  const after = Date.now();
8  console.log(`Requête ${params.model}.${params.action} a pris ${after - before}ms`);
9  
10  return result;
11});
12
13// Middleware pour la soft deletion
14prisma.$use(async (params, next) => {
15  if (params.model === 'Post') {
16    if (params.action === 'delete') {
17      // Convertir delete en update
18      params.action = 'update';
19      params.args.data = { deleted: true };
20    }
21    if (params.action === 'findUnique' || params.action === 'findMany') {
22      // Ajouter le filtre deleted: false
23      params.args.where = {
24        ...params.args.where,
25        deleted: false,
26      };
27    }
28  }
29  
30  return next(params);
31});

5.3. Structuration du code avec les repositories

Pour les applications de grande taille, il est recommandé d'organiser votre code en utilisant le pattern Repository :

1// repositories/userRepository.js
2const prisma = require('../db');
3
4class UserRepository {
5  async findAll(options = {}) {
6    const { skip, take, where, include, orderBy } = options;
7    
8    return prisma.user.findMany({
9      skip,
10      take,
11      where,
12      include,
13      orderBy,
14    });
15  }
16  
17  async findById(id, include = {}) {
18    return prisma.user.findUnique({
19      where: { id },
20      include,
21    });
22  }
23  
24  async create(data) {
25    return prisma.user.create({
26      data,
27    });
28  }
29  
30  async update(id, data) {
31    return prisma.user.update({
32      where: { id },
33      data,
34    });
35  }
36  
37  async delete(id) {
38    return prisma.user.delete({
39      where: { id },
40    });
41  }
42}
43
44module.exports = new UserRepository();
1// controllers/userController.js
2const userRepository = require('../repositories/userRepository');
3
4exports.getAllUsers = async (req, res) => {
5  try {
6    const users = await userRepository.findAll();
7    res.json(users);
8  } catch (error) {
9    res.status(500).json({ error: error.message });
10  }
11};
12
13exports.getUserById = async (req, res) => {
14  try {
15    const user = await userRepository.findById(req.params.id, { posts: true });
16    
17    if (!user) {
18      return res.status(404).json({ error: 'Utilisateur non trouvé' });
19    }
20    
21    res.json(user);
22  } catch (error) {
23    res.status(500).json({ error: error.message });
24  }
25};
26
27// Autres méthodes de contrôleur...

5.4. Performance et optimisation

Sélection des champs

Sélectionnez uniquement les champs dont vous avez besoin pour réduire la taille des données transférées :

1const users = await prisma.user.findMany({
2  select: {
3    id: true,
4    name: true,
5    email: true,
6    // Ne pas inclure le mot de passe et autres champs sensibles
7  },
8});

Chargement différé vs chargement anticipé

Choisissez judicieusement entre le chargement anticipé (eager loading) et le chargement différé (lazy loading) :

1// Chargement anticipé: récupérer l'utilisateur avec ses posts en une seule requête
2const userWithPosts = await prisma.user.findUnique({
3  where: { id },
4  include: {
5    posts: true,
6  },
7});
8
9// Chargement différé: récupérer les posts séparément si nécessaire
10const user = await prisma.user.findUnique({
11  where: { id },
12});
13
14if (needPosts) {
15  const posts = await prisma.post.findMany({
16    where: {
17      authorId: user.id,
18    },
19  });
20}

Pagination pour les grandes collections

Toujours utiliser la pagination pour les grandes collections de données :

1const { page = 1, limit = 10 } = req.query;
2const skip = (parseInt(page) - 1) * parseInt(limit);
3
4const users = await prisma.user.findMany({
5  skip,
6  take: parseInt(limit),
7  orderBy: {
8    createdAt: 'desc',
9  },
10});
11
12const total = await prisma.user.count();

6. Conclusion

Prisma offre une solution élégante et puissante pour interagir avec les bases de données relationnelles (comme MySQL) et non relationnelles (comme MongoDB) dans les applications Express. Grâce à son approche déclarative pour la définition des schémas et son API type-safe, il réduit considérablement la complexité du code et minimise les risques d'erreurs à l'exécution.

Dans ce chapitre, nous avons exploré les concepts fondamentaux de Prisma, sa configuration avec MySQL et MongoDB, les opérations CRUD de base, ainsi que des fonctionnalités plus avancées comme les transactions, les relations, et les optimisations de performance. Nous avons également abordé des bonnes pratiques pour organiser votre code dans des applications plus complexes.

L'intégration de Prisma dans vos applications Express vous permettra de développer plus rapidement, avec moins d'erreurs, et d'écrire un code plus maintenable sur le long terme. Que vous travailliez avec des bases de données relationnelles traditionnelles ou des bases de données NoSQL modernes, Prisma vous offre une interface unifiée et intuitive pour gérer vos données.

7. Exercices pratiques

Exercice 1 : API REST avec Prisma et MySQL

Créez une API REST complète pour gérer une bibliothèque avec les entités suivantes :

  • Livres (titre, année de publication, ISBN, description)
  • Auteurs (nom, biographie, date de naissance)
  • Catégories (nom, description)

Établissez les relations appropriées entre ces entités (un livre peut avoir plusieurs auteurs, un livre appartient à une ou plusieurs catégories) et implémentez des routes pour :

  1. Ajouter/modifier/supprimer des livres, auteurs et catégories
  2. Lister tous les livres avec leurs auteurs et catégories
  3. Rechercher des livres par titre, auteur ou catégorie
  4. Trier les livres par année de publication ou par titre

Exercice 2 : API de blog avec Prisma et MongoDB

Développez une API pour un système de blog avec les fonctionnalités suivantes :

  1. Gestion des utilisateurs (inscription, connexion, profil)
  2. Publication d'articles avec support des tags
  3. Système de commentaires (avec commentaires imbriqués)
  4. Fonctionnalité de recherche texte
  5. Système de likes/

8. Ressources et références