Intermediaire 17 min de lecture · 3 677 mots

REST API : Design patterns et meilleures pratiques

Estimated reading time: 18 minutes

Introduction

Les API REST (Representational State Transfer) sont devenues le standard de facto pour la communication entre applications web en 2025. Avec plus de 83% des APIs publiques utilisant REST, la maîtrise des design patterns et des meilleures pratiques n’est plus optionnelle. Ce guide complet vous accompagnera dans la conception d’APIs REST professionnelles, scalables et maintenables.

Principes fondamentaux REST

Les 6 contraintes REST

REST n’est pas un protocole mais un style architectural basé sur 6 contraintes :

  • Client-Serveur : Séparation des responsabilités
  • Sans état (Stateless) : Chaque requête contient toutes les informations nécessaires
  • Cacheable : Les réponses doivent indiquer si elles peuvent être mises en cache
  • Interface uniforme : Utilisation cohérente des verbes HTTP et des URI
  • Système en couches : L’architecture peut avoir des couches intermédiaires (proxy, gateway)
  • Code à la demande (optionnel) : Le serveur peut envoyer du code exécutable
  • Anatomie d’une requête REST

POST /api/v1/users HTTP/1.1
Host: api.exemple.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json
User-Agent: MonApp/1.0

{
  "username": "jdupont",
  "email": "j.dupont@exemple.com",
  "password": "SecureP@ss123"
}

Réponse :

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/users/12345
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1703001600

{
  "id": 12345,
  "username": "jdupont",
  "email": "j.dupont@exemple.com",
  "createdat": "2025-12-18T10:30:00Z",
  "profileurl": "/api/v1/users/12345",
  "links": {
    "self": { "href": "/api/v1/users/12345" },
    "posts": { "href": "/api/v1/users/12345/posts" }
  }
}

Design patterns essentiels

1. Resource-Based URLs (URLs orientées ressources)

MAUVAIS :

GET  /getUsers
POST /createUser
POST /updateUser
POST /deleteUser
GET  /getUserPosts?userId=123

BON :

GET    /api/v1/users                    # Liste des utilisateurs
POST   /api/v1/users                    # Créer un utilisateur
GET    /api/v1/users/123                # Détails d'un utilisateur
PUT    /api/v1/users/123                # Remplacer un utilisateur
PATCH  /api/v1/users/123                # Modifier partiellement
DELETE /api/v1/users/123                # Supprimer un utilisateur
GET    /api/v1/users/123/posts          # Posts de l'utilisateur
POST   /api/v1/users/123/posts          # Créer un post pour l'utilisateur

2. Utilisation correcte des verbes HTTP

// Express.js - API complète d'utilisateurs
const express = require('express');
const router = express.Router();

// GET - Récupérer (Idempotent & Safe)
router.get('/users', async (req, res) => {
  try {
    const { page = 1, limit = 20, sort = 'createdat', order = 'desc' } = req.query;

    const users = await User.findAll({
      limit: parseInt(limit),
      offset: (page - 1)  limit,
      order: [[sort, order.toUpperCase()]]
    });

    const total = await User.count();

    res.json({
      data: users,
      meta: {
        currentpage: parseInt(page),
        perpage: parseInt(limit),
        total: total,
        lastpage: Math.ceil(total / limit)
      },
      links: {
        self: /api/v1/users?page=${page},
        first: /api/v1/users?page=1,
        last: /api/v1/users?page=${Math.ceil(total / limit)},
        ...(page > 1 && { prev: /api/v1/users?page=${parseInt(page) - 1} }),
        ...(page < Math.ceil(total / limit) && { next: /api/v1/users?page=${parseInt(page) + 1} })
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error', message: error.message });
  }
});

// POST - Créer (Non-idempotent)
router.post('/users', async (req, res) => {
  try {
    const { username, email, password } = req.body;

    // Validation
    if (!username || !email || !password) {
      return res.status(400).json({
        error: 'Validation failed',
        details: {
          username: !username ? 'Username is required' : null,
          email: !email ? 'Email is required' : null,
          password: !password ? 'Password is required' : null
        }
      });
    }

    // Vérifier si l'utilisateur existe
    const existingUser = await User.findOne({ where: { email } });
    if (existingUser) {
      return res.status(409).json({
        error: 'Conflict',
        message: 'User with this email already exists'
      });
    }

    const user = await User.create({ username, email, password });

    res.status(201)
       .location(/api/v1/users/${user.id})
       .json({
         id: user.id,
         username: user.username,
         email: user.email,
         createdat: user.createdat
       });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// PUT - Remplacer complètement (Idempotent)
router.put('/users/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const { username, email, password } = req.body;

    const user = await User.findByPk(id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // PUT remplace TOUTES les propriétés
    await user.update({ username, email, password });

    res.json({
      id: user.id,
      username: user.username,
      email: user.email,
      updatedat: user.updatedat
    });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// PATCH - Modifier partiellement (Idempotent)
router.patch('/users/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const updates = req.body;

    const user = await User.findByPk(id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // PATCH modifie uniquement les champs fournis
    await user.update(updates);

    res.json({
      id: user.id,
      username: user.username,
      email: user.email,
      updatedat: user.updatedat
    });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// DELETE - Supprimer (Idempotent)
router.delete('/users/:id', async (req, res) => {
  try {
    const { id } = req.params;

    const user = await User.findByPk(id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    await user.destroy();

    res.status(204).send(); // No Content
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;

3. Versioning d’API

Approche recommandée : URL versioning

// server.js
const express = require('express');
const app = express();

// Version 1
const v1Routes = require('./routes/v1');
app.use('/api/v1', v1Routes);

// Version 2 - Modifications non rétrocompatibles
const v2Routes = require('./routes/v2');
app.use('/api/v2', v2Routes);

// Rediriger /api vers la dernière version
app.use('/api', v2Routes);

Autres approches :

# Header versioning
GET /api/users HTTP/1.1
Accept: application/vnd.myapp.v2+json

# Query parameter
GET /api/users?version=2

# Custom header
GET /api/users HTTP/1.1
X-API-Version: 2

4. Filtrage, tri et pagination

// routes/v1/products.js
router.get('/products', async (req, res) => {
  try {
    const {
      // Pagination
      page = 1,
      limit = 20,

      // Tri
      sort = 'createdat',
      order = 'desc',

      // Filtrage
      category,
      minprice,
      maxprice,
      instock,

      // Recherche
      search,

      // Sélection de champs
      fields
    } = req.query;

    // Construction de la requête
    const where = {};

    if (category) {
      where.category = category;
    }

    if (minprice || maxprice) {
      where.price = {};
      if (minprice) where.price[Op.gte] = parseFloat(minprice);
      if (maxprice) where.price[Op.lte] = parseFloat(maxprice);
    }

    if (instock !== undefined) {
      where.stock = instock === 'true' ? { [Op.gt]: 0 } : 0;
    }

    if (search) {
      where[Op.or] = [
        { name: { [Op.like]: %${search}% } },
        { description: { [Op.like]: %${search}% } }
      ];
    }

    // Sélection de champs
    const attributes = fields ? fields.split(',') : undefined;

    const products = await Product.findAndCountAll({
      where,
      attributes,
      limit: parseInt(limit),
      offset: (page - 1)  limit,
      order: [[sort, order.toUpperCase()]]
    });

    res.json({
      data: products.rows,
      meta: {
        currentpage: parseInt(page),
        perpage: parseInt(limit),
        total: products.count,
        lastpage: Math.ceil(products.count / limit),
        filtersapplied: {
          category,
          minprice,
          maxprice,
          instock,
          search
        }
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

Exemples d’utilisation :

# Filtrage basique
GET /api/v1/products?category=electronics&instock=true

# Filtrage par plage de prix
GET /api/v1/products?minprice=100&maxprice=500

# Recherche textuelle
GET /api/v1/products?search=laptop

# Tri
GET /api/v1/products?sort=price&order=asc

# Pagination
GET /api/v1/products?page=2&limit=50

# Sélection de champs (sparse fieldsets)
GET /api/v1/products?fields=id,name,price

# Combinaison
GET /api/v1/products?category=electronics&sort=price&order=asc&page=1&limit=20&fields=id,name,price

Gestion des erreurs professionnelle

Structure d’erreur standardisée

// middleware/errorHandler.js
class ApiError extends Error {
  constructor(statusCode, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
  }
}

const errorHandler = (err, req, res, next) => {
  // Logger l'erreur (production: utiliser Winston, Sentry, etc.)
  console.error('Error:', err);

  // Erreur personnalisée
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      error: {
        status: err.statusCode,
        message: err.message,
        ...(err.details && { details: err.details }),
        timestamp: new Date().toISOString(),
        path: req.path
      }
    });
  }

  // Erreur de validation (express-validator)
  if (err.array) {
    return res.status(400).json({
      error: {
        status: 400,
        message: 'Validation failed',
        details: err.array(),
        timestamp: new Date().toISOString(),
        path: req.path
      }
    });
  }

  // Erreur de base de données
  if (err.name === 'SequelizeValidationError') {
    return res.status(400).json({
      error: {
        status: 400,
        message: 'Database validation failed',
        details: err.errors.map(e => ({ field: e.path, message: e.message })),
        timestamp: new Date().toISOString(),
        path: req.path
      }
    });
  }

  // Erreur générique
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: {
      status: statusCode,
      message: process.env.NODEENV === 'production'
        ? 'Internal server error'
        : err.message,
      ...(process.env.NODEENV !== 'production' && { stack: err.stack }),
      timestamp: new Date().toISOString(),
      path: req.path
    }
  });
};

module.exports = { ApiError, errorHandler };

Codes de statut HTTP appropriés

// routes/v1/orders.js
const { ApiError } = require('../../middleware/errorHandler');

router.post('/orders', async (req, res, next) => {
  try {
    const { userid, items } = req.body;

    // 400 Bad Request - Validation échouée
    if (!items || items.length === 0) {
      throw new ApiError(400, 'Order must contain at least one item');
    }

    // 404 Not Found - Ressource inexistante
    const user = await User.findByPk(userid);
    if (!user) {
      throw new ApiError(404, 'User not found');
    }

    // 409 Conflict - Conflit d'état
    const pendingOrder = await Order.findOne({
      where: { userid, status: 'pending' }
    });
    if (pendingOrder) {
      throw new ApiError(409, 'User already has a pending order', {
        existingorderid: pendingOrder.id
      });
    }

    // 422 Unprocessable Entity - Données valides mais logique métier échouée
    for (const item of items) {
      const product = await Product.findByPk(item.productid);
      if (product.stock < item.quantity) {
        throw new ApiError(422, 'Insufficient stock', {
          productid: product.id,
          requested: item.quantity,
          available: product.stock
        });
      }
    }

    const order = await Order.create({ userid, items });

    // 201 Created - Ressource créée avec succès
    res.status(201)
       .location(/api/v1/orders/${order.id})
       .json(order);

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

// 204 No Content - Succès sans corps de réponse
router.delete('/orders/:id', async (req, res, next) => {
  try {
    const order = await Order.findByPk(req.params.id);
    if (!order) {
      throw new ApiError(404, 'Order not found');
    }

    await order.destroy();
    res.status(204).send();
  } catch (error) {
    next(error);
  }
});

// 503 Service Unavailable - Service temporairement indisponible
router.get('/external-data', async (req, res, next) => {
  try {
    const externalService = await fetch('https://external-api.com/data');

    if (!externalService.ok) {
      throw new ApiError(503, 'External service unavailable', {
        retryafter: 60
      });
    }

    const data = await externalService.json();
    res.json(data);
  } catch (error) {
    next(error);
  }
});

HATEOAS (Hypermedia As The Engine Of Application State)

// utils/hateoas.js
const generateLinks = (resource, id, relationships = []) => {
  const links = {
    self: { href: /api/v1/${resource}/${id} }
  };

  relationships.forEach(rel => {
    links[rel] = { href: /api/v1/${resource}/${id}/${rel} };
  });

  return links;
};

// routes/v1/users.js
router.get('/users/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json({
    id: user.id,
    username: user.username,
    email: user.email,
    links: generateLinks('users', user.id, ['posts', 'comments', 'followers']),
    actions: {
      update: {
        method: 'PATCH',
        href: /api/v1/users/${user.id},
        fields: ['username', 'email', 'bio']
      },
      delete: {
        method: 'DELETE',
        href: /api/v1/users/${user.id}
      }
    }
  });
});

Implémentation complète avec FastAPI (Python)

# main.py
from fastapi import FastAPI, HTTPException, Query, Path, status, Depends
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional, List
from datetime import datetime
from enum import Enum
import uvicorn

app = FastAPI(
    title="API REST Professionnelle",
    description="API REST avec meilleures pratiques 2025",
    version="1.0.0",
    docsurl="/api/docs",
    redocurl="/api/redoc"
)

# CORS
app.addmiddleware(
    CORSMiddleware,
    alloworigins=[""],
    allowcredentials=True,
    allowmethods=[""],
    allowheaders=[""],
)

# Modèles Pydantic
class UserRole(str, Enum):
    ADMIN = "admin"
    USER = "user"
    MODERATOR = "moderator"

class UserCreate(BaseModel):
    username: str = Field(..., minlength=3, maxlength=50)
    email: EmailStr
    password: str = Field(..., minlength=8)
    role: UserRole = UserRole.USER

    @validator('username')
    def usernamealphanumeric(cls, v):
        if not v.replace('', '').isalnum():
            raise ValueError('Username must be alphanumeric')
        return v

class UserUpdate(BaseModel):
    username: Optional[str] = Field(None, minlength=3, maxlength=50)
    email: Optional[EmailStr] = None
    role: Optional[UserRole] = None

class UserResponse(BaseModel):
    id: int
    username: str
    email: EmailStr
    role: UserRole
    createdat: datetime
    updatedat: Optional[datetime]

    class Config:
        fromattributes = True

class PaginatedResponse(BaseModel):
    data: List[UserResponse]
    meta: dict
    links: dict

class ErrorResponse(BaseModel):
    error: dict

# Gestion des erreurs personnalisée
@app.exceptionhandler(HTTPException)
async def httpexceptionhandler(request, exc):
    return JSONResponse(
        statuscode=exc.statuscode,
        content={
            "error": {
                "status": exc.statuscode,
                "message": exc.detail,
                "timestamp": datetime.now().isoformat(),
                "path": str(request.url.path)
            }
        }
    )

# Base de données simulée
usersdb = []
nextid = 1

# Endpoints
@app.get(
    "/api/v1/users",
    responsemodel=PaginatedResponse,
    summary="Liste des utilisateurs",
    description="Récupère une liste paginée d'utilisateurs avec filtres"
)
async def getusers(
    page: int = Query(1, ge=1, description="Numéro de page"),
    limit: int = Query(20, ge=1, le=100, description="Éléments par page"),
    role: Optional[UserRole] = Query(None, description="Filtrer par rôle"),
    search: Optional[str] = Query(None, description="Rechercher dans username/email")
):
    # Filtrage
    filteredusers = usersdb

    if role:
        filteredusers = [u for u in filteredusers if u.get('role') == role]

    if search:
        filteredusers = [
            u for u in filteredusers
            if search.lower() in u.get('username', '').lower()
            or search.lower() in u.get('email', '').lower()
        ]

    # Pagination
    total = len(filteredusers)
    start = (page - 1)  limit
    end = start + limit
    paginatedusers = filteredusers[start:end]

    return {
        "data": paginatedusers,
        "meta": {
            "currentpage": page,
            "perpage": limit,
            "total": total,
            "lastpage": (total + limit - 1) // limit
        },
        "links": {
            "self": f"/api/v1/users?page={page}&limit={limit}",
            "first": f"/api/v1/users?page=1&limit={limit}",
            "last": f"/api/v1/users?page={(total + limit - 1) // limit}&limit={limit}"
        }
    }

@app.post(
    "/api/v1/users",
    responsemodel=UserResponse,
    statuscode=status.HTTP201CREATED,
    summary="Créer un utilisateur",
    responses={
        201: {"description": "Utilisateur créé"},
        400: {"model": ErrorResponse, "description": "Validation échouée"},
        409: {"model": ErrorResponse, "description": "Utilisateur existe déjà"}
    }
)
async def createuser(user: UserCreate):
    global nextid

    # Vérifier si l'utilisateur existe
    if any(u.get('email') == user.email for u in usersdb):
        raise HTTPException(
            statuscode=status.HTTP409CONFLICT,
            detail="User with this email already exists"
        )

    newuser = {
        "id": nextid,
        "username": user.username,
        "email": user.email,
        "role": user.role,
        "createdat": datetime.now(),
        "updatedat": None
    }

    usersdb.append(newuser)
    nextid += 1

    return newuser

@app.get(
    "/api/v1/users/{userid}",
    responsemodel=UserResponse,
    summary="Détails d'un utilisateur",
    responses={
        200: {"description": "Utilisateur trouvé"},
        404: {"model": ErrorResponse, "description": "Utilisateur non trouvé"}
    }
)
async def getuser(
    userid: int = Path(..., ge=1, description="ID de l'utilisateur")
):
    user = next((u for u in usersdb if u.get('id') == userid), None)

    if not user:
        raise HTTPException(
            statuscode=status.HTTP404NOTFOUND,
            detail=f"User with id {userid} not found"
        )

    return user

@app.patch(
    "/api/v1/users/{userid}",
    responsemodel=UserResponse,
    summary="Modifier un utilisateur"
)
async def updateuser(
    userid: int = Path(..., ge=1),
    userupdate: UserUpdate = None
):
    user = next((u for u in usersdb if u.get('id') == userid), None)

    if not user:
        raise HTTPException(
            statuscode=status.HTTP404NOTFOUND,
            detail=f"User with id {userid} not found"
        )

    # Mise à jour partielle
    updatedata = userupdate.dict(excludeunset=True)
    for field, value in updatedata.items():
        user[field] = value

    user['updatedat'] = datetime.now()

    return user

@app.delete(
    "/api/v1/users/{userid}",
    statuscode=status.HTTP204NOCONTENT,
    summary="Supprimer un utilisateur"
)
async def deleteuser(userid: int = Path(..., ge=1)):
    global usersdb

    user = next((u for u in usersdb if u.get('id') == userid), None)

    if not user:
        raise HTTPException(
            statuscode=status.HTTP404NOTFOUND,
            detail=f"User with id {userid} not found"
        )

    usersdb = [u for u in usersdb if u.get('id') != userid]
    return None

# Health check
@app.get("/health")
async def healthcheck():
    return {
        "status": "healthy",
        "timestamp": datetime.now().isoformat(),
        "version": "1.0.0"
    }

if name == "main":
    uvicorn.run(app, host="0.0.0.0", port=8000)

Documentation OpenAPI/Swagger

# openapi.yaml
openapi: 3.0.3
info:
  title: API REST Professionnelle
  description: API REST avec design patterns et meilleures pratiques
  version: 1.0.0
  contact:
    name: Support API
    email: support@exemple.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.exemple.com/v1
    description: Production
  - url: https://staging-api.exemple.com/v1
    description: Staging
  - url: http://localhost:3000/v1
    description: Development

tags:
  - name: Users
    description: Gestion des utilisateurs
  - name: Products
    description: Gestion des produits

paths:
  /users:
    get:
      tags: [Users]
      summary: Liste des utilisateurs
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: role
          in: query
          schema:
            type: string
            enum: [admin, user, moderator]
      responses:
        '200':
          description: Liste paginée
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedUsers'

    post:
      tags: [Users]
      summary: Créer un utilisateur
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreate'
      responses:
        '201':
          description: Utilisateur créé
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          example: 123
        username:
          type: string
          example: jdupont
        email:
          type: string
          format: email
          example: j.dupont@exemple.com
        role:
          type: string
          enum: [admin, user, moderator]
        createdat:
          type: string
          format: date-time

    UserCreate:
      type: object
      required:
        - username
        - email
        - password
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 50
        email:
          type: string
          format: email
        password:
          type: string
          format: password
          minLength: 8

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            status:
              type: integer
            message:
              type: string
            timestamp:
              type: string
              format: date-time

    PaginatedUsers:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/User'
        meta:
          type: object
          properties:
            currentpage:
              type: integer
            perpage:
              type: integer
            total:
              type: integer
            lastpage:
              type: integer

  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1

    LimitParam:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  responses:
    BadRequest:
      description: Validation échouée
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

    Conflict:
      description: Conflit de ressource
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

security:
  - bearerAuth: []

Tests avec Jest et Supertest

// tests/users.test.js
const request = require('supertest');
const app = require('../app');

describe('Users API', () => {
  let userId;

  // Test GET /users
  describe('GET /api/v1/users', () => {
    it('devrait retourner une liste paginée', async () => {
      const res = await request(app)
        .get('/api/v1/users')
        .query({ page: 1, limit: 20 });

      expect(res.statusCode).toBe(200);
      expect(res.body).toHaveProperty('data');
      expect(res.body).toHaveProperty('meta');
      expect(res.body).toHaveProperty('links');
      expect(Array.isArray(res.body.data)).toBe(true);
    });

    it('devrait filtrer par rôle', async () => {
      const res = await request(app)
        .get('/api/v1/users')
        .query({ role: 'admin' });

      expect(res.statusCode).toBe(200);
      res.body.data.forEach(user => {
        expect(user.role).toBe('admin');
      });
    });

    it('devrait valider les paramètres de pagination', async () => {
      const res = await request(app)
        .get('/api/v1/users')
        .query({ page: -1 });

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

  // Test POST /users
  describe('POST /api/v1/users', () => {
    it('devrait créer un utilisateur valide', async () => {
      const newUser = {
        username: 'testuser',
        email: 'test@exemple.com',
        password: 'SecureP@ss123'
      };

      const res = await request(app)
        .post('/api/v1/users')
        .send(newUser);

      expect(res.statusCode).toBe(201);
      expect(res.body).toHaveProperty('id');
      expect(res.body.username).toBe(newUser.username);
      expect(res.body).not.toHaveProperty('password');
      expect(res.headers.location).toMatch(//api/v1/users/d+/);

      userId = res.body.id;
    });

    it('devrait rejeter un email invalide', async () => {
      const res = await request(app)
        .post('/api/v1/users')
        .send({
          username: 'testuser',
          email: 'invalid-email',
          password: 'SecureP@ss123'
        });

      expect(res.statusCode).toBe(400);
      expect(res.body.error.details).toBeDefined();
    });

    it('devrait rejeter un mot de passe trop court', async () => {
      const res = await request(app)
        .post('/api/v1/users')
        .send({
          username: 'testuser',
          email: 'test2@exemple.com',
          password: '123'
        });

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

    it('devrait rejeter un email déjà existant', async () => {
      const res = await request(app)
        .post('/api/v1/users')
        .send({
          username: 'testuser2',
          email: 'test@exemple.com',
          password: 'SecureP@ss123'
        });

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

  // Test PATCH /users/:id
  describe('PATCH /api/v1/users/:id', () => {
    it('devrait modifier partiellement un utilisateur', async () => {
      const res = await request(app)
        .patch(/api/v1/users/${userId})
        .send({ username: 'updateduser' });

      expect(res.statusCode).toBe(200);
      expect(res.body.username).toBe('updateduser');
      expect(res.body.updatedat).toBeDefined();
    });

    it('devrait retourner 404 pour un ID inexistant', async () => {
      const res = await request(app)
        .patch('/api/v1/users/99999')
        .send({ username: 'test' });

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

  // Test DELETE /users/:id
  describe('DELETE /api/v1/users/:id', () => {
    it('devrait supprimer un utilisateur', async () => {
      const res = await request(app)
        .delete(/api/v1/users/${userId});

      expect(res.statusCode).toBe(204);
      expect(res.body).toEqual({});
    });

    it('devrait retourner 404 après suppression', async () => {
      const res = await request(app)
        .get(/api/v1/users/${userId});

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

Tests avec Postman/Newman

{
  "info": {
    "name": "API REST Tests",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "Users",
      "item": [
        {
          "name": "Créer utilisateur",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Status 201', () => {",
                  "  pm.response.to.have.status(201);",
                  "});",
                  "",
                  "pm.test('Retourne un ID', () => {",
                  "  const json = pm.response.json();",
                  "  pm.expect(json).to.have.property('id');",
                  "  pm.environment.set('userid', json.id);",
                  "});",
                  "",
                  "pm.test('Header Location présent', () => {",
                  "  pm.response.to.have.header('Location');",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{n  "username": "{{$randomUserName}}",n  "email": "{{$randomEmail}}",n  "password": "SecureP@ss123"n}"
            },
            "url": {
              "raw": "{{baseurl}}/api/v1/users",
              "host": ["{{baseurl}}"],
              "path": ["api", "v1", "users"]
            }
          }
        },
        {
          "name": "Récupérer utilisateur",
          "event": [
            {
              "listen": "test",
              "script": {
                "exec": [
                  "pm.test('Status 200', () => {",
                  "  pm.response.to.have.status(200);",
                  "});",
                  "",
                  "pm.test('Temps de réponse < 500ms', () => {",
                  "  pm.expect(pm.response.responseTime).to.be.below(500);",
                  "});",
                  "",
                  "pm.test('Structure de réponse valide', () => {",
                  "  const schema = {",
                  "    type: 'object',",
                  "    required: ['id', 'username', 'email'],",
                  "    properties: {",
                  "      id: { type: 'number' },",
                  "      username: { type: 'string' },",
                  "      email: { type: 'string', format: 'email' }",
                  "    }",
                  "  };",
                  "  pm.response.to.have.jsonSchema(schema);",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "url": {
              "raw": "{{baseurl}}/api/v1/users/{{userid}}",
              "host": ["{{baseurl}}"],
              "path": ["api", "v1", "users", "{{user_id}}"]
            }
          }
        }
      ]
    }
  ]
}

Conclusion

La conception d’une API REST professionnelle en 2025 nécessite une attention particulière à :

  • Architecture RESTful pure : Utilisation correcte des verbes HTTP et des codes de statut
  • Versioning : Planification de l’évolution de l’API
  • Filtrage et pagination : Performance avec de grands ensembles de données
  • Gestion d’erreurs standardisée : Messages clairs et cohérents
  • Documentation OpenAPI : Documentation vivante et testable
  • Tests exhaustifs : Couverture unitaire et d’intégration
  • HATEOAS : Navigation découvrable de l’API
  • Ces pratiques garantissent une API scalable, maintenable et facile à consommer.

    Ressources supplémentaires

  • REST API Design Best Practices
  • OpenAPI Specification
  • HTTP Status Codes
  • Richardson Maturity Model
  • API Design Patterns

  • Mots-clés: REST API, design patterns API, meilleures pratiques API, Express.js, FastAPI, OpenAPI, Swagger, pagination API, filtrage API, versioning API, gestion erreurs API, tests API, HATEOAS

    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.