Chapitre 19
19 - Corrections des Exercices
19 - Corrections des Exercices
Chapitre 19 : Corrections Détaillées des Exercices
Introduction
Ce chapitre fournit les corrigés complets des 40 exercices. Chaque correction est structurée pour expliquer non seulement le code mais aussi les décisions architecturales et les compromis.
Exercice 01 : Hello World API
Solution
import express, { Request, Response, NextFunction } from "express";
const app = express();
const PORT = process.env.PORT ?? 3000;
// Middleware de logging
function requestLogger(req: Request, _res: Response, next: NextFunction): void {
const start = Date.now();
console.info(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
const duration = Date.now() - start;
console.info(`[${new Date().toISOString()}] ${req.method} ${req.url} - ${duration}ms`);
}
app.use(requestLogger);
app.get("/hello", (_req: Request, res: Response) => {
res.json({
message: "Hello World",
timestamp: new Date().toISOString(),
});
});
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.listen(PORT, () => {
console.info(`Server running on http://localhost:${PORT}`);
});
export { app };
Tests
import { describe, it, expect } from "vitest";
import supertest from "supertest";
import { app } from "./01-hello-world";
describe("Hello World API", () => {
it("should return hello message with timestamp", async () => {
const response = await supertest(app).get("/hello");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("message", "Hello World");
expect(response.body).toHaveProperty("timestamp");
expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp);
});
it("should return health status", async () => {
const response = await supertest(app).get("/health");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("status", "ok");
});
});
Discussion
L'approche utilise un middleware de logging pour séparer les préoccupations. Le port est configurable via variable d'environnement avec valeur par défaut. L'export de app permet les tests sans lancer le serveur.
Exercice 02 : CRUD Utilisateur basique
Solution
import { Router, Request, Response } from "express";
import { z } from "zod";
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
const updateUserSchema = createUserSchema.partial();
const router = Router();
const users = new Map<string, User>();
// POST /api/users
router.post("/", (req: Request, res: Response) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: result.error.flatten() });
return;
}
const id = crypto.randomUUID();
const user: User = {
id,
...result.data,
createdAt: new Date(),
updatedAt: new Date(),
};
users.set(id, user);
res.status(201).json(user);
});
// GET /api/users
router.get("/", (_req: Request, res: Response) => {
res.json(Array.from(users.values()));
});
// GET /api/users/:id
router.get("/:id", (req: Request, res: Response) => {
const user = users.get(req.params.id);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
res.json(user);
});
// PUT /api/users/:id
router.put("/:id", (req: Request, res: Response) => {
const existing = users.get(req.params.id);
if (!existing) {
res.status(404).json({ error: "User not found" });
return;
}
const result = updateUserSchema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: result.error.flatten() });
return;
}
const updated: User = {
...existing,
...result.data,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date(),
};
users.set(req.params.id, updated);
res.json(updated);
});
// DELETE /api/users/:id
router.delete("/:id", (req: Request, res: Response) => {
if (!users.has(req.params.id)) {
res.status(404).json({ error: "User not found" });
return;
}
users.delete(req.params.id);
res.status(204).send();
});
export { router as userRouter, users };
Discussion
L'utilisation de Map plutôt qu'un objet JavaScript offre de meilleures performances pour les suppressions fréquentes. UUID v4 pour les identifiants. Zod assure la validation avec des messages d'erreur explicites. Le statut 204 pour DELETE est plus correct que 200.
Exercice 03 : Validation avec Joi/Zod
Solution
import { z } from "zod";
// Schémas
const userSchema = z.object({
name: z.string().min(3).max(50),
email: z.string().email(),
age: z.number().int().min(18).max(120),
});
const productSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food", "books"]),
});
const orderSchema = z.object({
userId: z.string().uuid(),
products: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
}),
)
.min(1),
total: z.number().positive(),
});
// Middleware de validation générique
function validate(schema: z.ZodSchema, source: "body" | "query" | "params" = "body") {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
res.status(400).json({
error: "Validation failed",
details: result.error.errors.map((e) => ({
path: e.path.join("."),
message: e.message,
})),
});
return;
}
req[source] = result.data;
next();
};
}
// Utilisation
router.post("/users", validate(userSchema), createUserHandler);
router.post("/products", validate(productSchema), createProductHandler);
router.post("/orders", validate(orderSchema), createOrderHandler);
Discussion
Le middleware de validation générique réutilise un pattern commun. Les messages d'erreur détaillés aident au débogage. L'assignation req[source] = result.data nettoie les données transformées (trim, cast).
Exercice 04 : Middleware personnalisé
Solution
import { Request, Response, NextFunction } from "express";
// Logger
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const start = process.hrtime.bigint();
res.on("finish", () => {
const duration = Number(process.hrtime.bigint() - start) / 1e6;
console.info({
method: req.method,
url: req.originalUrl,
status: res.statusCode,
duration: `${duration.toFixed(2)}ms`,
ip: req.ip,
userAgent: req.get("user-agent"),
});
});
next();
}
// Error Handler
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string,
public details?: unknown,
) {
super(message);
this.name = "AppError";
}
}
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code ?? "APP_ERROR",
message: err.message,
details: err.details,
},
});
return;
}
console.error("Unhandled error:", err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
});
}
// Rate Limiter (in-memory, basique)
interface RateLimitEntry {
count: number;
resetAt: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
export function rateLimiter(maxRequests: number = 100, windowMs: number = 60_000) {
return (req: Request, res: Response, next: NextFunction): void => {
const key = req.ip ?? "unknown";
const now = Date.now();
let entry = rateLimitStore.get(key);
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + windowMs };
rateLimitStore.set(key, entry);
}
entry.count++;
res.setHeader("X-RateLimit-Limit", maxRequests);
res.setHeader("X-RateLimit-Remaining", Math.max(0, maxRequests - entry.count));
res.setHeader("X-RateLimit-Reset", Math.ceil(entry.resetAt / 1000));
if (entry.count > maxRequests) {
res.status(429).json({
error: {
code: "RATE_LIMIT_EXCEEDED",
message: "Too many requests, please try again later",
},
});
return;
}
next();
};
}
// Request Validator (réutilisable)
export function requestValidator(schema: z.ZodSchema) {
return (req: Request, _res: Response, next: NextFunction): void => {
const result = schema.safeParse({ ...req.body, ...req.query, ...req.params });
if (!result.success) {
next(new AppError(400, "Validation failed", "VALIDATION_ERROR", result.error.errors));
return;
}
next();
};
}
Discussion
Chaque middleware a une responsabilité unique. Le rate limiter utilise une Map en mémoire (à remplacer par Redis en production). Le logger utilise les timestamps haute résolution (process.hrtime.bigint()) pour des métriques précises.
Exercice 05 : Routes paramétrées
Solution
import { Router, Request, Response } from "express";
interface ProductQuery {
category?: string;
minPrice?: number;
maxPrice?: number;
page: number;
limit: number;
sort?: "price" | "name" | "createdAt";
order?: "asc" | "desc";
}
const router = Router();
router.get("/products", (req: Request, res: Response) => {
const {
category,
minPrice,
maxPrice,
page = "1",
limit = "10",
sort = "createdAt",
order = "desc",
} = req.query as Record<string, string>;
const query: ProductQuery = {
category,
minPrice: minPrice ? Number(minPrice) : undefined,
maxPrice: maxPrice ? Number(maxPrice) : undefined,
page: Math.max(1, Number(page)),
limit: Math.min(100, Math.max(1, Number(limit))),
sort: sort as ProductQuery["sort"],
order: order as ProductQuery["order"],
};
// Implémentation simulée
const filteredProducts = mockProducts
.filter((p) => !query.category || p.category === query.category)
.filter((p) => !query.minPrice || p.price >= query.minPrice!)
.filter((p) => !query.maxPrice || p.price <= query.maxPrice!);
const total = filteredProducts.length;
const totalPages = Math.ceil(total / query.limit);
const start = (query.page - 1) * query.limit;
const paginatedProducts = filteredProducts.slice(start, start + query.limit);
res.json({
data: paginatedProducts,
pagination: {
page: query.page,
limit: query.limit,
total,
totalPages,
hasNext: query.page < totalPages,
hasPrev: query.page > 1,
},
});
});
router.get("/products/:id/reviews", (req: Request, res: Response) => {
const { id } = req.params;
const { page = "1", limit = "10" } = req.query as Record<string, string>;
const productReviews = mockReviews.filter((r) => r.productId === id);
const pageNum = Number(page);
const limitNum = Number(limit);
const start = (pageNum - 1) * limitNum;
res.json({
data: productReviews.slice(start, start + limitNum),
pagination: {
page: pageNum,
limit: limitNum,
total: productReviews.length,
},
});
});
Discussion
Les valeurs sont nettoyées et validées avec des bornes (page >= 1, limit entre 1 et 100). La pagination retourne des métadonnées complètes pour que le client sache s'il y a plus de pages.
Exercice 06 : Gestion d'erreurs HTTP
Solution
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: string;
public readonly details?: unknown;
public readonly isOperational: boolean;
constructor(statusCode: number, message: string, code?: string, details?: unknown) {
super(message);
this.statusCode = statusCode;
this.code = code ?? "APP_ERROR";
this.details = details;
this.isOperational = true;
this.name = "AppError";
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id?: string) {
const message = id ? `${resource} with id '${id}' not found` : `${resource} not found`;
super(404, message, "NOT_FOUND");
}
}
export class ValidationError extends AppError {
constructor(details: unknown) {
super(400, "Validation failed", "VALIDATION_ERROR", details);
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Authentication required") {
super(401, message, "UNAUTHORIZED");
}
}
export class ForbiddenError extends AppError {
constructor(message = "Access denied") {
super(403, message, "FORBIDDEN");
}
}
export class ConflictError extends AppError {
constructor(message = "Resource already exists") {
super(409, message, "CONFLICT");
}
}
// Middleware de traitement centralisé
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
return;
}
// Erreur inattendue
console.error("UNHANDLED ERROR:", err);
res.status(500).json({
error: {
code: "INTERNAL_SERVER_ERROR",
message: "An unexpected error occurred",
},
});
}
Discussion
Les classes d'erreur spécialisées améliorent la lisibilité et la maintenabilité. Toutes les erreurs opérationnelles sont standardisées. Le flag isOperational permet de distinguer les erreurs prévisibles des bugs inattendus.
Exercices 07-40
Exercice 07 : Environnements et configuration
Pattern : Configuration validation avec Zod, typage strict.
import { z } from "zod";
import dotenv from "dotenv";
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(["development", "staging", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default("15m"),
REFRESH_TOKEN_EXPIRES_IN: z.string().default("7d"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
CORS_ORIGIN: z.string().default("*"),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment variables:", parsed.error.flatten());
process.exit(1);
}
export const config = parsed.data;
export type Config = z.infer<typeof envSchema>;
Exercice 08 : Logging structuré
Pattern : Pino avec correlation ID.
import pino from "pino";
import { randomUUID } from "crypto";
import { Request, Response, NextFunction } from "express";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
transport:
process.env.NODE_ENV === "development"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
serializers: {
req: (req) => ({ method: req.method, url: req.url, correlationId: req.correlationId }),
res: (res) => ({ statusCode: res.statusCode }),
err: pino.stdSerializers.err,
},
});
export function correlationMiddleware(req: Request, _res: Response, next: NextFunction): void {
req.correlationId = (req.get("X-Correlation-ID") ?? randomUUID()) as string;
next();
}
export { logger };
Exercice 09 : Upload de fichiers
Pattern : Multer + Sharp + S3 presigned URL.
import multer from "multer";
import sharp from "sharp";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (_req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
cb(null, allowed.includes(file.mimetype));
},
});
async function processImage(buffer: Buffer): Promise<Buffer> {
return sharp(buffer).resize(1200, 1200, { fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).toBuffer();
}
async function uploadToS3(buffer: Buffer, key: string): Promise<string> {
const s3 = new S3Client({ region: process.env.AWS_REGION });
await s3.send(new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, Body: buffer }));
return getSignedUrl(s3, new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }), { expiresIn: 3600 });
}
Exercice 10 : Sérialisation et DTO
Pattern : DTOs explicites avec class-transformer ou mapping manuel.
interface UserEntity {
id: string;
name: string;
email: string;
passwordHash: string;
role: string;
createdAt: Date;
updatedAt: Date;
}
interface UserResponse {
id: string;
name: string;
email: string;
role: string;
createdAt: string;
}
function toUserResponse(user: UserEntity): UserResponse {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt.toISOString(),
};
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
function toPaginatedResponse<T>(data: T[], total: number, page: number, limit: number): PaginatedResponse<T> {
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
};
}
Exercice 11 : PostgreSQL avec TypeORM
import "reflect-metadata";
import { DataSource, Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from "typeorm";
@Entity("users")
class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ length: 100 })
name!: string;
@Column({ unique: true })
email!: string;
@Column({ select: false })
passwordHash!: string;
@CreateDateColumn()
createdAt!: Date;
@OneToMany(() => Post, (post) => post.author)
posts!: Post[];
}
const AppDataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
entities: [User],
synchronize: process.env.NODE_ENV !== "production",
logging: process.env.NODE_ENV === "development",
poolSize: 20,
});
export { AppDataSource, User };
Exercice 12 : Authentification JWT
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { z } from "zod";
const SALT_ROUNDS = 12;
const registerSchema = z.object({
name: z.string().min(3).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
});
interface TokenPair {
accessToken: string;
refreshToken: string;
}
async function generateTokens(userId: string, role: string): Promise<TokenPair> {
const accessToken = jwt.sign({ sub: userId, role }, process.env.JWT_SECRET!, {
expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: userId, type: "refresh" }, process.env.JWT_REFRESH_SECRET!, {
expiresIn: "7d",
});
return { accessToken, refreshToken };
}
function verifyAccessToken(token: string): jwt.JwtPayload {
return jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload;
}
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// Middleware d'authentification
import { Request, Response, NextFunction } from "express";
interface AuthRequest extends Request {
userId?: string;
userRole?: string;
}
function authenticate(req: AuthRequest, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
res.status(401).json({ error: { code: "UNAUTHORIZED", message: "Missing token" } });
return;
}
try {
const payload = verifyAccessToken(header.slice(7));
req.userId = payload.sub;
req.userRole = payload.role as string;
next();
} catch {
res.status(401).json({ error: { code: "TOKEN_EXPIRED", message: "Token invalid or expired" } });
}
}
Exercice 13 : Cache Redis
import { createClient } from "redis";
import { Request, Response, NextFunction } from "express";
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
interface CacheOptions {
ttl?: number;
key?: (req: Request) => string;
}
function cacheMiddleware(options: CacheOptions = {}) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (req.method !== "GET") {
next();
return;
}
const key = options.key?.(req) ?? `cache:${req.originalUrl}`;
const ttl = options.ttl ?? 300;
try {
const cached = await redisClient.get(key);
if (cached) {
res.json(JSON.parse(cached));
return;
}
const originalJson = res.json.bind(res);
res.json = function (body: unknown): Response {
redisClient.setEx(key, ttl, JSON.stringify(body)).catch(console.error);
return originalJson(body);
};
next();
} catch (error) {
console.error("Cache error:", error);
next();
}
};
}
// Invalidation pattern
async function invalidateCache(pattern: string): Promise<void> {
const keys = await redisClient.keys(pattern);
if (keys.length > 0) {
await redisClient.del(keys);
}
}
Exercice 14 : Pagination avancée
interface OffsetPaginationParams {
page: number;
limit: number;
}
interface CursorPaginationParams {
cursor?: string;
limit: number;
}
interface OffsetPaginationResult<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
interface CursorPaginationResult<T> {
data: T[];
pagination: {
nextCursor?: string;
hasMore: boolean;
};
}
class PaginationService {
static offset(page: number, limit: number): { skip: number; take: number } {
return {
skip: (page - 1) * limit,
take: limit,
};
}
static cursor(cursor?: string): { cursor?: Date; take: number } {
if (!cursor) return { take: 21 }; // 20 + 1 for hasMore check
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
return { cursor: new Date(decoded), take: 21 };
}
static encodeCursor(date: Date): string {
return Buffer.from(date.toISOString()).toString("base64");
}
}
Exercice 15 : Recherche full-text
import { Router, Request, Response } from "express";
const searchRouter = Router();
searchRouter.get("/search", async (req: Request, res: Response) => {
const { q, category, minPrice, maxPrice, page = "1", limit = "20" } = req.query as Record<string, string>;
const query = `
SELECT
p.*,
ts_rank(p.search_vector, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', p.description, plainto_tsquery('english', $1),
'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=20') AS headline
FROM products p
WHERE p.search_vector @@ plainto_tsquery('english', $1)
AND ($2::text IS NULL OR p.category = $2)
AND ($3::numeric IS NULL OR p.price >= $3)
AND ($4::numeric IS NULL OR p.price <= $4)
ORDER BY rank DESC
LIMIT $5 OFFSET $6
`;
const offset = (Number(page) - 1) * Number(limit);
const result = await pool.query(query, [q, category ?? null, minPrice ?? null, maxPrice ?? null, Number(limit), offset]);
res.json({
data: result.rows,
search: { query: q, total: result.rowCount },
});
});
Exercice 16 : Rate limiting distribué
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
async function slidingWindowRateLimit(
key: string,
limit: number,
windowMs: number,
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
multi.zRemRangeByScore(key, 0, windowStart);
multi.zAdd(key, { score: now, value: `${now}` });
multi.zCard(key);
multi.expire(key, Math.ceil(windowMs / 1000));
multi.zRangeWithScores(key, 0, -1);
const [, , count] = await multi.exec();
const requestCount = count as number;
return {
allowed: requestCount <= limit,
remaining: Math.max(0, limit - requestCount),
resetAt: now + windowMs,
};
}
Exercice 17 : Import/Export CSV
import { parse } from "csv-parse";
import { stringify } from "csv-stringify";
import { Transform } from "stream";
import { pipeline } from "stream/promises";
async function importCSV<T>(filePath: string, batchSize: number, processBatch: (records: T[]) => Promise<void>): Promise<void> {
let batch: T[] = [];
const parser = parse({ columns: true, skipEmptyLines: true, trim: true });
const transformer = new Transform({
objectMode: true,
transform(record: T, _encoding, callback) {
batch.push(record);
if (batch.length >= batchSize) {
const currentBatch = batch;
batch = [];
processBatch(currentBatch).then(() => callback(null)).catch(callback);
} else {
callback(null);
}
},
flush(callback) {
if (batch.length > 0) {
processBatch(batch).then(() => callback(null)).catch(callback);
} else {
callback(null);
}
},
});
const source = await fs.open(filePath, "r");
const readStream = source.createReadStream();
await pipeline(readStream, parser, transformer);
}
async function exportCSV<T>(data: T[], columns: string[], filePath: string): Promise<void> {
const stringifier = stringify({ header: true, columns });
const writeStream = fs.createWriteStream(filePath);
for (const record of data) {
stringifier.write(record);
}
stringifier.end();
await pipeline(stringifier, writeStream);
}
Exercice 18 : WebSockets
import { Server as SocketIOServer } from "socket.io";
import { Server as HTTPServer } from "http";
import jwt from "jsonwebtoken";
function setupWebSocket(httpServer: HTTPServer): SocketIOServer {
const io = new SocketIOServer(httpServer, {
cors: { origin: process.env.CORS_ORIGIN },
pingInterval: 25000,
pingTimeout: 20000,
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
next(new Error("Authentication required"));
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload;
(socket as any).userId = payload.sub;
next();
} catch {
next(new Error("Invalid token"));
}
});
io.on("connection", (socket) => {
const userId = (socket as any).userId;
console.info(`User ${userId} connected`);
socket.join(`user:${userId}`);
socket.on("join:room", (room: string) => {
socket.join(room);
});
socket.on("leave:room", (room: string) => {
socket.leave(room);
});
socket.on("disconnect", () => {
console.info(`User ${userId} disconnected`);
});
});
return io;
}
Exercice 19 : Tests d'intégration
import { describe, beforeAll, afterAll, it, expect } from "vitest";
import supertest from "supertest";
import { AppDataSource } from "../src/database";
import { app } from "../src/app";
const request = supertest(app);
beforeAll(async () => {
await AppDataSource.initialize();
await AppDataSource.runMigrations();
});
afterAll(async () => {
await AppDataSource.dropDatabase();
await AppDataSource.destroy();
});
describe("User API Integration", () => {
it("should create a user", async () => {
const res = await request.post("/api/users").send({ name: "Test", email: "test@example.com" });
expect(res.status).toBe(201);
expect(res.body.data).toHaveProperty("id");
});
it("should list users", async () => {
const res = await request.get("/api/users");
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
});
});
Exercice 20 : Documentation Swagger
import swaggerJsdoc from "swagger-jsdoc";
import swaggerUi from "swagger-ui-express";
import { Express } from "express";
const options: swaggerJsdoc.Options = {
definition: {
openapi: "3.0.0",
info: {
title: "Modern Backend API",
version: "1.0.0",
description: "RESTful API documentation",
},
servers: [{ url: "/api/v1" }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
},
},
},
apis: ["./src/routes/*.ts", "./src/controllers/*.ts"],
};
export function setupSwagger(app: Express): void {
const specs = swaggerJsdoc(options);
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(specs, { explorer: true }));
}
Exercices Niveau Avancé (21-30)
Exercice 21 : Queue BullMQ
import { Queue, Worker, QueueEvents } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
const emailQueue = new Queue("email", { connection });
const worker = new Worker(
"email",
async (job) => {
const { to, subject, body } = job.data;
await sendEmail(to, subject, body);
return { sent: true };
},
{
connection,
concurrency: 5,
limiter: { max: 10, duration: 1000 },
},
);
worker.on("completed", (job) => console.info(`Job ${job.id} completed`));
worker.on("failed", (job, err) => console.error(`Job ${job?.id} failed:`, err));
Exercice 22 : Kafka Producer/Consumer
import { Kafka, Producer, Consumer, EachMessagePayload } from "kafkajs";
const kafka = new Kafka({
clientId: "backend-app",
brokers: (process.env.KAFKA_BROKERS ?? "localhost:9093").split(","),
});
const producer: Producer = kafka.producer({ allowAutoTopicCreation: true, idempotent: true });
const consumer: Consumer = kafka.consumer({ groupId: "backend-group" });
async function produce(topic: string, messages: Array<{ key?: string; value: string }>): Promise<void> {
await producer.connect();
await producer.send({
topic,
messages: messages.map((m) => ({
key: m.key,
value: m.value,
timestamp: Date.now().toString(),
})),
acks: -1, // all replicas
});
}
async function consume(topic: string, handler: (payload: EachMessagePayload) => Promise<void>): Promise<void> {
await consumer.connect();
await consumer.subscribe({ topic, fromBeginning: false });
await consumer.run({
eachMessage: handler,
autoCommit: true,
partitionsConsumedConcurrently: 3,
});
}
Exercice 23 : API Gateway
import httpProxy from "http-proxy-middleware";
import { Router, Request, Response, NextFunction } from "express";
const proxy = httpProxy.createProxyMiddleware({
changeOrigin: true,
proxyTimeout: 30000,
timeout: 30000,
});
const services = {
users: "http://user-service:3001",
orders: "http://order-service:3002",
payments: "http://payment-service:3003",
};
const gatewayRouter = Router();
gatewayRouter.use("/api/v1/users", authenticate, rateLimit("users"), (req, res, next) => {
proxy.web(req, res, { target: services.users }, next);
});
gatewayRouter.use("/api/v1/orders", authenticate, rateLimit("orders"), (req, res, next) => {
proxy.web(req, res, { target: services.orders }, next);
});
Exercice 24 : Cache invalidation pattern
// Pub/Sub pour l'invalidation distribuée
const publisher = createClient({ url: process.env.REDIS_URL! });
const subscriber = createClient({ url: process.env.REDIS_URL! });
await publisher.connect();
await subscriber.connect();
async function publishInvalidation(pattern: string): Promise<void> {
await publisher.publish("cache:invalidation", JSON.stringify({ pattern, timestamp: Date.now() }));
}
async function subscribeInvalidation(): Promise<void> {
await subscriber.subscribe("cache:invalidation", async (message) => {
const { pattern } = JSON.parse(message);
const keys = await publisher.keys(pattern);
if (keys.length > 0) {
await publisher.del(keys);
}
});
}
Exercice 25 : Transactions distribuées (Saga)
interface SagaStep<T = unknown> {
name: string;
execute: (context: T) => Promise<void>;
compensate: (context: T) => Promise<void>;
}
class SagaOrchestrator<T = unknown> {
private steps: SagaStep<T>[] = [];
private executedSteps: SagaStep<T>[] = [];
addStep(step: SagaStep<T>): this {
this.steps.push(step);
return this;
}
async execute(context: T): Promise<void> {
for (const step of this.steps) {
try {
await step.execute(context);
this.executedSteps.push(step);
} catch (error) {
console.error(`Saga failed at step '${step.name}', compensating...`);
await this.compensate();
throw error;
}
}
}
private async compensate(): Promise<void> {
for (const step of this.executedSteps.reverse()) {
try {
await step.compensate(step as unknown as T);
} catch (error) {
console.error(`Compensation failed for step '${step.name}':`, error);
}
}
}
}
Exercice 26 : Docker multi-stage
Voir le Dockerfile à la racine du projet pour l'implémentation complète multi-stage avec optimisation layer caching, alpine, healthcheck, et tini.
Exercice 27 : CI/CD Pipeline
Voir .github/workflows/ci.yml et .github/workflows/deploy.yml à la racine du projet.
Exercice 28 : Monitoring et métriques
import prometheus from "prom-client";
const httpRequestsTotal = new prometheus.Counter({
name: "http_requests_total",
help: "Total HTTP requests",
labelNames: ["method", "route", "status"],
});
const httpRequestDuration = new prometheus.Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration in seconds",
labelNames: ["method", "route"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});
const activeConnections = new prometheus.Gauge({
name: "http_active_connections",
help: "Active HTTP connections",
});
Exercice 29 : Graceful Shutdown
async function gracefulShutdown(server: http.Server, ...clients: Array<{ destroy: () => Promise<void> }>): Promise<void> {
console.info("SIGTERM received. Shutting down gracefully...");
server.close(async () => {
console.info("HTTP server closed");
for (const client of clients) {
await client.destroy();
}
process.exit(0);
});
// Force shutdown after timeout
setTimeout(() => {
console.error("Forced shutdown after 30s timeout");
process.exit(1);
}, 30000).unref();
}
process.on("SIGTERM", () => gracefulShutdown(server, redisClient, dbConnection));
process.on("SIGINT", () => gracefulShutdown(server, redisClient, dbConnection));
Exercice 30 : Feature Flags
interface FeatureFlag {
name: string;
enabled: boolean;
percentage?: number;
rules?: Array<{ field: string; operator: string; value: unknown }>;
}
class FeatureFlagService {
private cache = new Map<string, boolean>();
async isEnabled(flag: string, context?: Record<string, unknown>): Promise<boolean> {
const cacheKey = `${flag}:${JSON.stringify(context)}`;
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey)!;
const feature = await this.getFeatureFlag(flag);
if (!feature) return false;
if (!feature.enabled) return false;
if (feature.percentage && context?.userId) {
const hash = this.hashCode(String(context.userId)) % 100;
if (hash >= feature.percentage) return false;
}
this.cache.set(cacheKey, feature.enabled);
return feature.enabled;
}
}
Exercices Niveau Expert (31-40)
Exercice 31 : Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-app
labels:
app: backend
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: app
image: ghcr.io/org/backend:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet: { path: /ready, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }
Exercice 32 : Helm Chart
# Chart.yaml
apiVersion: v2
name: backend-app
description: Modern Backend Application
type: application
version: 0.1.0
appVersion: "1.0.0"
dependencies:
- name: postgresql
version: "12.x"
repository: "https://charts.bitnami.com/bitnami"
- name: redis
version: "18.x"
repository: "https://charts.bitnami.com/bitnami"
Exercice 33 : Terraform Infrastructure
# main.tf
terraform {
backend "s3" {
bucket = "backend-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
name = "backend-${var.environment}"
cidr = "10.0.0.0/16"
azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = "backend-${var.environment}"
cluster_version = "1.30"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
node_groups = {
main = {
desired_capacity = 3
max_capacity = 10
min_capacity = 1
instance_types = ["t3.medium"]
}
}
}
Exercice 34 : Service Mesh (Istio)
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: backend
spec:
hosts:
- backend
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: backend
subset: v2
weight: 100
- route:
- destination:
host: backend
subset: v1
weight: 90
- destination:
host: backend
subset: v2
weight: 10
retries:
attempts: 3
perTryTimeout: 2s
Exercice 35 : Database Sharding
class ShardManager {
private shards: string[];
private readonly SHARD_COUNT: number;
constructor(shards: string[]) {
this.shards = shards;
this.SHARD_COUNT = shards.length;
}
getShard(key: string): string {
const hash = this.hash(key);
const shardIndex = hash % this.SHARD_COUNT;
return this.shards[shardIndex];
}
private hash(key: string): number {
let hash = 0;
for (let i = 0; i < key.length; i++) {
const char = key.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
}
Exercice 36 : Event Sourcing
interface DomainEvent {
id: string;
aggregateId: string;
aggregateType: string;
type: string;
data: Record<string, unknown>;
version: number;
timestamp: Date;
}
class EventStore {
async append(event: DomainEvent): Promise<void> {
await pool.query(
`INSERT INTO events (id, aggregate_id, aggregate_type, type, data, version, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[event.id, event.aggregateId, event.aggregateType, event.type, JSON.stringify(event.data), event.version, event.timestamp],
);
}
async getEvents(aggregateId: string): Promise<DomainEvent[]> {
const result = await pool.query(
"SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version ASC",
[aggregateId],
);
return result.rows;
}
async getProjection<T>(projectionName: string): Promise<T | null> {
const result = await pool.query(
"SELECT data FROM projections WHERE name = $1",
[projectionName],
);
return result.rows[0]?.data ?? null;
}
}
Exercice 37 : GraphQL
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import DataLoader from "dataloader";
const typeDefs = `#graphql
type User { id: ID!, name: String!, email: String!, posts: [Post!]! }
type Post { id: ID!, title: String!, author: User!, comments: [Comment!]! }
type Comment { id: ID!, text: String!, author: User! }
type Query { users: [User!]! user(id: ID!): User posts: [Post!]! }
type Mutation { createUser(name: String!, email: String!): User! }
`;
const resolvers = {
Query: {
users: () => userService.findAll(),
user: (_: unknown, { id }: { id: string }) => userService.findById(id),
},
User: {
posts: (parent: { id: string }) => postLoaders.load(parent.id),
},
};
const postLoaders = new DataLoader(async (userIds: readonly string[]) => {
const posts = await postService.findByUserIds(userIds as string[]);
const grouped = new Map<string, typeof posts>();
for (const post of posts) {
if (!grouped.has(post.authorId)) grouped.set(post.authorId, []);
grouped.get(post.authorId)!.push(post);
}
return userIds.map((id) => grouped.get(id) ?? []);
});
Exercice 38 : gRPC
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser (CreateUserRequest) returns (User);
rpc StreamUsers (Empty) returns (stream User);
}
message User {
string id = 1;
string name = 2;
string email = 3;
}
message GetUserRequest { string id = 1; }
message ListUsersRequest { int32 page = 1; int32 limit = 2; }
message ListUsersResponse { repeated User users = 1; int32 total = 2; }
message CreateUserRequest { string name = 1; string email = 2; }
message Empty {}
Exercice 39 : Chaos Engineering
class ChaosMonkey {
private enabled = false;
private failureRate = 0.1;
async injectLatency(durationMs: number): Promise<void> {
if (!this.enabled || Math.random() > this.failureRate) return;
await new Promise((resolve) => setTimeout(resolve, durationMs));
}
async injectError<T>(fallback: T): Promise<T> {
if (!this.enabled || Math.random() > this.failureRate) return fallback;
throw new Error("Chaos monkey injected failure");
}
}
Exercice 40 : Plateforme complète
L'exercice 40 est un projet de synthèse qui combine l'ensemble des concepts. L'architecture proposée :
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │────▶│ API Gateway │────▶│ Auth Service│
└─────────────┘ └──────┬───────┘ └─────────────┘
│
┌────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ User │ │ Order │ │ Payment │
│ Service │ │ Service │ │ Service │
└────┬─────┘ └────┬─────┘ └──────┬───────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼──────┐
│Postgres │ │ Redis │ │ Kafka │
└─────────┘ └─────────┘ └────────────┘
Le déploiement complet inclut :
- Kubernetes avec Helm
- Terraform pour l'infrastructure cloud
- CI/CD avec GitHub Actions
- Monitoring Prometheus/Grafana
- Tracing avec OpenTelemetry
- Logging structuré ELK
- Tests de résilience Chaos Engineering