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