Construisez une application CRUD complète : API REST Express, MongoDB/MySQL, frontend Angular avec HttpClient, validation, JWT et déploiement Docker.
1. Architecture CRUD et concepts
Un système CRUD (Create, Read, Update, Delete) est le fondement de toute application données. En architecture full-stack moderne, on sépare :
- Backend (API REST) : Express.js, endpoints sécurisés, validation, persistance
- Database : MongoDB (NoSQL) ou MySQL (relationnel), indexation, contraintes
- Frontend (Angular) : HttpClient, modèles TypeScript, gestion d'état locale
Client Angular → POST /api/items (Create)
Client Angular → GET /api/items (Read)
Client Angular → PUT /api/items/:id (Update)
Client Angular → DELETE /api/items/:id (Delete)
Chaque endpoint du backend doit valider les entrées, traiter les erreurs, et retourner des statuts HTTP appropriés (200, 201, 400, 404, 500).
2. Setup Node.js + Express + MongoDB
Initialisation du projet backend
# Créer le dossier et initialiser npm
mkdir crud-fullstack-api
cd crud-fullstack-api
npm init -y
# Installer les dépendances essentielles
npm install express mongoose dotenv cors helmet jsonwebtoken bcryptjs
npm install --save-dev nodemon
# Pour MySQL (optionnel)
npm install mysql2 sequelize
express : Framework HTTP | mongoose : ODM MongoDB | dotenv : Variables d'environnement | cors : Sécurité cross-origin | helmet : En-têtes de sécurité
Structure du projet backend
crud-api/
├── config/
│ └── database.js # Connexion MongoDB/MySQL
├── models/
│ └── Item.js # Modèle Mongoose
├── routes/
│ └── items.js # Routes CRUD
├── controllers/
│ └── itemController.js # Logique métier
├── middleware/
│ ├── authMiddleware.js # Authentification JWT
│ └── errorHandler.js # Gestion erreurs globale
├── .env # Variables secrètes (non commité)
├── .env.example # Template public
├── server.js # Point d'entrée Express
└── package.json
Configuration Express (server.js)
// server.js - Configuration centrale Express
const express = require('express');
const mongoose = require('mongoose');
const dotenv = require('dotenv');
const cors = require('cors');
const helmet = require('helmet');
// Charger les variables d'environnement
dotenv.config();
const app = express();
// ===== Middlewares globaux =====
app.use(helmet()); // En-têtes de sécurité
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:4200', // URL Angular
credentials: true
}));
app.use(express.json({ limit: '10mb' })); // Parser JSON
app.use(express.urlencoded({ limit: '10mb', extended: true }));
// ===== Connexion MongoDB =====
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/crud-db', {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => console.log('✅ MongoDB connecté'))
.catch(err => console.error('❌ Erreur MongoDB:', err));
// ===== Routes =====
app.use('/api/items', require('./routes/items'));
// ===== Gestion d'erreurs globale =====
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: true,
message: err.message || 'Erreur serveur',
status: err.status || 500
});
});
// ===== Démarrage serveur =====
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Serveur en écoute sur port ${PORT}`);
});
Fichier .env (secrets)
# .env - Ne JAMAIS commiter ce fichier
MONGODB_URI=mongodb://localhost:27017/crud-db
# MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/crud-db (Atlas)
# Pour MySQL
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=password
DB_NAME=crud_db
# Variables d'application
PORT=3000
NODE_ENV=development
CORS_ORIGIN=http://localhost:4200
JWT_SECRET=your-secret-key-change-in-prod-12345678
# API keys (si intégrations externes)
GROQ_API_KEY=sk-xxxxx
⚠️ Sécurité critique : Ajouter .env à .gitignore. Jamais commiter les secrets dans le repo.
3. Implémentation complète des endpoints CRUD
Modèle Mongoose (models/Item.js)
// models/Item.js - Schéma Mongoose avec validation
const mongoose = require('mongoose');
const itemSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'Le titre est obligatoire'],
trim: true,
minlength: [3, 'Le titre doit avoir au moins 3 caractères'],
maxlength: [100, 'Le titre ne peut pas dépasser 100 caractères']
},
description: {
type: String,
default: '',
maxlength: 500
},
status: {
type: String,
enum: ['pending', 'in-progress', 'completed'],
default: 'pending'
},
priority: {
type: Number,
min: 1,
max: 5,
default: 3
},
dueDate: {
type: Date,
validate: {
validator: function(date) {
return !date || date > new Date(); // Doit être futur
},
message: 'La date doit être dans le futur'
}
},
tags: {
type: [String],
default: []
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User', // Relation avec User (si authentification)
required: false
}
},
{
timestamps: true // Ajoute createdAt et updatedAt
}
);
// Index pour les recherches performantes
itemSchema.index({ userId: 1, createdAt: -1 });
itemSchema.index({ status: 1 });
itemSchema.index({ title: 'text', description: 'text' }); // Full-text search
module.exports = mongoose.model('Item', itemSchema);
Contrôleur (controllers/itemController.js)
// controllers/itemController.js - Logique métier CRUD
const Item = require('../models/Item');
// ===== CREATE - Créer un nouvel item =====
exports.createItem = async (req, res, next) => {
try {
const { title, description, priority, dueDate, tags } = req.body;
// Validation simple (la validation Mongoose fera le reste)
if (!title || title.trim().length === 0) {
return res.status(400).json({
error: true,
message: 'Le titre est obligatoire'
});
}
// Créer l'item
const newItem = new Item({
title: title.trim(),
description: description || '',
priority: priority || 3,
dueDate: dueDate || null,
tags: tags || [],
userId: req.user?.id || null // Si authentification
});
// Sauvegarder
await newItem.save();
res.status(201).json({
error: false,
message: 'Item créé avec succès',
data: newItem
});
} catch (err) {
// Erreur Mongoose validation
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
return res.status(400).json({
error: true,
message: 'Erreur de validation',
details: messages
});
}
next(err);
}
};
// ===== READ - Lire tous les items avec pagination et filtres =====
exports.getItems = async (req, res, next) => {
try {
const { page = 1, limit = 10, status, sortBy = '-createdAt' } = req.query;
const skip = (page - 1) * limit;
// Construire les filtres
const filter = {};
if (status) filter.status = status;
if (req.user?.id) filter.userId = req.user.id; // Items de l'utilisateur
// Requête avec pagination et tri
const items = await Item.find(filter)
.sort(sortBy)
.skip(skip)
.limit(parseInt(limit));
// Total pour la pagination côté client
const total = await Item.countDocuments(filter);
res.json({
error: false,
data: items,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (err) {
next(err);
}
};
// ===== READ - Lire un item spécifique =====
exports.getItemById = async (req, res, next) => {
try {
const { id } = req.params;
// Valider l'ID MongoDB
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return res.status(400).json({
error: true,
message: 'ID invalide'
});
}
const item = await Item.findById(id);
if (!item) {
return res.status(404).json({
error: true,
message: 'Item non trouvé'
});
}
// Vérifier l'autorisation (si userId existe)
if (req.user?.id && item.userId && item.userId.toString() !== req.user.id) {
return res.status(403).json({
error: true,
message: 'Accès non autorisé'
});
}
res.json({
error: false,
data: item
});
} catch (err) {
next(err);
}
};
// ===== UPDATE - Mettre à jour un item =====
exports.updateItem = async (req, res, next) => {
try {
const { id } = req.params;
const { title, description, status, priority, dueDate, tags } = req.body;
// Validation ID
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return res.status(400).json({
error: true,
message: 'ID invalide'
});
}
// Construire l'objet update (ne mettre à jour que les champs envoyés)
const updateData = {};
if (title !== undefined) updateData.title = title.trim();
if (description !== undefined) updateData.description = description;
if (status !== undefined) updateData.status = status;
if (priority !== undefined) updateData.priority = priority;
if (dueDate !== undefined) updateData.dueDate = dueDate;
if (tags !== undefined) updateData.tags = tags;
// Mettre à jour et retourner le document
const updatedItem = await Item.findByIdAndUpdate(
id,
updateData,
{
new: true, // Retourner le document mis à jour
runValidators: true // Lancer les validations
}
);
if (!updatedItem) {
return res.status(404).json({
error: true,
message: 'Item non trouvé'
});
}
res.json({
error: false,
message: 'Item mis à jour avec succès',
data: updatedItem
});
} catch (err) {
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
return res.status(400).json({
error: true,
message: 'Erreur de validation',
details: messages
});
}
next(err);
}
};
// ===== DELETE - Supprimer un item =====
exports.deleteItem = async (req, res, next) => {
try {
const { id } = req.params;
// Validation ID
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return res.status(400).json({
error: true,
message: 'ID invalide'
});
}
const deletedItem = await Item.findByIdAndDelete(id);
if (!deletedItem) {
return res.status(404).json({
error: true,
message: 'Item non trouvé'
});
}
res.json({
error: false,
message: 'Item supprimé avec succès',
data: deletedItem
});
} catch (err) {
next(err);
}
};
// ===== Recherche full-text =====
exports.searchItems = async (req, res, next) => {
try {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.status(400).json({
error: true,
message: 'La requête de recherche doit avoir au moins 2 caractères'
});
}
const items = await Item.find(
{ $text: { $search: q } },
{ score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } });
res.json({
error: false,
data: items,
count: items.length
});
} catch (err) {
next(err);
}
};
Routes (routes/items.js)
// routes/items.js - Définition des endpoints
const express = require('express');
const router = express.Router();
const itemController = require('../controllers/itemController');
const authMiddleware = require('../middleware/authMiddleware');
// Routes publiques
router.post('/', itemController.createItem); // POST /api/items - Créer
router.get('/', itemController.getItems); // GET /api/items - Lister avec filtres
router.get('/search', itemController.searchItems); // GET /api/items/search?q=text
router.get('/:id', itemController.getItemById); // GET /api/items/:id - Lire un
router.put('/:id', itemController.updateItem); // PUT /api/items/:id - Mettre à jour
router.delete('/:id', itemController.deleteItem); // DELETE /api/items/:id - Supprimer
// Routes protégées par JWT (optionnel)
// router.post('/', authMiddleware, itemController.createItem);
// router.put('/:id', authMiddleware, itemController.updateItem);
// router.delete('/:id', authMiddleware, itemController.deleteItem);
module.exports = router;
Table de référence : Endpoints API
| Verbe HTTP | Endpoint | Fonction | Statut succès |
|---|---|---|---|
| POST | /api/items | Créer un item | 201 Created |
| GET | /api/items | Lister tous avec pagination | 200 OK |
| GET | /api/items/:id | Récupérer un item | 200 OK |
| PUT | /api/items/:id | Mettre à jour un item | 200 OK |
| DELETE | /api/items/:id | Supprimer un item | 200 OK |
| GET | /api/items/search?q= | Recherche full-text | 200 OK |
4. Frontend Angular : HttpClient et interceptors
Setup Angular (AppModule)
// app.module.ts - Importer HttpClient et interceptors
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { ItemListComponent } from './components/item-list/item-list.component';
import { ItemFormComponent } from './components/item-form/item-form.component';
// Interceptors personnalisés
import { ErrorInterceptor } from './interceptors/error.interceptor';
import { AuthInterceptor } from './interceptors/auth.interceptor';
@NgModule({
declarations: [
AppComponent,
ItemListComponent,
ItemFormComponent
],
imports: [
BrowserModule,
HttpClientModule,
FormsModule,
ReactiveFormsModule
],
providers: [
// Enregistrer les interceptors globalement
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: ErrorInterceptor,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule { }
Modèle TypeScript (models/item.model.ts)
// models/item.model.ts - Interface TypeScript
export interface Item {
_id?: string; // MongoDB ObjectId
title: string;
description?: string;
status: 'pending' | 'in-progress' | 'completed';
priority: number; // 1-5
dueDate?: Date;
tags?: string[];
createdAt?: Date;
updatedAt?: Date;
}
export interface ApiResponse<T> {
error: boolean;
message?: string;
data?: T;
details?: string[];
}
export interface PaginatedResponse<T> {
error: boolean;
data: T[];
pagination: {
page: number;
limit: number;
total: number;
pages: number;
};
}
Service ItemService (services/item.service.ts)
// services/item.service.ts - Appels HTTP au backend
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { Item, ApiResponse, PaginatedResponse } from '../models/item.model';
@Injectable({
providedIn: 'root'
})
export class ItemService {
private apiUrl = 'http://localhost:3000/api/items'; // URL de l'API
constructor(private http: HttpClient) { }
// ===== CREATE =====
createItem(item: Item): Observable<ApiResponse<Item>> {
return this.http.post<ApiResponse<Item>>(this.apiUrl, item)
.pipe(
catchError(this.handleError)
);
}
// ===== READ (Lister avec pagination et filtres) =====
getItems(page: number = 1, limit: number = 10, status?: string): Observable<PaginatedResponse<Item>> {
let params = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString());
if (status) {
params = params.set('status', status);
}
return this.http.get<PaginatedResponse<Item>>(this.apiUrl, { params })
.pipe(
catchError(this.handleError)
);
}
// ===== READ (Lire un item spécifique) =====
getItemById(id: string): Observable<ApiResponse<Item>> {
return this.http.get<ApiResponse<Item>>(`${this.apiUrl}/${id}`)
.pipe(
catchError(this.handleError)
);
}
// ===== UPDATE =====
updateItem(id: string, item: Partial<Item>): Observable<ApiResponse<Item>> {
return this.http.put<ApiResponse<Item>>(`${this.apiUrl}/${id}`, item)
.pipe(
catchError(this.handleError)
);
}
// ===== DELETE =====
deleteItem(id: string): Observable<ApiResponse<Item>> {
return this.http.delete<ApiResponse<Item>>(`${this.apiUrl}/${id}`)
.pipe(
catchError(this.handleError)
);
}
// ===== RECHERCHE =====
searchItems(query: string): Observable<ApiResponse<Item[]>> {
const params = new HttpParams().set('q', query);
return this.http.get<ApiResponse<Item[]>>(`${this.apiUrl}/search`, { params })
.pipe(
catchError(this.handleError)
);
}
// ===== Gestion d'erreurs =====
private handleError(error: any) {
console.error('Erreur API:', error);
let errorMessage = 'Une erreur est survenue';
if (error.error instanceof ErrorEvent) {
// Erreur client (réseau)
errorMessage = `Erreur: ${error.error.message}`;
} else if (error.status) {
// Erreur serveur HTTP
errorMessage = error.error?.message || `Erreur ${error.status}: ${error.statusText}`;
}
return throwError(() => new Error(errorMessage));
}
}
Composant de liste (item-list.component.ts)
// components/item-list/item-list.component.ts
import { Component, OnInit } from '@angular/core';
import { ItemService } from '../../services/item.service';
import { Item } from '../../models/item.model';
@Component({
selector: 'app-item-list',
templateUrl: './item-list.component.html',
styleUrls: ['./item-list.component.css']
})
export class ItemListComponent implements OnInit {
items: Item[] = [];
loading = false;
error: string | null = null;
currentPage = 1;
totalPages = 1;
selectedStatus = '';
constructor(private itemService: ItemService) { }
ngOnInit(): void {
this.loadItems();
}
// Charger les items depuis l'API
loadItems(): void {
this.loading = true;
this.error = null;
this.itemService.getItems(this.currentPage, 10, this.selectedStatus || undefined)
.subscribe({
next: (response) => {
this.items = response.data;
this.totalPages = response.pagination.pages;
this.loading = false;
},
error: (err) => {
this.error = err.message;
this.loading = false;
console.error('Erreur lors du chargement des items:', err);
}
});
}
// Supprimer un item
deleteItem(id: string | undefined): void {
if (!id || !confirm('Êtes-vous sûr de vouloir supprimer cet item ?')) {
return;
}
this.itemService.deleteItem(id)
.subscribe({
next: () => {
this.items = this.items.filter(item => item._id !== id);
alert('Item supprimé avec succès');
},
error: (err) => {
alert('Erreur lors de la suppression: ' + err.message);
}
});
}
// Filtrer par statut
filterByStatus(status: string): void {
this.selectedStatus = status;
this.currentPage = 1;
this.loadItems();
}
// Pagination
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.loadItems();
}
}
prevPage(): void {
if (this.currentPage > 1) {
this.currentPage--;
this.loadItems();
}
}
}
Template HTML (item-list.component.html)
<div class="container mt-5">
<h2 class="mb-4">Liste des items CRUD</h2>
<!-- Message d'erreur -->
<div *ngIf="error" class="alert alert-danger" role="alert">
{{ error }}
</div>
<!-- Loading -->
<div *ngIf="loading" class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<!-- Filtres -->
<div class="btn-group mb-3" role="group">
<button (click)="filterByStatus('')" [class.active]="!selectedStatus" class="btn btn-outline-primary">
Tous
</button>
<button (click)="filterByStatus('pending')" [class.active]="selectedStatus === 'pending'" class="btn btn-outline-warning">
En attente
</button>
<button (click)="filterByStatus('in-progress')" [class.active]="selectedStatus === 'in-progress'" class="btn btn-outline-info">
En cours
</button>
<button (click)="filterByStatus('completed')" [class.active]="selectedStatus === 'completed'" class="btn btn-outline-success">
Complété
</button>
</div>
<!-- Tableau des items -->
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-dark">
<tr>
<th>Titre</th>
<th>Description</th>
<th>Statut</th>
<th>Priorité</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of items">
<td><strong>{{ item.title }}</strong></td>
<td>{{ item.description || '-' }}</td>
<td>
<span [class]="'badge badge-' + (item.status === 'completed' ? 'success' : item.status === 'in-progress' ? 'info' : 'warning')">
{{ item.status }}
</span>
</td>
<td>⭐ {{ item.priority }}/5</td>
<td>
<button (click)="deleteItem(item._id)" class="btn btn-sm btn-danger">
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="Pagination">
<ul class="pagination">
<li class="page-item" [class.disabled]="currentPage === 1">
<button (click)="prevPage()" class="page-link">Précédent</button>
</li>
<li class="page-item active">
<span class="page-link">Page {{ currentPage }} / {{ totalPages }}</span>
</li>
<li class="page-item" [class.disabled]="currentPage === totalPages">
<button (click)="nextPage()" class="page-link">Suivant</button>
</li>
</ul>
</nav>
</div>
Interceptor d'erreurs (error.interceptor.ts)
// interceptors/error.interceptor.ts - Gestion centralisée des erreurs
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => {
// Traiter les erreurs globalement
let errorMessage = 'Une erreur est survenue';
if (error.error instanceof ErrorEvent) {
// Erreur client
errorMessage = `Erreur: ${error.error.message}`;
} else {
// Erreur serveur
switch (error.status) {
case 0:
errorMessage = 'Impossible de contacter le serveur';
break;
case 400:
errorMessage = error.error?.message || 'Requête invalide';
break;
case 401:
errorMessage = 'Non authentifié';
// Rediriger vers login si nécessaire
break;
case 403:
errorMessage = 'Accès refusé';
break;
case 404:
errorMessage = 'Ressource non trouvée';
break;
case 500:
errorMessage = 'Erreur serveur interne';
break;
default:
errorMessage = `Erreur ${error.status}: ${error.statusText}`;
}
}
console.error('Erreur interceptée:', errorMessage);
// Afficher une notification toast/modal si besoin
// this.notificationService.showError(errorMessage);
return throwError(() => new Error(errorMessage));
})
);
}
}
5. Validation et gestion des erreurs
La validation doit se faire en deux couches : client (UX rapide) et serveur (sécurité).
Validation côté Angular avec Reactive Forms
// components/item-form/item-form.component.ts - Validation réactive
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ItemService } from '../../services/item.service';
@Component({
selector: 'app-item-form',
templateUrl: './item-form.component.html',
styleUrls: ['./item-form.component.css']
})
export class ItemFormComponent implements OnInit {
form: FormGroup;
submitted = false;
loading = false;
successMessage = '';
errorMessage = '';
constructor(
private fb: FormBuilder,
private itemService: ItemService
) {
// Créer le formulaire avec validations
this.form = this.fb.group({
title: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(100)
]],
description: ['', [
Validators.maxLength(500)
]],
priority: [3, [
Validators.required,
Validators.min(1),
Validators.max(5)
]],
status: ['pending', Validators.required],
dueDate: ['', this.futureDateValidator.bind(this)]
});
}
ngOnInit(): void { }
// Validateur personnalisé : la date doit être dans le futur
futureDateValidator(control: any): { [key: string]: any } | null {
if (!control.value) return null;
const selectedDate = new Date(control.value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
return { 'pastDate': true };
}
return null;
}
// Soumettre le formulaire
onSubmit(): void {
this.submitted = true;
// Vérifier si le formulaire est valide
if (this.form.invalid) {
console.log('Formulaire invalide:', this.form.errors);
return;
}
this.loading = true;
this.errorMessage = '';
this.successMessage = '';
// Appeler l'API
this.itemService.createItem(this.form.value)
.subscribe({
next: (response) => {
this.successMessage = 'Item créé avec succès !';
this.form.reset({ priority: 3, status: 'pending' });
this.submitted = false;
this.loading = false;
// Rediriger ou recharger la liste après 2s
setTimeout(() => {
// this.router.navigate(['/items']);
}, 2000);
},
error: (err) => {
this.errorMessage = err.message;
this.loading = false;
}
});
}
// Helper pour accéder aux champs du formulaire dans le template
get title() {
return this.form.get('title');
}
get priority() {
return this.form.get('priority');
}
get dueDate() {
return this.form.get('dueDate');
}
}
Template du formulaire (item-form.component.html)
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<h3 class="mb-4">Créer un nouvel item</h3>
<!-- Message de succès -->
<div *ngIf="successMessage" class="alert alert-success" role="alert">
{{ successMessage }}
</div>
<!-- Message d'erreur -->
<div *ngIf="errorMessage" class="alert alert-danger" role="alert">
{{ errorMessage }}
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Titre -->
<div class="mb-3">
<label for="title" class="form-label">Titre</label>
<input
type="text"
class="form-control"
[class.is-invalid]="submitted && title?.invalid"
id="title"
formControlName="title"
placeholder="Entrez le titre"
>
<div *ngIf="submitted && title?.invalid" class="invalid-feedback">
<span *ngIf="title?.hasError('required')">Le titre est obligatoire.</span>
<span *ngIf="title?.hasError('minlength')">Minimum 3 caractères.</span>
<span *ngIf="title?.hasError('maxlength')">Maximum 100 caractères.</span>
</div>
</div>
<!-- Description -->
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea
class="form-control"
id="description"
formControlName="description"
rows="3"
placeholder="Description optionnelle"
></textarea>
</div>
<!-- Priorité -->
<div class="mb-3">
<label for="priority" class="form-label">Priorité</label>
<input
type="range"
class="form-range"
id="priority"
formControlName="priority"
min="1"
max="5"
>
<small class="text-muted">{{ priority?.value || 3 }} / 5</small>
</div>
<!-- Statut -->
<div class="mb-3">
<label for="status" class="form-label">Statut</label>
<select class="form-select" id="status" formControlName="status">
<option value="pending">En attente</option>
<option value="in-progress">En cours</option>
<option value="completed">Complété</option>
</select>
</div>
<!-- Date d'échéance -->
<div class="mb-3">
<label for="dueDate" class="form-label">Date d'échéance</label>
<input
type="date"
class="form-control"
[class.is-invalid]="submitted && dueDate?.invalid"
id="dueDate"
formControlName="dueDate"
>
<div *ngIf="submitted && dueDate?.hasError('pastDate')" class="invalid-feedback">
La date doit être dans le futur.
</div>
</div>
<!-- Boutons -->
<button type="submit" class="btn btn-primary" [disabled]="loading">
<span *ngIf="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
{{ loading ? 'En cours...' : 'Créer' }}
</button>
<button type="reset" class="btn btn-outline-secondary ms-2">Réinitialiser</button>
</form>
</div>
</div>
</div>
- Ne pas valider côté client → formulaires malveillants passent
- Afficher erreurs serveur brutes → fuite d'informations sensibles
- Pas de gestion timeout → requêtes "infinies" en attente
- Ignorer les erreurs 5xx → serveur en feu sans le savoir
6. Sécurité et authentification JWT
Pour un CRUD production, ajouter JWT (JSON Web Tokens) pour authentifier et autoriser les utilisateurs. Voici l'implémentation :
Middleware d'authentification (middleware/authMiddleware.js)
// middleware/authMiddleware.js - Vérifier le JWT
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
// Extraire le token du header Authorization
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: true,
message: 'Token manquant ou invalide'
});
}
const token = authHeader.slice(7); // Enlever "Bearer "
try {
// Vérifier le token avec la clé secrète
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Ajouter l'utilisateur à la requête
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: true,
message: 'Token expiré'
});
}
res.status(403).json({
error: true,
message: 'Token invalide'
});
}
};
module.exports = authMiddleware;
Endpoint de login (routes/auth.js)
// routes/auth.js - Authentification
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// Modèle User (exemple simplifié)
const User = require('../models/User');
// ===== REGISTER =====
router.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
// Vérifier si l'utilisateur existe
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({
error: true,
message: 'Utilisateur déjà existant'
});
}
// Hasher le mot de passe
const hashedPassword = await bcrypt.hash(password, 10);
// Créer l'utilisateur
const newUser = new User({
email,
password: hashedPassword
});
await newUser.save();
res.status(201).json({
error: false,
message: 'Utilisateur créé. Connectez-vous.'
});
} catch (err) {
res.status(500).json({
error: true,
message: err.message
});
}
});
// ===== LOGIN =====
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Trouver l'utilisateur
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({
error: true,
message: 'Email ou mot de passe incorrect'
});
}
// Vérifier le mot de passe
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
error: true,
message: 'Email ou mot de passe incorrect'
});
}
// Générer le JWT (valide 24h)
const token = jwt.sign(
{ id: user._id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
error: false,
message: 'Connecté avec succès',
data: {
token,
user: {
id: user._id,
email: user.email
}
}
});
} catch (err) {
res.status(500).json({
error: true,
message: err.message
});
}
});
module.exports = router;
Interceptor d'authentification Angular (auth.interceptor.ts)
// interceptors/auth.interceptor.ts - Ajouter le JWT aux requêtes
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Récupérer le token du localStorage
const token = localStorage.getItem('authToken');
// Ajouter le token à chaque requête
if (token) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next.handle(request);
}
}
- JWT en localStorage → XSS vulnérable. Meilleur : HttpOnly cookies
- Hasher les mots de passe avec bcrypt (min 10 rounds)
- Valider sur serveur → ne jamais faire confiance aux tokens clients
- HTTPS en production → jamais HTTP
- Rate limiting → limiter les tentatives de login (max 5/min)
7. Tests et déploiement production
Tests unitaires avec Jest (backend)
# Installer Jest
npm install --save-dev jest supertest
# Configurer Jest (jest.config.js)
module.exports = {
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
testMatch: ['**/__tests__/**/*.test.js', '**/?(*.)+(spec|test).js']
};
Test unitaire d'un contrôleur
// __tests__/itemController.test.js
const itemController = require('../controllers/itemController');
const Item = require('../models/Item');
// Mock Mongoose
jest.mock('../models/Item');
describe('ItemController', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('createItem doit créer un nouvel item', async () => {
const mockItem = { _id: '123', title: 'Test', save: jest.fn() };
Item.prototype.save = jest.fn().resolvedValue(mockItem);
const req = {
body: { title: 'Test Item', priority: 3 }
};
const res = {
status: jest.fn().returnThis(),
json: jest.fn()
};
await itemController.createItem(req, res);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalled();
});
test('getItems doit retourner une liste d\'items', async () => {
const mockItems = [
{ _id: '1', title: 'Item 1' },
{ _id: '2', title: 'Item 2' }
];
Item.find = jest.fn().mockReturnThis();
Item.find().sort = jest.fn().mockReturnThis();
Item.find().sort().skip = jest.fn().mockReturnThis();
Item.find().sort().skip().limit = jest.fn().resolvedValue(mockItems);
Item.countDocuments = jest.fn().resolvedValue(2);
const req = { query: { page: 1, limit: 10 } };
const res = {
json: jest.fn()
};
await itemController.getItems(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: false,
data: mockItems
})
);
});
});
Tests e2e Angular avec Cypress
# Installer Cypress
npm install --save-dev cypress
# Exécuter
npx cypress open
// cypress/e2e/crud.cy.js - Scénario complet CRUD
describe('CRUD Full-Stack', () => {
beforeEach(() => {
cy.visit('http://localhost:4200');
});
it('Doit créer, afficher, mettre à jour et supprimer un item', () => {
// CREATE
cy.get('input[name="title"]').type('Mon nouvel item');
cy.get('input[name="priority"]').clear().type('5');
cy.get('button[type="submit"]').click();
cy.contains('Item créé avec succès').should('be.visible');
// READ
cy.visit('http://localhost:4200/items');
cy.contains('Mon nouvel item').should('be.visible');
// UPDATE (si interface disponible)
cy.get('button.edit').first().click();
cy.get('input[name="title"]').clear().type('Item modifié');
cy.get('button[type="submit"]').click();
cy.contains('Item modifié').should('be.visible');
// DELETE
cy.get('button.delete').first().click();
cy.on('window:confirm', () => true);
cy.contains('Item supprimé').should('be.visible');
});
});
Déploiement avec Docker
# Dockerfile - Backend Node.js
FROM node:18-alpine
WORKDIR /app
# Copier package.json et installer les dépendances
COPY package*.json ./
RUN npm ci --only=production
# Copier le code application
COPY . .
# Exposer le port
EXPOSE 3000
# Commande de démarrage
CMD ["npm", "start"]
# docker-compose.yml - Orchestrer Backend + MongoDB
version: '3.8'
services:
backend:
build: ./backend
ports:
- "3000:3000"
environment:
MONGODB_URI: mongodb://mongo:27017/crud-db
JWT_SECRET: your-secret-key
NODE_ENV: production
depends_on:
- mongo
restart: always
mongo:
image: mongo:6
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
restart: always
frontend:
build: ./frontend
ports:
- "4200:4200"
environment:
API_URL: http://localhost:3000/api
depends_on:
- backend
volumes:
mongo_data:
Déploiement sur production
# 1. Build de production Angular
cd frontend
ng build --configuration production
# 2. Build Docker et push
docker build -t crud-api:1.0.0 .
docker push registry.exemple.com/crud-api:1.0.0
# 3. Déployer sur serveur (via SSH)
ssh user@prod-server
docker pull registry.exemple.com/crud-api:1.0.0
docker-compose -f docker-compose.prod.yml up -d
# 4. Vérifier santé du service
curl https://api.exemple.com/health
8. Conclusion et checklist de mise en production
Vous avez maintenant une application CRUD complète et production-ready combinant Node.js, Angular, et MongoDB. Voici la checklist avant le déploiement :
✅ Checklist de mise en production
Points clés à retenir
- Architecture : Séparer client, API, et base de données pour la maintenabilité
- Validation : Toujours valider côté client ET serveur
- Sécurité : JWT pour l'authentification, HTTPS obligatoire, secrets hors du code
- Performance : Index BD, pagination, caching client
- Tests : Couvrir les cas normaux, edge cases, et erreurs
- Monitoring : Logs, alertes, et dashboards pour la production
- Ajouter des relations complexes (Many-to-Many, cascade deletes)
- Implémenter les webhooks pour les notifications en temps réel
- Ajouter l'export/import CSV
- Graphiques de statistiques avec Chart.js
- Notifications push et emails (SendGrid, Mailgun)