Back-end angularforall.com

- CRUD Full-Stack avec Node.js, Angular et REST API

Nodejs Express Angular Crud Rest Api Mongodb Full-Stack Httpclient Jwt
CRUD Full-Stack avec Node.js, Angular et REST API

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
💡 Flux CRUD standard : 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
        

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>
        
⚠️ Erreurs à éviter :
  • 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);
    }
}
        
🔒 Points de sécurité critiques :
  • 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
🎯 Prochaines étapes :
  • 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)

Ressources complémentaires

Partager