Django 5 : Créer une API REST avec Django REST Framework

Guide complet pour créer une API REST professionnelle avec Django 5 et DRF. Serializers, ViewSets, authentification JWT et bonnes pratiques expliqués.

Guide pour créer une API REST avec Django 5 et Django REST Framework

Django REST Framework (DRF) reste la référence incontournable pour créer des APIs REST avec Python. Combiné à Django 5, le framework offre une expérience de développement exceptionnelle avec ses serializers puissants, ses ViewSets automatisés et son système d'authentification flexible. Ce guide couvre la création complète d'une API professionnelle, de l'installation aux tests.

Django 5 + DRF 3.15

Django REST Framework 3.15 apporte le support complet de Django 5, des améliorations de performance significatives et une meilleure intégration avec les types Python natifs. La combinaison reste le choix privilégié pour les APIs Python en production.

Installation et configuration du projet

La mise en place d'un projet Django avec DRF nécessite quelques étapes de configuration. L'utilisation d'un environnement virtuel et d'une structure de projet claire facilite la maintenance à long terme.

bash
# terminal
# Création de l'environnement virtuel et installation des dépendances
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate   # Windows

# Installation de Django 5 et DRF
pip install django djangorestframework
pip install django-filter  # Filtrage avancé
pip install djangorestframework-simplejwt  # Authentification JWT

# Création du projet Django
django-admin startproject config .
python manage.py startapp api

Ces commandes créent un projet Django avec une application api dédiée aux endpoints REST.

python
# config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Applications tierces
    'rest_framework',
    'rest_framework_simplejwt',
    'django_filters',
    # Applications locales
    'api',
]

REST_FRAMEWORK = {
    # Classes d'authentification par défaut
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
    # Permissions par défaut : authentifié requis
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    # Pagination globale
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    # Filtres
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
}

Cette configuration définit l'authentification JWT, la pagination par défaut et les backends de filtrage pour toute l'API.

Création des modèles de données

Les modèles Django représentent la structure des données de l'API. Une conception soignée des modèles simplifie la création des serializers et des vues.

python
# api/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator, MaxValueValidator
import uuid

class User(AbstractUser):
    """Modèle utilisateur personnalisé avec champs supplémentaires."""
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
    bio = models.TextField(blank=True, max_length=500)
    avatar = models.URLField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.username


class Category(models.Model):
    """Catégorie pour organiser les articles."""
    name = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(max_length=100, unique=True)
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name_plural = 'categories'
        ordering = ['name']

    def __str__(self):
        return self.name


class Article(models.Model):
    """Article de blog avec relation auteur et catégorie."""
    STATUS_CHOICES = [
        ('draft', 'Brouillon'),
        ('published', 'Publié'),
        ('archived', 'Archivé'),
    ]

    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    content = models.TextField()
    excerpt = models.TextField(max_length=300, blank=True)
    # Relation avec l'auteur
    author = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='articles'
    )
    # Relation avec la catégorie
    category = models.ForeignKey(
        Category,
        on_delete=models.SET_NULL,
        null=True,
        related_name='articles'
    )
    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default='draft'
    )
    views_count = models.PositiveIntegerField(default=0)
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.title

L'utilisation d'UUID comme clés primaires améliore la sécurité (identifiants non prédictibles) et facilite la distribution des données.

Modèle User personnalisé

Toujours définir un modèle User personnalisé dès le début du projet, même sans champs supplémentaires. Modifier le modèle User après les migrations initiales est complexe et source d'erreurs.

Serializers : transformation et validation des données

Les serializers constituent le cœur de DRF. Ces classes transforment les objets Python en JSON et inversement, tout en validant les données entrantes.

python
# api/serializers.py
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from .models import Article, Category

User = get_user_model()


class UserSerializer(serializers.ModelSerializer):
    """Serializer pour la lecture des données utilisateur."""
    # Champ calculé : nombre d'articles publiés
    articles_count = serializers.SerializerMethodField()

    class Meta:
        model = User
        fields = [
            'id', 'username', 'email', 'bio',
            'avatar', 'articles_count', 'created_at'
        ]
        # Champs en lecture seule
        read_only_fields = ['id', 'created_at']

    def get_articles_count(self, obj):
        """Compte les articles publiés de l'utilisateur."""
        return obj.articles.filter(status='published').count()


class UserCreateSerializer(serializers.ModelSerializer):
    """Serializer pour la création d'utilisateur avec validation du mot de passe."""
    password = serializers.CharField(
        write_only=True,
        required=True,
        validators=[validate_password],
        style={'input_type': 'password'}
    )
    password_confirm = serializers.CharField(
        write_only=True,
        required=True,
        style={'input_type': 'password'}
    )

    class Meta:
        model = User
        fields = [
            'id', 'username', 'email', 'password',
            'password_confirm', 'bio', 'avatar'
        ]

    def validate(self, attrs):
        """Vérifie que les deux mots de passe correspondent."""
        if attrs['password'] != attrs['password_confirm']:
            raise serializers.ValidationError({
                'password_confirm': 'Les mots de passe ne correspondent pas.'
            })
        return attrs

    def create(self, validated_data):
        """Crée l'utilisateur avec le mot de passe hashé."""
        # Retire le champ de confirmation
        validated_data.pop('password_confirm')
        # Utilise create_user pour hasher le mot de passe
        user = User.objects.create_user(**validated_data)
        return user


class CategorySerializer(serializers.ModelSerializer):
    """Serializer pour les catégories avec compteur d'articles."""
    articles_count = serializers.IntegerField(
        source='articles.count',
        read_only=True
    )

    class Meta:
        model = Category
        fields = ['id', 'name', 'slug', 'description', 'articles_count']


class ArticleListSerializer(serializers.ModelSerializer):
    """Serializer léger pour la liste des articles."""
    # Affiche le username au lieu de l'UUID
    author = serializers.StringRelatedField()
    category = serializers.StringRelatedField()

    class Meta:
        model = Article
        fields = [
            'id', 'title', 'slug', 'excerpt',
            'author', 'category', 'status',
            'views_count', 'published_at', 'created_at'
        ]


class ArticleDetailSerializer(serializers.ModelSerializer):
    """Serializer complet pour le détail d'un article."""
    # Inclut les données complètes de l'auteur
    author = UserSerializer(read_only=True)
    category = CategorySerializer(read_only=True)
    # Champs pour l'écriture (accepte l'ID)
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(),
        source='category',
        write_only=True,
        required=False
    )

    class Meta:
        model = Article
        fields = [
            'id', 'title', 'slug', 'content', 'excerpt',
            'author', 'category', 'category_id',
            'status', 'views_count',
            'published_at', 'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'author', 'views_count', 'created_at', 'updated_at']

    def create(self, validated_data):
        """Assigne automatiquement l'auteur à l'utilisateur connecté."""
        validated_data['author'] = self.context['request'].user
        return super().create(validated_data)

La séparation entre ArticleListSerializer (léger) et ArticleDetailSerializer (complet) optimise les performances en évitant de charger des données inutiles pour les listes.

Prêt à réussir tes entretiens Django ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

ViewSets et routeurs automatiques

Les ViewSets regroupent les opérations CRUD dans une seule classe. Combinés aux routeurs, ils génèrent automatiquement les URLs de l'API.

python
# api/views.py
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny, IsAdminUser
from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth import get_user_model
from django.utils import timezone
from .models import Article, Category
from .serializers import (
    UserSerializer, UserCreateSerializer,
    ArticleListSerializer, ArticleDetailSerializer,
    CategorySerializer
)
from .permissions import IsAuthorOrReadOnly
from .filters import ArticleFilter

User = get_user_model()


class UserViewSet(viewsets.ModelViewSet):
    """
    ViewSet pour la gestion des utilisateurs.

    Endpoints générés :
    - GET /users/ : liste des utilisateurs
    - POST /users/ : création (inscription)
    - GET /users/{id}/ : détail
    - PUT/PATCH /users/{id}/ : modification
    - DELETE /users/{id}/ : suppression
    - GET /users/me/ : profil de l'utilisateur connecté
    """
    queryset = User.objects.all()
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ['username', 'email']
    ordering_fields = ['created_at', 'username']

    def get_serializer_class(self):
        """Utilise un serializer différent pour la création."""
        if self.action == 'create':
            return UserCreateSerializer
        return UserSerializer

    def get_permissions(self):
        """Permissions dynamiques selon l'action."""
        if self.action == 'create':
            # Inscription ouverte
            return [AllowAny()]
        if self.action in ['update', 'partial_update', 'destroy']:
            # Modification : propriétaire ou admin
            return [IsAuthenticated()]
        return [IsAuthenticated()]

    @action(detail=False, methods=['get'])
    def me(self, request):
        """Retourne le profil de l'utilisateur connecté."""
        serializer = self.get_serializer(request.user)
        return Response(serializer.data)

    @action(detail=False, methods=['patch'])
    def update_profile(self, request):
        """Met à jour le profil de l'utilisateur connecté."""
        serializer = self.get_serializer(
            request.user,
            data=request.data,
            partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)


class CategoryViewSet(viewsets.ModelViewSet):
    """
    ViewSet pour la gestion des catégories.
    Seuls les admins peuvent créer/modifier/supprimer.
    """
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    lookup_field = 'slug'
    filter_backends = [filters.SearchFilter]
    search_fields = ['name', 'description']

    def get_permissions(self):
        """Lecture publique, écriture admin uniquement."""
        if self.action in ['list', 'retrieve']:
            return [AllowAny()]
        return [IsAdminUser()]


class ArticleViewSet(viewsets.ModelViewSet):
    """
    ViewSet pour la gestion des articles.

    Fonctionnalités :
    - Filtrage par catégorie, statut, auteur
    - Recherche textuelle
    - Tri par date, vues
    - Actions personnalisées (publication, archivage)
    """
    queryset = Article.objects.select_related('author', 'category')
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_class = ArticleFilter
    search_fields = ['title', 'content', 'excerpt']
    ordering_fields = ['created_at', 'published_at', 'views_count']
    ordering = ['-created_at']
    lookup_field = 'slug'

    def get_serializer_class(self):
        """Serializer léger pour les listes, complet pour le détail."""
        if self.action == 'list':
            return ArticleListSerializer
        return ArticleDetailSerializer

    def get_permissions(self):
        """Permissions selon l'action."""
        if self.action in ['list', 'retrieve']:
            return [AllowAny()]
        if self.action == 'create':
            return [IsAuthenticated()]
        # Modification/suppression : auteur ou admin
        return [IsAuthorOrReadOnly()]

    def get_queryset(self):
        """Filtre les articles selon l'utilisateur."""
        queryset = super().get_queryset()
        user = self.request.user

        # Utilisateurs non authentifiés : articles publiés uniquement
        if not user.is_authenticated:
            return queryset.filter(status='published')

        # Admins : tous les articles
        if user.is_staff:
            return queryset

        # Utilisateurs authentifiés : publiés + leurs propres articles
        from django.db.models import Q
        return queryset.filter(
            Q(status='published') | Q(author=user)
        )

    def retrieve(self, request, *args, **kwargs):
        """Incrémente le compteur de vues à chaque consultation."""
        instance = self.get_object()
        instance.views_count += 1
        instance.save(update_fields=['views_count'])
        serializer = self.get_serializer(instance)
        return Response(serializer.data)

    @action(detail=True, methods=['post'])
    def publish(self, request, slug=None):
        """Publie un article en brouillon."""
        article = self.get_object()

        if article.status == 'published':
            return Response(
                {'error': 'Article déjà publié.'},
                status=status.HTTP_400_BAD_REQUEST
            )

        article.status = 'published'
        article.published_at = timezone.now()
        article.save()

        serializer = self.get_serializer(article)
        return Response(serializer.data)

    @action(detail=True, methods=['post'])
    def archive(self, request, slug=None):
        """Archive un article publié."""
        article = self.get_object()
        article.status = 'archived'
        article.save()

        serializer = self.get_serializer(article)
        return Response(serializer.data)

Les actions personnalisées (@action) ajoutent des endpoints spécifiques comme /articles/{slug}/publish/ sans créer de nouvelles vues.

Permissions personnalisées

Les permissions contrôlent l'accès aux ressources. DRF permet de créer des permissions réutilisables pour des règles métier complexes.

python
# api/permissions.py
from rest_framework import permissions


class IsAuthorOrReadOnly(permissions.BasePermission):
    """
    Permission personnalisée :
    - Lecture : tout le monde
    - Écriture : auteur de l'objet ou admin uniquement
    """

    def has_object_permission(self, request, view, obj):
        # Les méthodes GET, HEAD, OPTIONS sont toujours autorisées
        if request.method in permissions.SAFE_METHODS:
            return True

        # Écriture autorisée uniquement pour l'auteur ou les admins
        return obj.author == request.user or request.user.is_staff


class IsOwnerOrAdmin(permissions.BasePermission):
    """
    Permission pour les ressources utilisateur :
    - L'utilisateur peut accéder à ses propres ressources
    - Les admins peuvent accéder à tout
    """

    def has_object_permission(self, request, view, obj):
        # Vérifie si l'objet est l'utilisateur lui-même
        if hasattr(obj, 'id') and obj.id == request.user.id:
            return True

        # Vérifie si l'objet appartient à l'utilisateur
        if hasattr(obj, 'user') and obj.user == request.user:
            return True

        # Les admins ont accès à tout
        return request.user.is_staff

Ces permissions s'appliquent au niveau de l'objet (has_object_permission) pour un contrôle fin sur chaque ressource.

Filtres personnalisés avec django-filter

Les filtres permettent aux clients de l'API de rechercher et filtrer les données selon différents critères.

python
# api/filters.py
import django_filters
from .models import Article


class ArticleFilter(django_filters.FilterSet):
    """
    Filtres personnalisés pour les articles.

    Exemples d'utilisation :
    - /articles/?category=tech
    - /articles/?status=published
    - /articles/?author=username
    - /articles/?created_after=2026-01-01
    - /articles/?min_views=100
    """
    # Filtre par slug de catégorie
    category = django_filters.CharFilter(
        field_name='category__slug',
        lookup_expr='exact'
    )

    # Filtre par username de l'auteur
    author = django_filters.CharFilter(
        field_name='author__username',
        lookup_expr='exact'
    )

    # Filtre par date de création (après)
    created_after = django_filters.DateFilter(
        field_name='created_at',
        lookup_expr='gte'
    )

    # Filtre par date de création (avant)
    created_before = django_filters.DateFilter(
        field_name='created_at',
        lookup_expr='lte'
    )

    # Filtre par nombre minimum de vues
    min_views = django_filters.NumberFilter(
        field_name='views_count',
        lookup_expr='gte'
    )

    # Filtre par titre (contient)
    title = django_filters.CharFilter(
        field_name='title',
        lookup_expr='icontains'
    )

    class Meta:
        model = Article
        fields = ['status', 'category', 'author']

Ces filtres génèrent automatiquement la documentation dans l'interface browsable de DRF.

Performance des filtres

Les filtres sur des champs textuels avec icontains peuvent être lents sur de grandes tables. Pour les recherches full-text, envisagez PostgreSQL avec SearchVector ou Elasticsearch.

Configuration des URLs et routeurs

Le routeur DRF génère automatiquement les URLs RESTful à partir des ViewSets enregistrés.

python
# api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)
from .views import UserViewSet, CategoryViewSet, ArticleViewSet

# Création du routeur avec génération automatique des URLs
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'categories', CategoryViewSet, basename='category')
router.register(r'articles', ArticleViewSet, basename='article')

urlpatterns = [
    # URLs générées par le routeur
    path('', include(router.urls)),

    # Endpoints d'authentification JWT
    path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('auth/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
]
python
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    # Toutes les URLs de l'API sous /api/
    path('api/', include('api.urls')),
]

Cette configuration expose les endpoints suivants :

  • POST /api/auth/token/ : obtention du token JWT
  • POST /api/auth/token/refresh/ : rafraîchissement du token
  • GET/POST /api/users/ : liste et création d'utilisateurs
  • GET/PUT/PATCH/DELETE /api/users/{id}/ : opérations sur un utilisateur
  • Et ainsi de suite pour les catégories et articles.

Configuration JWT avancée

L'authentification JWT nécessite une configuration adaptée à la sécurité et à l'expérience utilisateur.

python
# config/settings.py
from datetime import timedelta

SIMPLE_JWT = {
    # Durée de validité du token d'accès
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    # Durée de validité du token de rafraîchissement
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    # Rotation automatique des tokens de rafraîchissement
    'ROTATE_REFRESH_TOKENS': True,
    # Blacklist les anciens tokens après rotation
    'BLACKLIST_AFTER_ROTATION': True,
    # Algorithme de signature
    'ALGORITHM': 'HS256',
    # Clé de signature (utiliser une clé secrète en production)
    'SIGNING_KEY': SECRET_KEY,
    # Préfixe du header Authorization
    'AUTH_HEADER_TYPES': ('Bearer',),
    # Champs inclus dans le token
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
}

La rotation des tokens (ROTATE_REFRESH_TOKENS) renforce la sécurité en invalidant les anciens tokens après chaque rafraîchissement.

Tests automatisés de l'API

DRF fournit des outils de test intégrés pour valider le comportement de l'API.

python
# api/tests/test_articles.py
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from api.models import Article, Category

User = get_user_model()


class ArticleAPITestCase(TestCase):
    """Tests pour les endpoints articles."""

    def setUp(self):
        """Préparation des données de test."""
        self.client = APIClient()

        # Création d'un utilisateur de test
        self.user = User.objects.create_user(
            username='testuser',
            email='test@example.com',
            password='testpass123'
        )

        # Création d'une catégorie
        self.category = Category.objects.create(
            name='Tech',
            slug='tech',
            description='Articles technologiques'
        )

        # Création d'un article publié
        self.article = Article.objects.create(
            title='Test Article',
            slug='test-article',
            content='Contenu de test détaillé.',
            excerpt='Résumé court',
            author=self.user,
            category=self.category,
            status='published'
        )

    def test_list_articles_unauthenticated(self):
        """Les articles publiés sont accessibles sans authentification."""
        url = reverse('article-list')
        response = self.client.get(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

    def test_list_articles_filters_drafts(self):
        """Les brouillons ne sont pas visibles pour les utilisateurs non authentifiés."""
        # Création d'un brouillon
        Article.objects.create(
            title='Draft Article',
            slug='draft-article',
            content='Brouillon',
            author=self.user,
            status='draft'
        )

        url = reverse('article-list')
        response = self.client.get(url)

        # Seul l'article publié est visible
        self.assertEqual(len(response.data['results']), 1)

    def test_create_article_authenticated(self):
        """Un utilisateur authentifié peut créer un article."""
        self.client.force_authenticate(user=self.user)

        url = reverse('article-list')
        data = {
            'title': 'Nouvel Article',
            'slug': 'nouvel-article',
            'content': 'Contenu du nouvel article.',
            'category_id': self.category.id,
        }

        response = self.client.post(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Article.objects.count(), 2)
        # L'auteur est automatiquement assigné
        self.assertEqual(
            Article.objects.get(slug='nouvel-article').author,
            self.user
        )

    def test_create_article_unauthenticated(self):
        """Un utilisateur non authentifié ne peut pas créer d'article."""
        url = reverse('article-list')
        data = {
            'title': 'Article Non Autorisé',
            'slug': 'article-non-autorise',
            'content': 'Contenu',
        }

        response = self.client.post(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_update_own_article(self):
        """Un auteur peut modifier son propre article."""
        self.client.force_authenticate(user=self.user)

        url = reverse('article-detail', kwargs={'slug': self.article.slug})
        data = {'title': 'Titre Modifié'}

        response = self.client.patch(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.article.refresh_from_db()
        self.assertEqual(self.article.title, 'Titre Modifié')

    def test_update_other_user_article(self):
        """Un utilisateur ne peut pas modifier l'article d'un autre."""
        other_user = User.objects.create_user(
            username='other',
            email='other@example.com',
            password='otherpass123'
        )
        self.client.force_authenticate(user=other_user)

        url = reverse('article-detail', kwargs={'slug': self.article.slug})
        data = {'title': 'Titre Modifié'}

        response = self.client.patch(url, data, format='json')

        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_publish_action(self):
        """L'action publish change le statut de l'article."""
        draft = Article.objects.create(
            title='Brouillon',
            slug='brouillon',
            content='Contenu brouillon',
            author=self.user,
            status='draft'
        )
        self.client.force_authenticate(user=self.user)

        url = reverse('article-publish', kwargs={'slug': draft.slug})
        response = self.client.post(url)

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        draft.refresh_from_db()
        self.assertEqual(draft.status, 'published')
        self.assertIsNotNone(draft.published_at)

    def test_filter_by_category(self):
        """Le filtrage par catégorie fonctionne correctement."""
        url = reverse('article-list')
        response = self.client.get(url, {'category': 'tech'})

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

    def test_search_articles(self):
        """La recherche textuelle fonctionne."""
        url = reverse('article-list')
        response = self.client.get(url, {'search': 'Test'})

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data['results']), 1)

L'exécution des tests se fait via la commande python manage.py test api.tests.

Gestion des erreurs et réponses standardisées

Une gestion cohérente des erreurs améliore l'expérience développeur pour les consommateurs de l'API.

python
# api/exceptions.py
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status


def custom_exception_handler(exc, context):
    """
    Gestionnaire d'exceptions personnalisé pour standardiser les réponses d'erreur.

    Format de réponse :
    {
        "success": false,
        "error": {
            "code": "ERROR_CODE",
            "message": "Description de l'erreur",
            "details": {...}  # Optionnel
        }
    }
    """
    # Appelle le gestionnaire par défaut
    response = exception_handler(exc, context)

    if response is not None:
        # Standardise le format de réponse
        custom_response = {
            'success': False,
            'error': {
                'code': get_error_code(exc),
                'message': get_error_message(response.data),
                'details': response.data if isinstance(response.data, dict) else None
            }
        }
        response.data = custom_response

    return response


def get_error_code(exc):
    """Retourne un code d'erreur basé sur le type d'exception."""
    error_codes = {
        'ValidationError': 'VALIDATION_ERROR',
        'AuthenticationFailed': 'AUTHENTICATION_FAILED',
        'NotAuthenticated': 'NOT_AUTHENTICATED',
        'PermissionDenied': 'PERMISSION_DENIED',
        'NotFound': 'NOT_FOUND',
        'MethodNotAllowed': 'METHOD_NOT_ALLOWED',
        'Throttled': 'RATE_LIMIT_EXCEEDED',
    }
    return error_codes.get(exc.__class__.__name__, 'UNKNOWN_ERROR')


def get_error_message(data):
    """Extrait un message d'erreur lisible des données de réponse."""
    if isinstance(data, dict):
        if 'detail' in data:
            return str(data['detail'])
        # Collecte les messages de validation
        messages = []
        for field, errors in data.items():
            if isinstance(errors, list):
                messages.extend([f"{field}: {e}" for e in errors])
            else:
                messages.append(f"{field}: {errors}")
        return '; '.join(messages) if messages else 'Erreur de validation'
    return str(data)
python
# config/settings.py
REST_FRAMEWORK = {
    # ... autres configurations
    'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
}

Conclusion

Django REST Framework combiné à Django 5 offre un écosystème complet pour créer des APIs REST professionnelles. La puissance des serializers, la flexibilité des ViewSets et l'intégration native de l'authentification JWT permettent de construire rapidement des APIs robustes et sécurisées.

Checklist pour une API Django de qualité

  • ✅ Utiliser des serializers séparés pour lecture et écriture
  • ✅ Implémenter des permissions personnalisées pour le contrôle d'accès
  • ✅ Configurer des filtres pour la recherche et le tri
  • ✅ Ajouter l'authentification JWT avec rotation des tokens
  • ✅ Écrire des tests unitaires pour chaque endpoint
  • ✅ Standardiser le format des réponses d'erreur
  • ✅ Documenter l'API via DRF Spectacular ou drf-yasg

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

L'approche de DRF encourage la réutilisation et la composition : les serializers, permissions et filtres s'assemblent pour créer des APIs maintenables à long terme. La documentation automatique et l'interface browsable accélèrent le développement et facilitent l'intégration pour les équipes frontend.

Tags

#django
#django rest framework
#python
#rest api
#api development

Partager

Articles similaires