19 min de lecture · 4 171 mots

Node.js et Express : API REST complète

Node.js et Express : API REST complète

Installation et configuration

Initialisation du projet

Créer projet

mkdir mon-api && cd mon-api npm init -y

Installation Express

npm install express

Dépendances essentielles

npm install dotenv cors helmet morgan npm install --save-dev nodemon typescript @types/node @types/express

Base de données

npm install mongoose # MongoDB npm install pg # PostgreSQL npm install mysql2 # MySQL

Validation et sécurité

npm install joi express-validator npm install bcrypt jsonwebtoken npm install --save-dev @types/bcrypt @types/jsonwebtoken

Testing

npm install --save-dev jest supertest @types/jest @types/supertest

Structure de projet

src/
├── config/
│   ├── database.js
│   └── env.js
├── controllers/
│   ├── authController.js
│   └── userController.js
├── middleware/
│   ├── auth.js
│   ├── errorHandler.js
│   └── validation.js
├── models/
│   └── User.js
├── routes/
│   ├── auth.js
│   └── users.js
├── services/
│   └── userService.js
├── utils/
│   ├── logger.js
│   └── errors.js
├── app.js
└── server.js

Configuration de base

// .env
PORT=3000
NODEENV=development
DBURI=mongodb://localhost:27017/myapp
JWTSECRET=your-secret-key
JWTEXPIRE=7d

// src/config/env.js
require('dotenv').config();

module.exports = {
  port: process.env.PORT || 3000,
  env: process.env.NODEENV || 'development',
  dbUri: process.env.DBURI,
  jwtSecret: process.env.JWTSECRET,
  jwtExpire: process.env.JWTEXPIRE
};

// src/server.js
const app = require('./app');
const config = require('./config/env');

const PORT = config.port;

app.listen(PORT, () => {
  console.log(Server running on port ${PORT});
});

// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');

const app = express();

// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan('dev'));

// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users'));

// Error handling
app.use(require('./middleware/errorHandler'));

module.exports = app;

Express fondamentaux

Application de base

const express = require('express');
const app = express();

// Middleware global
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.get('/', (req, res) => {
  res.json({ message: 'Hello World' });
});

app.post('/data', (req, res) => {
  const { name, email } = req.body;
  res.json({ name, email });
});

app.put('/data/:id', (req, res) => {
  const { id } = req.params;
  res.json({ id, updated: true });
});

app.delete('/data/:id', (req, res) => {
  const { id } = req.params;
  res.json({ id, deleted: true });
});

// Route avec query params
app.get('/search', (req, res) => {
  const { q, page, limit } = req.query;
  res.json({ q, page, limit });
});

// Route avec multiple params
app.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ userId, postId });
});

// Error 404
app.use((req, res) => {
  res.status(404).json({ error: 'Not Found' });
});

app.listen(3000);

Router

// routes/users.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const auth = require('../middleware/auth');

// Public routes
router.post('/register', userController.register);
router.post('/login', userController.login);

// Protected routes
router.get('/', auth, userController.getAll);
router.get('/:id', auth, userController.getById);
router.put('/:id', auth, userController.update);
router.delete('/:id', auth, userController.delete);

module.exports = router;

// Nested routers
const userRouter = require('./users');
const postRouter = require('./posts');

app.use('/api/users', userRouter);
app.use('/api/posts', postRouter);

// Route parameters middleware
router.param('id', (req, res, next, id) => {
  // Validation ou traitement de l'ID
  req.userId = id;
  next();
});

// Route grouping
const apiRouter = express.Router();

apiRouter.use('/users', userRouter);
apiRouter.use('/posts', postRouter);
apiRouter.use('/comments', commentRouter);

app.use('/api/v1', apiRouter);

Middleware

// Middleware simple
const logger = (req, res, next) => {
  console.log(${req.method} ${req.url});
  next();
};

app.use(logger);

// Middleware avec paramètres
const validateBody = (schema) => {
  return (req, res, next) => {
    const { error } = schema.validate(req.body);
    if (error) {
      return res.status(400).json({ error: error.details[0].message });
    }
    next();
  };
};

// Utilisation
app.post('/users', validateBody(userSchema), createUser);

// Middleware d'authentification
const auth = async (req, res, next) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '');

    if (!token) {
      throw new Error('No token provided');
    }

    const decoded = jwt.verify(token, process.env.JWTSECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Please authenticate' });
  }
};

// Middleware de rôles
const authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
};

// Utilisation
app.delete('/users/:id', auth, authorize('admin'), deleteUser);

// Middleware d'erreur
const errorHandler = (err, req, res, next) => {
  console.error(err.stack);

  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({
    error: {
      message,
      ...(process.env.NODEENV === 'development' && { stack: err.stack })
    }
  });
};

app.use(errorHandler);

// Middleware de rate limiting
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15  60  1000, // 15 minutes
  max: 100, // limite par IP
  message: 'Too many requests'
});

app.use('/api/', limiter);

// Middleware de cache
const cache = (duration) => {
  return (req, res, next) => {
    res.set('Cache-Control', public, max-age=${duration});
    next();
  };
};

app.get('/public-data', cache(3600), getData);

Contrôleurs et services

Pattern MVC

// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true
  },
  password: {
    type: String,
    required: true,
    minlength: 6
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  },
  isActive: {
    type: Boolean,
    default: true
  }
}, {
  timestamps: true
});

// Hash password avant sauvegarde
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Méthode d'instance
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

// Méthode statique
userSchema.statics.findByEmail = function(email) {
  return this.findOne({ email });
};

// Virtuals
userSchema.virtual('fullName').get(function() {
  return ${this.firstName} ${this.lastName};
});

module.exports = mongoose.model('User', userSchema);

// services/userService.js
const User = require('../models/User');
const jwt = require('jsonwebtoken');

class UserService {
  async createUser(userData) {
    const existingUser = await User.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('Email already exists');
    }

    const user = new User(userData);
    await user.save();
    return this.sanitizeUser(user);
  }

  async getUserById(id) {
    const user = await User.findById(id).select('-password');
    if (!user) {
      throw new Error('User not found');
    }
    return user;
  }

  async updateUser(id, updates) {
    const user = await User.findByIdAndUpdate(
      id,
      updates,
      { new: true, runValidators: true }
    ).select('-password');

    if (!user) {
      throw new Error('User not found');
    }

    return user;
  }

  async deleteUser(id) {
    const user = await User.findByIdAndDelete(id);
    if (!user) {
      throw new Error('User not found');
    }
    return { message: 'User deleted successfully' };
  }

  async login(email, password) {
    const user = await User.findByEmail(email);
    if (!user) {
      throw new Error('Invalid credentials');
    }

    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      throw new Error('Invalid credentials');
    }

    const token = this.generateToken(user);
    return {
      user: this.sanitizeUser(user),
      token
    };
  }

  generateToken(user) {
    return jwt.sign(
      { id: user.id, email: user.email, role: user.role },
      process.env.JWTSECRET,
      { expiresIn: process.env.JWTEXPIRE }
    );
  }

  sanitizeUser(user) {
    const userObject = user.toObject();
    delete userObject.password;
    return userObject;
  }
}

module.exports = new UserService();

// controllers/userController.js
const userService = require('../services/userService');
const asyncHandler = require('../utils/asyncHandler');

exports.register = asyncHandler(async (req, res) => {
  const user = await userService.createUser(req.body);
  res.status(201).json({
    success: true,
    data: user
  });
});

exports.login = asyncHandler(async (req, res) => {
  const { email, password } = req.body;
  const result = await userService.login(email, password);

  res.json({
    success: true,
    data: result
  });
});

exports.getAll = asyncHandler(async (req, res) => {
  const { page = 1, limit = 10, sort = '-createdAt' } = req.query;

  const users = await User.find()
    .select('-password')
    .sort(sort)
    .limit(limit  1)
    .skip((page - 1)  limit);

  const count = await User.countDocuments();

  res.json({
    success: true,
    data: users,
    pagination: {
      page: parseInt(page),
      limit: parseInt(limit),
      total: count,
      pages: Math.ceil(count / limit)
    }
  });
});

exports.getById = asyncHandler(async (req, res) => {
  const user = await userService.getUserById(req.params.id);
  res.json({
    success: true,
    data: user
  });
});

exports.update = asyncHandler(async (req, res) => {
  const user = await userService.updateUser(req.params.id, req.body);
  res.json({
    success: true,
    data: user
  });
});

exports.delete = asyncHandler(async (req, res) => {
  await userService.deleteUser(req.params.id);
  res.json({
    success: true,
    message: 'User deleted successfully'
  });
});

// utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = asyncHandler;

Validation

Express Validator

const { body, param, query, validationResult } = require('express-validator');

// Middleware de validation
const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      errors: errors.array()
    });
  }
  next();
};

// Règles de validation
const userValidationRules = () => {
  return [
    body('name')
      .trim()
      .notEmpty().withMessage('Name is required')
      .isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),

    body('email')
      .trim()
      .notEmpty().withMessage('Email is required')
      .isEmail().withMessage('Invalid email format')
      .normalizeEmail(),

    body('password')
      .notEmpty().withMessage('Password is required')
      .isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
      .matches(/d/).withMessage('Password must contain a number'),

    body('age')
      .optional()
      .isInt({ min: 18, max: 120 }).withMessage('Age must be between 18 and 120'),

    body('role')
      .optional()
      .isIn(['user', 'admin']).withMessage('Invalid role')
  ];
};

// Utilisation
router.post('/register',
  userValidationRules(),
  validate,
  userController.register
);

// Validation de paramètres
const idValidation = [
  param('id')
    .isMongoId().withMessage('Invalid ID format')
];

router.get('/users/:id', idValidation, validate, getUser);

// Validation de query
const paginationValidation = [
  query('page')
    .optional()
    .isInt({ min: 1 }).withMessage('Page must be >= 1'),

  query('limit')
    .optional()
    .isInt({ min: 1, max: 100 }).withMessage('Limit must be 1-100')
];

router.get('/users', paginationValidation, validate, getUsers);

// Custom validators
const customValidators = [
  body('email').custom(async (email) => {
    const user = await User.findOne({ email });
    if (user) {
      throw new Error('Email already in use');
    }
    return true;
  }),

  body('confirmPassword').custom((value, { req }) => {
    if (value !== req.body.password) {
      throw new Error('Passwords do not match');
    }
    return true;
  })
];

Joi Validation

const Joi = require('joi');

// Schémas
const schemas = {
  user: Joi.object({
    name: Joi.string().min(2).max(50).required(),
    email: Joi.string().email().required(),
    password: Joi.string().min(6).required(),
    age: Joi.number().integer().min(18).max(120),
    role: Joi.string().valid('user', 'admin').default('user')
  }),

  login: Joi.object({
    email: Joi.string().email().required(),
    password: Joi.string().required()
  }),

  updateUser: Joi.object({
    name: Joi.string().min(2).max(50),
    email: Joi.string().email(),
    age: Joi.number().integer().min(18).max(120)
  })
};

// Middleware
const validateRequest = (schema) => {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      stripUnknown: true
    });

    if (error) {
      const errors = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message
      }));

      return res.status(400).json({
        success: false,
        errors
      });
    }

    req.body = value;
    next();
  };
};

// Utilisation
router.post('/register', validateRequest(schemas.user), register);
router.post('/login', validateRequest(schemas.login), login);

// Validation complexe
const productSchema = Joi.object({
  name: Joi.string().required(),
  price: Joi.number().positive().required(),
  category: Joi.string().required(),
  tags: Joi.array().items(Joi.string()),
  dimensions: Joi.object({
    width: Joi.number().positive(),
    height: Joi.number().positive(),
    depth: Joi.number().positive()
  }),
  variants: Joi.array().items(
    Joi.object({
      color: Joi.string(),
      size: Joi.string(),
      stock: Joi.number().integer().min(0)
    })
  )
});

Authentification et sécurité

JWT Authentication

const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

// Générer token
const generateToken = (userId) => {
  return jwt.sign(
    { id: userId },
    process.env.JWTSECRET,
    { expiresIn: '7d' }
  );
};

// Générer refresh token
const generateRefreshToken = (userId) => {
  return jwt.sign(
    { id: userId },
    process.env.REFRESHTOKENSECRET,
    { expiresIn: '30d' }
  );
};

// Middleware d'authentification
const authenticate = async (req, res, next) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '');

    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const decoded = jwt.verify(token, process.env.JWTSECRET);
    const user = await User.findById(decoded.id).select('-password');

    if (!user) {
      return res.status(401).json({ error: 'Invalid token' });
    }

    req.user = user;
    req.token = token;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Authentication failed' });
  }
};

// Login avec refresh token
exports.login = async (req, res) => {
  const { email, password } = req.body;

  const user = await User.findOne({ email });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = generateToken(user.id);
  const refreshToken = generateRefreshToken(user.id);

  // Stocker refresh token
  user.refreshToken = refreshToken;
  await user.save();

  res.json({
    user: { id: user.id, email: user.email },
    accessToken,
    refreshToken
  });
};

// Refresh token endpoint
exports.refreshToken = async (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }

  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESHTOKENSECRET);
    const user = await User.findById(decoded.id);

    if (!user || user.refreshToken !== refreshToken) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }

    const newAccessToken = generateToken(user.id);

    res.json({ accessToken: newAccessToken });
  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
};

// Logout
exports.logout = async (req, res) => {
  req.user.refreshToken = null;
  await req.user.save();

  res.json({ message: 'Logged out successfully' });
};

Sécurité avancée

const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const cors = require('cors');

// Configuration helmet
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
}));

// CORS
app.use(cors({
  origin: process.env.ALLOWEDORIGINS?.split(',') || '',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// Protection NoSQL injection
app.use(mongoSanitize());

// Protection XSS
app.use(xss());

// Protection HTTP Parameter Pollution
app.use(hpp({
  whitelist: ['sort', 'fields', 'page', 'limit']
}));

// Rate limiting avancé
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');

const limiter = rateLimit({
  store: new RedisStore({
    client: redisClient
  }),
  windowMs: 15  60  1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many requests, please try again later'
    });
  }
});

app.use('/api/', limiter);

// Rate limiting pour login (plus strict)
const loginLimiter = rateLimit({
  windowMs: 15  60  1000,
  max: 5,
  skipSuccessfulRequests: true
});

app.post('/api/auth/login', loginLimiter, login);

// Password hashing avec bcrypt
const hashPassword = async (password) => {
  const salt = await bcrypt.genSalt(12);
  return await bcrypt.hash(password, salt);
};

// Validation de mot de passe fort
const isStrongPassword = (password) => {
  const regex = /^(?=.[a-z])(?=.[A-Z])(?=.d)(?=.[@$!%?&])[A-Za-zd@$!%?&]{8,}$/;
  return regex.test(password);
};

Base de données

MongoDB avec Mongoose

const mongoose = require('mongoose');

// Connexion
const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGOURI, {
      useNewUrlParser: true,
      useUnifiedTopology: true
    });

    console.log(MongoDB Connected: ${conn.connection.host});
  } catch (error) {
    console.error(Error: ${error.message});
    process.exit(1);
  }
};

// Modèle avec relations
const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'Title is required'],
    trim: true,
    maxlength: [100, 'Title cannot exceed 100 characters']
  },
  content: {
    type: String,
    required: true
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  tags: [{
    type: String,
    trim: true
  }],
  comments: [{
    user: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User'
    },
    text: String,
    createdAt: {
      type: Date,
      default: Date.now
    }
  }],
  likes: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }],
  status: {
    type: String,
    enum: ['draft', 'published', 'archived'],
    default: 'draft'
  }
}, {
  timestamps: true,
  toJSON: { virtuals: true },
  toObject: { virtuals: true }
});

// Index
postSchema.index({ title: 'text', content: 'text' });
postSchema.index({ author: 1, createdAt: -1 });

// Virtuals
postSchema.virtual('likesCount').get(function() {
  return this.likes.length;
});

postSchema.virtual('commentsCount').get(function() {
  return this.comments.length;
});

// Pre hooks
postSchema.pre(/^find/, function(next) {
  this.populate('author', 'name email');
  next();
});

// Méthodes d'instance
postSchema.methods.like = function(userId) {
  if (!this.likes.includes(userId)) {
    this.likes.push(userId);
  }
  return this.save();
};

postSchema.methods.unlike = function(userId) {
  this.likes = this.likes.filter(id => id.toString() !== userId.toString());
  return this.save();
};

// Méthodes statiques
postSchema.statics.findByAuthor = function(authorId) {
  return this.find({ author: authorId }).sort('-createdAt');
};

postSchema.statics.searchPosts = function(query) {
  return this.find({ $text: { $search: query } });
};

const Post = mongoose.model('Post', postSchema);

// Requêtes avancées
const getPosts = async (req, res) => {
  const { page = 1, limit = 10, sort = '-createdAt', search } = req.query;

  const query = {};

  // Recherche
  if (search) {
    query.$text = { $search: search };
  }

  // Filtres
  if (req.query.author) {
    query.author = req.query.author;
  }

  if (req.query.status) {
    query.status = req.query.status;
  }

  // Exécution
  const posts = await Post.find(query)
    .populate('author', 'name email')
    .populate('comments.user', 'name')
    .sort(sort)
    .limit(limit  1)
    .skip((page - 1)  limit)
    .select('-v');

  const count = await Post.countDocuments(query);

  res.json({
    posts,
    totalPages: Math.ceil(count / limit),
    currentPage: parseInt(page),
    total: count
  });
};

// Agrégation
const getPostStats = async (req, res) => {
  const stats = await Post.aggregate([
    {
      $match: { status: 'published' }
    },
    {
      $group: {
        id: '$author',
        totalPosts: { $sum: 1 },
        totalLikes: { $sum: { $size: '$likes' } },
        avgLikes: { $avg: { $size: '$likes' } }
      }
    },
    {
      $lookup: {
        from: 'users',
        localField: 'id',
        foreignField: 'id',
        as: 'author'
      }
    },
    {
      $unwind: '$author'
    },
    {
      $project: {
        authorName: '$author.name',
        totalPosts: 1,
        totalLikes: 1,
        avgLikes: { $round: ['$avgLikes', 2] }
      }
    },
    {
      $sort: { totalLikes: -1 }
    }
  ]);

  res.json(stats);
};

PostgreSQL avec Sequelize

const { Sequelize, DataTypes, Model } = require('sequelize');

// Connexion
const sequelize = new Sequelize(
  process.env.DBNAME,
  process.env.DBUSER,
  process.env.DBPASSWORD,
  {
    host: process.env.DBHOST,
    dialect: 'postgres',
    logging: false,
    pool: {
      max: 5,
      min: 0,
      acquire: 30000,
      idle: 10000
    }
  }
);

// Test connexion
const testConnection = async () => {
  try {
    await sequelize.authenticate();
    console.log('Database connected');
  } catch (error) {
    console.error('Unable to connect:', error);
  }
};

// Modèle
class User extends Model {}

User.init({
  id: {
    type: DataTypes.UUID,
    defaultValue: DataTypes.UUIDV4,
    primaryKey: true
  },
  name: {
    type: DataTypes.STRING(100),
    allowNull: false,
    validate: {
      len: [2, 100]
    }
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true
    }
  },
  password: {
    type: DataTypes.STRING,
    allowNull: false
  },
  role: {
    type: DataTypes.ENUM('user', 'admin'),
    defaultValue: 'user'
  },
  isActive: {
    type: DataTypes.BOOLEAN,
    defaultValue: true
  }
}, {
  sequelize,
  modelName: 'User',
  tableName: 'users',
  timestamps: true,
  paranoid: true, // Soft delete
  underscored: true
});

// Relations
class Post extends Model {}

Post.init({
  id: {
    type: DataTypes.UUID,
    defaultValue: DataTypes.UUIDV4,
    primaryKey: true
  },
  title: {
    type: DataTypes.STRING,
    allowNull: false
  },
  content: {
    type: DataTypes.TEXT,
    allowNull: false
  },
  status: {
    type: DataTypes.ENUM('draft', 'published'),
    defaultValue: 'draft'
  }
}, {
  sequelize,
  modelName: 'Post'
});

// Définir relations
User.hasMany(Post, { foreignKey: 'userId', as: 'posts' });
Post.belongsTo(User, { foreignKey: 'userId', as: 'author' });

// Sync models
const syncDB = async () => {
  await sequelize.sync({ alter: true });
};

// Requêtes
const getUsers = async (req, res) => {
  const users = await User.findAll({
    attributes: ['id', 'name', 'email', 'role'],
    where: {
      isActive: true
    },
    include: [{
      model: Post,
      as: 'posts',
      attributes: ['id', 'title']
    }],
    order: [['createdAt', 'DESC']],
    limit: 10
  });

  res.json(users);
};

// Transactions
const createUserWithPost = async (userData, postData) => {
  const t = await sequelize.transaction();

  try {
    const user = await User.create(userData, { transaction: t });
    const post = await Post.create({
      ...postData,
      userId: user.id
    }, { transaction: t });

    await t.commit();
    return { user, post };
  } catch (error) {
    await t.rollback();
    throw error;
  }
};

Upload de fichiers

Multer

const multer = require('multer');
const path = require('path');

// Configuration de stockage
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random()  1E9);
    cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
  }
});

// Filtres
const fileFilter = (req, file, cb) => {
  const allowedTypes = /jpeg|jpg|png|gif/;
  const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
  const mimetype = allowedTypes.test(file.mimetype);

  if (extname && mimetype) {
    cb(null, true);
  } else {
    cb(new Error('Only images are allowed'));
  }
};

// Configuration multer
const upload = multer({
  storage: storage,
  limits: {
    fileSize: 5  1024  1024 // 5MB
  },
  fileFilter: fileFilter
});

// Routes d'upload
router.post('/upload/single',
  upload.single('image'),
  (req, res) => {
    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' });
    }

    res.json({
      filename: req.file.filename,
      path: req.file.path,
      size: req.file.size
    });
  }
);

router.post('/upload/multiple',
  upload.array('images', 5),
  (req, res) => {
    const files = req.files.map(file => ({
      filename: file.filename,
      path: file.path,
      size: file.size
    }));

    res.json({ files });
  }
);

// Champs multiples
router.post('/upload/fields',
  upload.fields([
    { name: 'avatar', maxCount: 1 },
    { name: 'gallery', maxCount: 5 }
  ]),
  (req, res) => {
    res.json({
      avatar: req.files['avatar'],
      gallery: req.files['gallery']
    });
  }
);

// Upload vers cloud (AWS S3)
const AWS = require('aws-sdk');
const multerS3 = require('multer-s3');

const s3 = new AWS.S3({
  accessKeyId: process.env.AWSACCESSKEY,
  secretAccessKey: process.env.AWSSECRETKEY,
  region: process.env.AWSREGION
});

const uploadS3 = multer({
  storage: multerS3({
    s3: s3,
    bucket: process.env.S3BUCKET,
    acl: 'public-read',
    metadata: (req, file, cb) => {
      cb(null, { fieldName: file.fieldname });
    },
    key: (req, file, cb) => {
      cb(null, Date.now().toString() + '-' + file.originalname);
    }
  })
});

router.post('/upload/s3',
  uploadS3.single('file'),
  (req, res) => {
    res.json({
      location: req.file.location,
      key: req.file.key
    });
  }
);

WebSockets avec Socket.io

const express = require('express');
const http = require('http');
const socketIO = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
  cors: {
    origin: "",
    methods: ["GET", "POST"]
  }
});

// Middleware d'authentification Socket.io
io.use((socket, next) => {
  const token = socket.handshake.auth.token;

  if (!token) {
    return next(new Error('Authentication error'));
  }

  try {
    const decoded = jwt.verify(token, process.env.JWTSECRET);
    socket.userId = decoded.id;
    next();
  } catch (error) {
    next(new Error('Authentication error'));
  }
});

// Événements
io.on('connection', (socket) => {
  console.log('User connected:', socket.userId);

  // Rejoindre une room
  socket.on('join-room', (roomId) => {
    socket.join(roomId);
    socket.to(roomId).emit('user-joined', {
      userId: socket.userId
    });
  });

  // Message de chat
  socket.on('send-message', async (data) => {
    const { roomId, message } = data;

    // Sauvegarder en DB
    const savedMessage = await Message.create({
      user: socket.userId,
      room: roomId,
      content: message
    });

    // Émettre à la room
    io.to(roomId).emit('new-message', {
      id: savedMessage.id,
      user: socket.userId,
      content: message,
      timestamp: savedMessage.createdAt
    });
  });

  // Typing indicator
  socket.on('typing', (roomId) => {
    socket.to(roomId).emit('user-typing', {
      userId: socket.userId
    });
  });

  socket.on('stop-typing', (roomId) => {
    socket.to(roomId).emit('user-stopped-typing', {
      userId: socket.userId
    });
  });

  // Déconnexion
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.userId);
  });
});

// Émettre depuis une route Express
app.post('/api/notify', auth, (req, res) => {
  const { userId, message } = req.body;

  // Envoyer notification à un utilisateur spécifique
  io.to(userId).emit('notification', {
    type: 'info',
    message: message
  });

  res.json({ success: true });
});

server.listen(3000);

Tests

Jest et Supertest

// tests_/auth.test.js
const request = require('supertest');
const app = require('../src/app');
const User = require('../src/models/User');
const mongoose = require('mongoose');

beforeAll(async () => {
  await mongoose.connect(process.env.MONGOTESTURI);
});

afterAll(async () => {
  await mongoose.connection.close();
});

beforeEach(async () => {
  await User.deleteMany({});
});

describe('Auth endpoints', () => {
  describe('POST /api/auth/register', () => {
    it('should register a new user', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          name: 'Test User',
          email: 'test@example.com',
          password: 'password123'
        });

      expect(res.statusCode).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data).toHaveProperty('id');
      expect(res.body.data.email).toBe('test@example.com');
    });

    it('should return error for duplicate email', async () => {
      await User.create({
        name: 'Existing User',
        email: 'test@example.com',
        password: 'password123'
      });

      const res = await request(app)
        .post('/api/auth/register')
        .send({
          name: 'Test User',
          email: 'test@example.com',
          password: 'password123'
        });

      expect(res.statusCode).toBe(400);
      expect(res.body.success).toBe(false);
    });

    it('should validate required fields', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'test@example.com'
        });

      expect(res.statusCode).toBe(400);
    });
  });

  describe('POST /api/auth/login', () => {
    beforeEach(async () => {
      await request(app)
        .post('/api/auth/register')
        .send({
          name: 'Test User',
          email: 'test@example.com',
          password: 'password123'
        });
    });

    it('should login with valid credentials', async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          email: 'test@example.com',
          password: 'password123'
        });

      expect(res.statusCode).toBe(200);
      expect(res.body.data).toHaveProperty('token');
    });

    it('should reject invalid credentials', async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          email: 'test@example.com',
          password: 'wrongpassword'
        });

      expect(res.statusCode).toBe(401);
    });
  });
});

// Tests de middleware
describe('Auth middleware', () => {
  it('should allow authenticated requests', async () => {
    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'test@example.com',
        password: 'password123'
      });

    const token = loginRes.body.data.token;

    const res = await request(app)
      .get('/api/users/profile')
      .set('Authorization', Bearer ${token});

    expect(res.statusCode).toBe(200);
  });

  it('should reject unauthenticated requests', async () => {
    const res = await request(app)
      .get('/api/users/profile');

    expect(res.statusCode).toBe(401);
  });
});

// Mock de services
jest.mock('../src/services/emailService');
const emailService = require('../src/services/emailService');

describe('User controller', () => {
  it('should send welcome email on registration', async () => {
    emailService.sendWelcomeEmail.mockResolvedValue(true);

    await request(app)
      .post('/api/auth/register')
      .send({
        name: 'Test User',
        email: 'test@example.com',
        password: 'password123'
      });

    expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(
      'test@example.com',
      'Test User'
    );
  });
});

Performance et optimisation

Caching avec Redis

const redis = require('redis');
const client = redis.createClient({
  host: process.env.REDISHOST,
  port: process.env.REDIS_PORT
});

client.on('error', (err) => console.error('Redis error:', err));
client.connect();

// Middleware de cache
const cache = (duration) => {
  return async (req, res, next) => {
    const key = cache:${req.originalUrl};

    try {
      const cached = await client.get(key);

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

      // Stocker la réponse originale
      res.sendResponse = res.json;
      res.json = (data) => {
        client.setEx(key, duration, JSON.stringify(data));
        res.sendResponse(data);
      };

      next();
    } catch (error) {
      next();
    }
  };
};

// Utilisation
router.get('/posts', cache(3600), getPosts);

// Invalidation de cache
const invalidateCache = async (pattern) => {
  const keys = await client.keys(pattern);
  if (keys.length > 0) {
    await client.del(keys);
  }
};

// Invalider après création
router.post('/posts', auth, async (req, res) => {
  const post = await createPost(req.body);
  await invalidateCache('cache:/api/posts');
  res.json(post);
});

Compression et optimization

const compression = require('compression');
const cluster = require('cluster');
const os = require('os');

// Compression
app.use(compression({
  filter: (req, res) => {
    if (req.headers['x-no-compression']) {
      return false;
    }
    return compression.filter(req, res);
  },
  level: 6
}));

// Clustering
if (cluster.isMaster) {
  const numCPUs = os.cpus().length;

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(Worker ${worker.process.pid} died);
    cluster.fork();
  });
} else {
  app.listen(3000);
}

// Request timeout
app.use((req, res, next) => {
  req.setTimeout(30000);
  res.setTimeout(30000);
  next();
});

// Lazy loading
const lazyLoadRoute = (path) => {
  return (req, res, next) => {
    import(path).then(module => {
      module.default(req, res, next);
    });
  };
};

Bonnes pratiques

// 1. Structure de réponse cohérente
const successResponse = (data, message = 'Success') => ({
  success: true,
  message,
  data
});

const errorResponse = (message, errors = null) => ({
  success: false,
  message,
  errors
});

// 2. Logging structuré
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

// 3. Gestion d'erreurs centralisée
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 4. Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, closing server...');
  server.close(async () => {
    await mongoose.connection.close();
    process.exit(0);
  });
});

// 5. Health check endpoint
app.get('/health', (req, res) => {
  res.json({
    status: 'OK',
    timestamp: new Date(),
    uptime: process.uptime()
  });
});

Version: Node.js 20+, Express 4.18+ | Ressources: expressjs.com, nodejs.org

Une remarque, un retour ?

Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.