loading...
Cover image for Django & DRF 101, partie 2

Django & DRF 101, partie 2

zorky profile image DUVAL Olivier Updated on ・27 min read

Dans la 1ère partie, nous avons vu une introduction à l'ORM de Django avec ses capacités.

Cette 2ème partie sera consacrée à une introduction à DRF qui nous permettra de développer une API, API qui pourra être exploitée dans une application de type Angular (ou toute autre SPA).

Les sources se trouvent sur le repository https://github.com/zorky/library : l'application et le serveur d'applications containérisé (Docker).

Sommaire

Note Bene

Dans la suite de l'article, le serveur d'application Web django pourra fonctionner selon le type d'environnement :

Utiliser la bonne url selon votre environnement. Dans la suite de l'article si j'utilise http://plateform/, à remplacer par http://localhost:8000 si vous êtes en environnement virtualenv

Préparation de l'environnement

Voir l'article en question

On se basera sur la version 3.0.4 de django et la 3.11.x de DRF et quelques modules utiles.

Django et applications

Initialisation

Django peut fonctionner en mode "plateforme" c'est à dire que vous créez un projet dans lequel vous allez créer des applications, toutes partageront la même base de données (une convention est prise alors pour le nommage des tables de la forme application_table), les configurations, "utilisateur" (au sens user de django) sont aussi partagés, ça a le mérite de mutualiser l'ensemble : pour le développement, les déploiements et l'usage d'éléments communs (serveur d'applications, authentification, "utilisateur" django, configs, etc) sans reproduire l'environnement pour chaque application.

La commande manage.py gère ce type de création.

Création du projet commun :

$ django-admin startproject backend
$ cd backend

on aura l’arborescence et les fichiers suivants de créer dans backend

manage.py
backend/
    __init__.py
    settings.py
    urls.py
    wsgi.py

Créer notre api que l'on nommera api_library :

$ django-admin startapp api_library

on aura ces fichiers de créer dans api_library

migrations/
    __init__.py
__init__.py
admin.py
apps.py
models.py
tests.py
views.py

A la fin des manipulations, on a alors l'arborescence suivante, backend qui est le coeur de django et api_library :

./api_library
./api_library/admin.py
./api_library/apps.py
./api_library/migrations
./api_library/migrations/0001_initial.py
./api_library/models.py
./api_library/serializers.py
./api_library/tests.py
./api_library/urls.py
./api_library/views.py
./api_library/__init__.py
./backend
./backend/settings.py
./backend/urls.py
./backend/webapi.py
./backend/wsgi.py
./backend/__init__.py
./db.sqlite3
./manage.py
./__init__.py

On initialise notre plateforme :

  • les migrations django (par défaut, django utilise sqlite, cela sera très bien en local), cela va créer toutes les tables dont Django a besoin
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying sessions.0001_initial... OK
  • on crée un utilisateur pour l'admin :
$ python manage.py createsuperuser 
Username (leave blank to use 'root'): admin
Email address: admin@gmail.com
Password:
Password (again):
The password is too similar to the username.
This password is too short. It must contain at least 8 characters.
This password is too common.
Bypass password validation and create user anyway? [y/N]: y
Superuser created successfully.

Lançons le serveur

$ python manage.py runserver

par défaut il est accessible sur http://127.0.0.1:8000

Alt Text

Arrêtons le et créons au moins un utilisateur pour accéder à l'admin Django : l'admin de django permet d'accéder à la gestion de vos données de façon simple, je l'utilise souvent pour, en développement, saisir quelques données avant d'avoir à développer les interfaces côté frontend (en Angular ou autre), pratique

Accédons à l'URL http://127.0.0.1:8000/admin en se connectant avec le compte précédemment créé, une interface apparaît pour une gestion de groupes ou d'utilisateurs, cette interface permet aussi facilement d'utiliser l'admin pour une application pour avoir des formulaires en ligne de nos entités et les garnir sans passer par la ligne de commande comme vue dans la partie 1 sur l'ORM avec shell_plus.

Amusons-nous avec quelques modèles

L'objectif est de schématiser une librairie : livres et leur auteur, ce qui donne : Book * --- 1 Author

from django.db import models

class TimeStampedModel(models.Model):
    """
    Classe utilitaire, champs dt_created / dt_updated
    """
    dt_created = models.DateTimeField(auto_now_add=True)
    dt_updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

class Author(TimeStampedModel):
    first_name = models.CharField(max_length=50, null=False, blank=False)
    last_name = models.CharField(max_length=50, null=False, blank=False)

    def __str__(self):
        return '{} {}'.format(self.first_name, self.last_name)

class Book(TimeStampedModel):
    name = models.CharField(max_length=100, null=False, blank=False, db_index=True)
    nb_pages = models.IntegerField()

    author = models.ForeignKey(Author, related_name='books', null=False, on_delete=models.CASCADE)

    enabled = models.BooleanField(default=True, help_text='disponible ou non')

    def __str__(self):
        return '{} : {}'.format(self.name, self.author)

La propriété blank sert pour les formulaires Django (dans l'admin que nous voyons plus bas par exemple), le champ est donc optionnel.
str permet de surcharger ce que l'on souhaite afficher lorsque l'on fait un

print(instance_author)

il affichera alors la concaténation de first_name et last_name sinon par défaut, django affiche la clé id

Créons les migrations et les tables

$ python manage.py makemigrations api_library
Migrations for 'api_library':
  api_library\migrations\0001_initial.py
    - Create model Author
    - Create model Book
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, api_library, auth, contenttypes, sessions
Running migrations:
  Applying api_library.0001_initial... OK

Admin

Ajoutons la possibilité d'ajouter des auteurs et livres grâce à des formulaires en utilisant l'admin django.

Il suffit, dans le fichier api_library/admin.py de déclarer les modèles de la sorte

from django.contrib import admin

from .models import Author, Book

admin.site.register(Author)
admin.site.register(Book)

En se connectant sur http://127.0.0.1:8000/admin apparaît alors une interface pour gérer des auteurs et livres, le CRUD (ajout, modification, suppression), c'est très pratique au démarrage d'un projet ou de nouveaux modèles pour avoir de la donnée.

Alt Text

DRF & API

Django REST Framework est un module compatible avec Django qui permet de développer des API REST assez rapidement.

REST

Architecture REST

REST est un paradigme / une architecture d'échange (service Web) entre un client (navigateur dans la plupart des cas) et un serveur, en se basant sur HTTP, décrit par Roy Fielding dans sa thèse (chapitre 5).

REST se base sur l'antique protocole HTTP :

  • verbes HTTP pour la communication client vers le serveur : GET (obtenir un ou des éléments), POST (créer un élément), PUT (modifier un élément), DELETE (supprimer un élément), PATCH (modifier une partie d'un élément) (et OPTIONS que nous verrons plus tard), ces verbes HTTP sont utilisés conjointement à des URLs côté client (par exemple : GET https://domain.ntld/api/todolist)
  • codes HTTP pour les acquittements (ACK) entre le serveur et le client utilisateur : 2xx, 4xx, 5xx, ...

REST est sans état (ou stateless) : pour résumer, tout est porté par l'URL de l'API (demande de la ressource, pagination, identité du demandeur, etc), autrement dit, la notion de session n'existe pas, pas de stockage côté serveur (a contrario des modèles clients / serveurs habituels de type MVC ou autre), si une information est gardée, elle le sera côté client

REST a été créé pour palier à certains défauts des techniques antérieures utilisées et faciliter l'échange de données dans un monde où l'échange et l'interopérabilité entre systèmes (entre un client et un serveur) sont devenus proéminents.

Ces échanges "REST" s'effectuent la plupart du temps au format JSON qui est une notation Javascript pour décrire des objets, objets qui seront utilisés pour l'échange de données en entrée ou en sortie entre 2 parties : le client et le serveur, le lien entre ces parties étant les APIs. Comme nous le verrons ci-après, l'avantage de JSON vs. XML est que c'est un format natif Javascript qui pourra être interprété directement par le moteur JS du navigateur sans avoir besoin de librairie tierce pour le faire.

API : à quoi sert une API et quelques rappels historiques

Une architecture APIs est une architecture "backoffice", c'est à dire, toute la mécanique / logique est portée par les APIs, reste à développer des frontaux devant pour adresser ces APIs : applications Web, une application Mobile, une SPA, ou des scripts / routines, etc, une API ne s'occupe pas de la manière dont sera présentée la donnée (la vue), charge aux frontaux de l'implémenter.

Un schéma qui résume très bien (pris sur https://data-flair.training/blogs/django-rest-framework/)

Alt Text

API publiques

La majeure partie des plateformes (Dropbox, Amazon, Google, etc) proposent dorénavant des APIs qui permettent d'interagir avec ces dernières et l'interopérabilité entre plateformes (notamment de l'échange de données), ce qui facilite l'intégration de leurs services dans d'autres applications Web, pour y apporter plus de services à l'utilisateur.

Avant cela, afin d'intégrer de la donnée externe à une application, l'un des moyen de faire était soit d'interroger directement la base de données, ce qui est bien évidemment impossible avec Amazon par exemple et cela pose également des soucis "d'abstraction" de la donnée (ie : connaître le schéma de la base et de ses tables, quid de l'évolution de ce schéma, etc), soit d'utiliser SOAP pour échanger à une époque pas si lointaine, SOAP étant fait à la base surtout pour de l'échange entre serveurs (un serveur d'application JEE ou .NET ou ...) et reste un format inapproprié pour l'échange entre client (entendre navigateur) et serveur : par défaut le format d'échange de SOAP renvoie du XML, format non natif Javascript, contrairement à JSON, ce qui oblige l'usage de librairie pour transformer la donnée, et une "complexité" (à cause du WSDL) de SOAP pour les plateformes n'étant pas natives "SOAP / XML" (comme .NET ou J2EE) : enveloppe / entête nécessaires, souci de cache, WSDL "complexe" pour décrire les entrées / sorties du service.

API privées

Avec l’avènement de l'utilisation d'Ajax à partir des années 2000 et de cette volonté d'améliorer l'expérience utilisateur sur les interfaces Web (UX), cette UX étant portée principalement par Javascript / Ajax : toute la page n'est pas rechargée, seules des parties de la page sont mises à jour, souvent avec des effets (spinner d'attente, accordéon, autocompletion, etc), ce qui donne une impression d'être sur une application bien plus réactive.

Pour cela, on utilisait ou on utilise bien souvent la libraire jQuery qui s'est imposée comme librairie pour les appels "Ajax" (à l'époque, l'objet utilisé pour faire des appels Ajax n'était pas standardisé sur les navigateurs, IE, Chrome, Firefox, cela permettait d'avoir une couche d'abstraction pour le développeur sans se soucier du navigateur) puis pour la manipulation du DOM grâce aux sélecteurs (pouvoir rechercher un élément "HTML" dans la page pour l'afficher, le cacher, lui ajouter d'autres éléments, etc) : on développait des SPA sans vraiment en faire : les pages se rechargeaient malgré tout avec une certaine latence (car utilisation d'un serveur d'application bien souvent MVC), le code devenait très vite du code spaghetti à cause bien souvent d'une mauvaise utilisation de Javascript (via des fonctions et non de la POO) et de l'excès de jQuery : des sélecteurs partout, des fonctions javascript éparpillées dans de multiples fichiers JS inclus ou non dans les pages, ou côté MVC, avec des vues partielles et des modèles injectés comme on pouvait. L'entropie de ce type d'application était forte : maintenabilité et évolutivité réduites qui peuvent devenir complexes, sans compter les régressions et du côté utilisateur, un ressenti et une UX perfectible.

De là, sont nés des frameworks / librairies de type "SPA" pour améliorer les critiques sur les applications "anciennes générations" à base de jQuery / MVC (appelés aussi MPA : Multi Pages Applications) : Ember, KnockoutJS, VueJS ou ReactJS et bien sûr Angular.

Le principe d'une SPA est de se baser sur des services REST ou APIs pour obtenir, et/ou mettre à jour la donnée, sous forme de multiples appels "Ajax" et de l'usage du binding (attachement) automatique entre ces données et la vue pour les afficher. Les derniers frameworks ont également une approche components / composants, qui introduit un des principes de SOLID en POO : la responsabilité unique ou souhaitée : un composant répond à un besoin et un seul (par exemple, un composant d'entêtes, d'autocompletion, de table pour afficher des éléments, etc) et souvent, réutilisables.

Schéma (tiré de cet article) qui explique la différence entre les MPAs et le SPAs

Alt MPA vs SPA

Le propre d'une SPA est de charger l'application en une seule fois ou en différé et que cela soit transparent pour l'utilisateur de l'application : cela amène une fluidité de navigation et une UX bien supérieure à des applications "classiques".

Pour ces APIs, dans l'écosystème Django, il existe un module qui va nous permettre de développer ces dites APIs : Django rest framework et c'est ce nous allons voir dans les prochaines parties.

DRF initialisation

Pour l'utilisation de DRF, nous aurons besoin d'un ensemble de modules à installer et à utiliser (voir le requirements.txt de l'initialisation)

Dans backend/settings.py, la partie INSTALLED_APPS contient les modules qui seront chargés : rest_framework mais aussi rest_framework_swagger ou drf-yasg qui nous permettra de documenter nos APIs, django_filters est un module tiers très puissant qui nous servira à faire des filtres de recherche par la suite et bien entendu notre module api_library :

INSTALLED_APPS = [
    'rest_framework',
    'rest_framework_swagger',

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django_extensions',

    'django_filters',

    'api_library',
]

APIs

Avec DRF, un triptyque permet simplement de créer une API avec : le modèle (de Django), le Serializer, la Viewset, on se basera sur le plus complet le ModelViewSet, l'URL et optionnellement le FilterSet.

Serializers et viewsets

Serializers

Un serializer servira à sérialiser ou désérialiser la donnée en JSON (entrées / sorties d'une API) : l'application envoie du JSON et réceptionne du JSON.

Une viewset servira pour traiter les requêtes HTTP pour les APIs : un viewset qui répondra à 80 % de nos besoins est le ModelViewSet, il traitera le GET, POST, PUT, DELETE "automatiquement" (le CRUD de nos modèles), nous verrons qu'il existe aussi d'autres classes plus restrictives (pour uniquement du GET ou du POST, etc).

Dans api_library, créer un fichier serializers.py pour définir nos serializers sur nos modèles Author et Book :

# api_library/serializers.py

from rest_framework import serializers

from .models import Author, Book

class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = '__all__'

class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ['id', 'label']

la class Meta permet de définir le serializer (les méta-données), parmi les plus communs :

  • model : le modèle utilisé par DRF pour sérializer / désérializer
  • fields : les champs qui seront inclus dans le JSON, le mot clé 'all' permet d'inclure tous les champs du modèle automatiquement
  • exclude : les champs à exclure - attention, ne doit pas être utilisé en même temps que fields, il prendra donc tous les champs moins les exclude
  • read_only_fields : les champs qui ne sont pas à "écrire" lors de l'envoi / de la réception du JSON

Lorsque l'on interrogera les Books (via http://plateform/library/books/ ou http://plateform/library/books/1), avec la pagination on aura comme résultat du serializer BookSerializer, un Book en JSON aura la forme suivante

{
   "id": 1,
   "dt_created": "2020-02-22T15:57:26.934127",
   "dt_updated": "2020-02-22T15:57:26.934149",
   "name": "Django primer",
   "nb_pages": 150,
   "enabled": true,
   "author": 1
}
Nested Serializers

Comme nous pouvons le voir sur la partie précédente et selon le schéma

Book * ----- 1 Author

le sérializer de Book, renverra le lien Author avec sa clé : "author": 1, ou "1" est l'id d'author qui est lié à Book (un livre a un auteur).

Egalement, lorsque l'on demande un "Author", on pourrait avoir besoin de sa liste de livres directement sous forme d'objects.

Il peut être intéressant de renvoyer tout l'objet Author, pour afficher directement sur le livre toute l'information de son auteur (un nester serializer ou serializer embarqué)

On modifie le serializer BookSerializer pour ajouter l'objet Author :

class BookSerializer(serializers.ModelSerializer):
    author_obj = AuthorSerializer(source='author', read_only=True)

    class Meta:
        model = Book
        fields = '__all__'
  • source est à la propriété de jointure utilisé dans le modèle Book (author = models.ForeignKey(Author, related_name='books', null=False, on_delete=models.CASCADE)), elle est obligatoire dans notre cas car la propriété renvoyée dans le serializer se nomme author_obj sinon elle devient optionnelle (si on avait écrit par exemple author = AuthorSerializer(read_only=True))
  • read_only car cela ne servira qu'à la consultation, DRF ne peut écrire directement des nested serializer en base, une autre technique sera alors utilisée

Lors du requêtage (http://plateform/library/books/ ou http://plateform/library/books/1), on aura alors le résultat suivant, avec intégrer l'auteur (propriété author_obj)

{
    "id": 1,
    "author_obj": {
        "id": 1,
        "dt_created": "2020-02-22T15:56:55.547391",
        "dt_updated": "2020-02-22T15:56:55.547413",
        "first_name": "Olivier",
        "last_name": "DUVAL"
    },
    "dt_created": "2020-02-22T15:57:26.934127",
    "dt_updated": "2020-02-22T15:57:26.934149",
    "name": "Django primer",
    "nb_pages": 150,
    "enabled": true,
    "author": 1
}

A l'inverse, on pourrait avoir la liste des livres d'un auteur directement dans le serializer AuthorSerializer, on aura alors le code suivant :

class BookSimpleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = '__all__'

class AuthorSerializer(serializers.ModelSerializer):
    books = BookSimpleSerializer(many=True, read_only=True)

    class Meta:
        model = Author
        fields = '__all__'

class BookSerializer(serializers.ModelSerializer):
    author_obj = AuthorSerializer(source='author', read_only=True)

    class Meta:
        model = Book
        fields = '__all__'

Dans ce cas, la création d'un autre serializer (BookSimpleSerializer) est obligatoire pour pouvoir l'utiliser dans AuthorSerializer car BookSerializer est en-dessous de AuthorSerializer, ceci pour éviter de la récursion à l'infinie (author -> books -> author -> books -> etc).

  • many=True permet de répondre au rendu de plusieurs livres pour un auteur (pour rappel, la déclaration dans Book est author = models.ForeignKey(Author, related_name='books', null=False, on_delete=models.CASCADE)), books de books = BookSimpleSerializer(many=True, read_only=True) représente la relation inverse déclarée dans related_name.

Lorsque l'on interroge http://plateform/library/authors/1/ , on aura le JSON suivant, l'auteur avec la liste de ses livres :

{
    "id": 1,
    "books": [
        {
            "id": 1,
            "dt_created": "2020-02-22T15:57:26.934127",
            "dt_updated": "2020-02-22T15:57:26.934149",
            "name": "Django primer",
            "nb_pages": 150,
            "enabled": true,
            "author": 1
        },
        {
            "id": 3,
            "dt_created": "2020-02-22T15:57:51.851990",
            "dt_updated": "2020-02-22T15:57:51.852012",
            "name": "Angular primer",
            "nb_pages": 325,
            "enabled": true,
            "author": 1
        }
    ],
    "dt_created": "2020-02-22T15:56:55.547391",
    "dt_updated": "2020-02-22T15:56:55.547413",
    "first_name": "Olivier",
    "last_name": "DUVAL"
}

ATTENTION : les "nested serializers" feront un appel / des appels vers l' / les entité(s) pour obtenir les données, on pensera donc à utiliser le select_related (pour les cardinalités 1) ou le prefetch_related (pour les many) dans la queryset de la viewset que nous voyons ci-après. Les "nested" engendrent aussi plus de données qui transitent via l'API.

Viewsets

Un viewset est une vue qui va servir pour l'API, le plus simple étant le ModelViewSet qui fera le CRUD : GET, POST, PUT, DELETE, en se servant d'un serializer (pour la transformation JSON des entrées et des sorties), d'une queryset (la requête par défaut),. Dans dans api_library, le fichier views.py contiendra :

# api_library/views.py

from rest_framework import viewsets
from rest_framework.permissions import AllowAny

from .models import Author, Book
from .serializers import AuthorSerializer, BookSerializer

class AuthorViewSet(viewsets.ModelViewSet):
    queryset = Author.objects.prefetch_related('books').all()
    serializer_class = AuthorSerializer
    permission_classes = [AllowAny]


class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.select_related('author').all()
    serializer_class = BookSerializer
    permission_classes = [AllowAny]
  • queryset : est la queryset par défaut utilisée pour retrouver les enregistrements, remarquez qu'on utilise les select_related ou prefetch_related pour optimiser le requêtage comme vu sur https://dev.to/zorky/django-drf-101-partie-1-2p2#optimisations
  • serializer_class : le serializer par défaut à utiliser pour la sérialisation / dé-sérialisation des données, on peut également en préciser dynamiquement, patience, nous verrons cela dans un futur article
  • permission_classes : les droits d'accès à l'API, ici AllowAny pour dire qu'elle est publique, on pourrait également configurer les permissions par défaut de toutes les viewset via le backend/settings, section REST_FRAMEWORK, cela s'appliquera à toutes les viewsets sans avoir besoin de permission_classes à l'intérieur. Si vous souhaitez qu'au moins les APIs aient besoin d'une authentification, il suffira d'utiliser IsAuthenticated à la place. Les permissions sont également personnalisables, c'est à dire que l'on peut imaginer des rôles applicatifs, rôles qui seront ensuite utilisés pour accéder à telle ou telle API (APIs de backoffice, de frontoffice, etc)
REST_FRAMEWORK = {
 'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny',
    ]
...
}

Lorsqu'on regarde les sources de ModelViewSet, la class se compose en une multitude de "mixin" qui représente le CRUD et une pour la tuyauterie. On peut utiliser d'autre viewset génériques spécialisés autre que le ModelViewSet pour répondre à des besoins spécifiques d'API : CreateAPIView (POST uniquement), UpdateAPIView (PUT uniquement), ListAPIView (GET liste), RetrieveAPIView (GET d'un objet), DestroyAPIView (DELETE uniquement), ListCreateAPIView, RetrieveUpdateAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, voir l'ensemble des views disponibles sur le site de DRF.

class ModelViewSet(mixins.CreateModelMixin,
                   mixins.RetrieveModelMixin,
                   mixins.UpdateModelMixin,
                   mixins.DestroyModelMixin,
                   mixins.ListModelMixin,
                   GenericViewSet):
    """
    A viewset that provides default `create()`, `retrieve()`, `update()`,
    `partial_update()`, `destroy()` and `list()` actions.
    """
    pass

Dans api_library, créer un fichier urls.py, Les viewset sont attachées à une URL, précisées dans urls.py qui contiendra le code suivant

# coding=utf-8
# api_library/urls.py

from django.conf.urls import url, include
from rest_framework import routers
from . import views

router = routers.DefaultRouter()
router.register(r'authors', views.AuthorViewSet, 'authors')
router.register(r'book', views.BookViewSet, 'books')

urlpatterns = [
    url(r'', include(router.urls)),    
]

Dans backend, le fichier urls.py utilisera les urls d'api_library précédemment ajoutées, et nous allons activer Swagger pour accéder et requêter notre API directement. Egalement, en mode DEBUG (en dév), on active la debug toolbar (debug_toolbar) pour obtenir de l'information sur les API (notamment le SQL), l'api de notre librairie aura la forme : http://domain.ntld/library/ :

# backend/urls.py
from django.contrib import admin
from django.conf import settings
from django.conf.urls import url, include
from django.conf.urls.static import static
from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token, verify_jwt_token
from rest_framework_swagger.views import get_swagger_view

schema_view = get_swagger_view(title='Api plateforme')

urlpatterns = [
    url(r'^$', schema_view),
    url(r'library/', include('api_library.urls')),
    url(r'^admin/', admin.site.urls)
]

if settings.DEBUG:
    import debug_toolbar

    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
    urlpatterns = [
        url(r'^__debug__/', include(debug_toolbar.urls))
    ] + urlpatterns

Pour résumer :

  • des serializers dans api_library/serializers.py
  • des views dans api_library/views.py
  • des urls avec les viewsets dans api_library/urls.py
  • déclaration de swagger, de la debug toolbar et inclusion des urls api_library dans backend/urls.py

Aller sur http://localhost:8000 ou http://plateform , Swagger expose les APIs authors et books que l'on peut manipuler directement, basées sur les verbes HTTP d'une architecture REST

Alt Text

Utilisation de /library/authors/ en GET, avec le résultat JSON

Alt Text

ou de /library/authors/1/ (donne moi l'auteur d'identifiant 1)

Alt Text

La partie Request URL (par ex. http://plateform/library/authors/) peut être ouverte dans un onglet à part du navigateur, on a accès alors à la debug toolbar et notamment la partie SQL pour voir les requêtes qui sont exécutées ou d'autres informations utiles

Alt Text

Alt Text

Si l'on veut ouvrir la request URL avec un résultat uniquement en JSON (sans Swagger), il suffit de postfixer l'url avec ?format=json (http://plateform/library/authors/?format=json)

Alt Text

Actions

Les viewsets ModelViewSet permettent le CRUD sur un modèle, on peut les spécialiser pour avoir des "fonctions" au sein du ModelViewSet, par exemple une url de type http://plateform/library/books/disponibles/ , /disponibles/ renverra les livres uniquement disponibles, les actions permettent de répondre spécifiquement à du fonctionnel dans le détail.

Le paramètre method permet de choisir le verbe HTTP sur lequel l'action répondra (get, post, put, delete), optionnellement, on peut également utiliser le permission_classes avec une liste de permissions, cela surchargera les permissions de la viewset.

Les actions DRF sont créés grâce au décorateur @action, il en existe 2 types

  • une qui va chercher l'objet d'id N, l'attribut detail est à True. Action qui nécessite de passer l'id de l'objet sur lequel on va créer l'action Cela permet d'utiliser la fonction utilitaire get_object qui se chargera de nous trouver l'objet à partir du paramètre pk qui représente l'id envoyé. L'action doit renvoyer un Response, dans l'exemple si après : trouve l'objet Entite d'id pk et le renvoie
    @action(detail=True, methods=['get'])
    def ma_fonction_specialisee(self, request, pk=None):
          mon_entite: Entite = self.get_object()
          serializer = EntiteSerializer(mon_entite)
          return Response(data={serializer.data}, status=status.HTTP_200_OK)
  • une "générique" où le detail est à False
    @action(detail=False)
    def ma_fonction_specialisee(self, request):
        # traitement pour renvoyer de la donnée
        return Response(data={}, status=status.HTTP_200_OK)

Dans les 2, on pourra utiliser request pour retrouver des paramètres passés à la queystring (&param1=val1&param2=val2&...&parmN=valN), de la façon suivante, exemple pour retrouver param1, s'il n'existe pas, sa valeur est fixée à None

request.query_params.get('param1', None)

On pourra également tester la méthode utilisée via request.method, à savoir si c'est 'GET', 'POST', 'PUT', 'DELETE'

Pagination, filtres et recherche

Lorsque l'on développe des API ou plus généralement on interroge des listes d'objets, il est important de penser à la pagination (le fait de ne ramener qu'un ensemble de lignes (10 par exemple) d'un ensemble global de données), et aux filtres de recherche qu'on peut donner à l'utilisateur d'une application, cela contribue à l'UX.

Pagination

La pagination doit être réalisée côté base de données, plus performante bien évidemment qu'une pagination effectuée sur un ensemble d'objets en "mémoire", voir plus loin pour les requêtes.

Pour ça, DRF propose 2 options :

  • soit indiquer la pagination en settings, qui sera commune à toutes les APIs,
  • soit dans le viewset, au cas par cas

DRF propose plusieurs type de "fournisseurs" : PageNumberPagination (l'url sera de la forme &page=N&page_size=LIMIT), LimitOffsetPagination (l'url sera de la forme &limit=10&offset=5) et CursorPagination, je préfère le LimitOffsetPagination car dans le retour JSON, nous avons toutes les indications liées à la pagination, on se déplace dans les "pages" grâce au limit et optionnellement à l'offset (par défaut 0, indique le démarrage à l'enregistrement N° offset)

Par exemple, la requête http://plateform/library/authors/?limit=1&format=json retournera : on a le nombre totale en base (count: 3), la prochaine "page" avec l'offset (http://plateform/library/authors/?format=json&limit=1&offset=1) et enfin les données dans results

{
  count: 3,
  next: "http://plateform/library/authors/?format=json&limit=1&offset=1",
  previous: null,
  results: [
  {
    id: 1,
    dt_created: "2020-02-18T11:57:51.608823",
    dt_updated: "2020-02-18T11:57:51.608823",
    first_name: "Olivier",
    last_name: "DUVAL"
  }
]
}

Dans le backend/settings.py, sur la partie REST_FRAMEWORK, ajouter :

REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 10,
...
}

ou dans le viewset désiré :

from rest_framework import viewsets
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import AllowAny

from .models import Author, Book
from .serializers import AuthorSerializer, BookSerializer

class AuthorViewSet(viewsets.ModelViewSet):
    queryset = Author.objects.prefetch_related('books').all()
    serializer_class = AuthorSerializer
    permission_classes = [AllowAny]

    pagination_class = LimitOffsetPagination
    page_size = 10

Dès lors, on peut utiliser la pagination en ajoutant les paramètres limit (le nombre de lignes ramené) et offset (à partir de quel ligne) à l'url : http://plateform/library/authors/?limit=5&offset=5 : donne moi la liste des auteurs à partir du 5ème et les 5 premiers de la liste, simple et efficace.

Pour les requêtes SQL générées, ouvrons sur http://plateform/library/authors/?limit=2&offset=1 la partie SQL de la debug toolbar, 2 requêtes pour les auteurs sont générées : 1 pour le comptage en base (count) et une autre pour la liste (selon le SGBD les instructions peuvent varier, ici, avec SQLite, nous avons un OFFSET et un LIMIT)

Alt Text

Alt Text

Filtres

Recherche

Lorsque l'on a des listes, on a souvent besoin de rechercher des éléments. DRF a prévu le cas grâce au SearchFilter, ajoutons filter_backends = (SearchFilter,) au viewset et les champs de recherche souhaités (ici "first_name" OU "last_name")

# api_library/views.py

import django_filters

from rest_framework import viewsets
from rest_framework.filters import SearchFilter
from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination
from rest_framework.permissions import AllowAny, IsAuthenticated

from .models import Author, Book
from .serializers import AuthorSerializer, BookSerializer

class AuthorViewSet(viewsets.ModelViewSet):
    queryset = Author.objects.prefetch_related('books').all()
    serializer_class = AuthorSerializer
    permission_classes = [AllowAny]
    filter_backends =  (SearchFilter,)

    search_fields = ['first_name', 'last_name']

La requête http://plateform/library/authors/?search=aur%C3%A9%20duv cherchera sur les 2 champs précisés dans search_fields en effectuant un LIKE et un OR en permutant toutes les possibilités :

SELECT "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_author"
 WHERE (("api_library_author"."first_name" LIKE '''%auré%''' ESCAPE '\' OR "api_library_author"."last_name" LIKE '''%auré%''' ESCAPE '\') AND ("api_library_author"."first_name" LIKE '''%duv%''' ESCAPE '\' OR "api_library_author"."last_name" LIKE '''%duv%''' ESCAPE '\'))
 LIMIT 10

Attention, c'est accent sensitive mais case insensitive (duv ou DUV renverront le même résultat).

Filtres de recherche sur des champs

Autre facilité de DRF, la possibilité d'utiliser des filtres sur des champs, par exemple, pouvoir rechercher les livres ayant 150 pages, ou le nom d'un auteur égale à "DUVAL", les filtres sont cumulables, et bien entendu, si la pagination est utilisée, elle s'intégrera à la requête.

Ajoutons à filter_backends la classe DjangoFilterBackend qui active l'usage des filtres, associé à filterset_fields qui précise les champs que l'on peut filter. Les filtres de base sont stricts, un = sera utilisé.

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.select_related('author').all()
    serializer_class = BookSerializer
    permission_classes = [AllowAny]
    filter_backends = (DjangoFilterBackend, SearchFilter,)
    filterset_fields = ('name', 'nb_pages', 'author', 'author__last_name', 'author__first_name',)

Dès lors que les filtres sont mis, on peut les voir sur swagger pour les utiliser

Alt Text

Dès lors on peut filtrer exactement sur les champs souhaités (précisés avec filterset_fields), lors de l'utilisation de ces champs dans l'url, un ET est appliqué entre les conditions.

Par exemple, les livres de 150 pages pour l'auteur d'identifiant 1 : http://plateform/library/books/?nb_pages=150&author=1 ou les livres de 150 pages de l'auteur de nom "DUVAL" : http://plateform/library/books/?author__last_name=DUVAL&nb_pages=150

La dernière requête, en utilisant la debug toolbar donnera

SELECT "api_library_book"."id",
       "api_library_book"."dt_created",
       "api_library_book"."dt_updated",
       "api_library_book"."name",
       "api_library_book"."nb_pages",
       "api_library_book"."author_id",
       "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_book"
 INNER JOIN "api_library_author"
    ON ("api_library_book"."author_id" = "api_library_author"."id")
 WHERE ("api_library_book"."nb_pages" = '150' AND "api_library_author"."last_name" = '''DUVAL''')

Comme on peut le remarquer, dans notre exemple, les filtres s'effectuent à partir du modèle Book, on peut alors remonter vers auteur en indiquant les champs de ce modèle grâce à __ (grâce à la clé étrangère author définie dans le modèle par author = models.ForeignKey(Author, related_name='books', null=False, on_delete=models.CASCADE)) pour filtrer sur le nom ou prénom : author__last_name ou author__first_name. Attention, cela génère une requête pour aller chercher l'entité author pour ses attributs last_name / first_name, un select_related('author') est conseillé sur la queryset de la viewset.

Filtres personnalisés

Les filtres proposés permettent déjà pas mal de recherche et filtrage. DRF et django-filters permettent aussi d'en créer, la classe FilterSet permet cela en conjonction avec filterset_class que nous allons utiliser dans les viewsets.

Pour Book, on pourrait rechercher les livres entre 2 nombre de pages ou sur une liste d'auteurs (identifiants).

Création d'un filtre personnalisé pour Book et Author, dans un nouveau fichier api_libray/filters.py

from django_filters import FilterSet, filters, BaseInFilter, NumberFilter

from .models import Book, Author

class IntegerInFilter(BaseInFilter, NumberFilter):
    pass

class AuthorFilter(FilterSet):
    last_name = filters.CharFilter(lookup_expr='iexact')
    first_name = filters.CharFilter(lookup_expr='iexact')

    class Meta:
        model = Author
        fields = ['last_name', 'first_name', ]

class BookFilter(FilterSet):
    pages =  filters.RangeFilter(field_name='nb_pages')
    author_in = IntegerInFilter(field_name='author', lookup_expr='in')
    author_isnull = django_filters.BooleanFilter(field_name='author', lookup_expr='isnull')

    class Meta:
        model = Book
        fields = ['name', 'nb_pages', 'author', 'author__last_name', 'author__first_name', ]

La partie gauche représente la clé de l'url (pages ou author_in ou author_isnull, l'url sera pages=valeur ou author_in=1,2 ou author_isnull=true).

La partie droite soit l'expression de la queryset (lookup_expr="mot_cle", sera le postfix à la requête queryset.

iexact donnera champ__iexact="valeur"), soit la classe filtre spécialisée (RangeFilter dans notre exemple), si field_name n'est pas donné, il recherchera le champ de même libellé que la partie gauche (last_name par exemple).

author_isnull donnera champ__isnull=true

La class Meta permet de préciser le modèle sur lequel le filtre s'applique ainsi que les champs, optionnellement, les lookup_expr pourraient être définies dans cette partie pour chaque champ

class AuthorFilter(FilterSet):
    last_name = filters.CharFilter()
    first_name = filters.CharFilter()

    class Meta:
        model = Author
        fields = {'last_name': ['iexact'],
                  'first_name': ['iexact']}

Pour Book, on va pouvoir rechercher les libres qui ont entre N et M pages grâce au filtre RangeFilter, l'url d'interrogation sera par exemple http://plateform/library/books/?pages_min=100&pages_max=400, on peut omettre _min ou _max, la requête sera alors >= ou <= pour comparer la valeur

Requête générée

SELECT "api_library_book"."id",
       "api_library_book"."dt_created",
       "api_library_book"."dt_updated",
       "api_library_book"."name",
       "api_library_book"."nb_pages",
       "api_library_book"."author_id",
       "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_book"
 INNER JOIN "api_library_author"
    ON ("api_library_book"."author_id" = "api_library_author"."id")
 WHERE "api_library_book"."nb_pages" BETWEEN '100' AND '400'
 LIMIT 10

Pouvoir retrouver les libres d'une liste d'auteurs, author_in = IntegerInFilter(field_name='author', lookup_expr='in'), IntergerInFilter permet de surchargées BaseInFilter et NumberFilter pour préciser que ce sont des entiers que l'on attend, la requête sera alors par exemple http://plateform/library/books/?author_in=1,2

Il est à remarquer que l'on retrouve les filtres applicables sur l'interface de Swagger, en ouvrant directement les urls, par exemple http://plateform/library/books/

Alt Text

Alt Text

On pourrait aussi définir des fonctions si les règles métiers sont un peu complexes grâce au mot clé method, on accède à la queryset courante ainsi que la valeur à prendre en compte, ajoutons la possibilité pour Author de faire ?by_search=oli%20duv

from django.db.models import Q
from django_filters import FilterSet, filters, BaseInFilter, NumberFilter

from .models import Book, Author

class AuthorFilter(FilterSet):
    last_name = filters.CharFilter(lookup_expr='iexact')
    first_name = filters.CharFilter(lookup_expr='iexact')
    by_search = filters.CharFilter(method="get_by_search")

    def get_by_search(self, queryset, name, value):
        """
        Search mot à mot
        """
        words = value.split(' ')

        filters = Q()
        for word in words:
            filters |= Q(first_name__icontains=word) | Q(last_name__icontains=word)

        qs = queryset.filter(filters)
        return qs

    class Meta:
        model = Author
        fields = ['last_name', 'first_name',]

Tri

DRF permet également d'effectuer des tris dynamiquement en les précisant dans l'url. Pour cela, nous utilisons la classe OrderingFilter que l'on va ajouter à filter_backends et associé à ordering_fields qui précise quels champs sont triables, dans l'url il suffira d'utiliser ordering en précisant le champ et l'ordre (- pour DESC, rien ou + pour ASC), par exemples :

http://plateform/library/books/?ordering=name donnera un tri sur le champ name en tri ascendant (A -> Z)

SELECT "api_library_book"."id",
       "api_library_book"."dt_created",
       "api_library_book"."dt_updated",
       "api_library_book"."name",
       "api_library_book"."nb_pages",
       "api_library_book"."author_id",
       "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_book"
 INNER JOIN "api_library_author"
    ON ("api_library_book"."author_id" = "api_library_author"."id")
 ORDER BY "api_library_book"."name" ASC
 LIMIT 10

http://plateform/library/books/?ordering=-name donnera un tri sur le champ name en tri descendant (Z -> A)

SELECT "api_library_book"."id",
       "api_library_book"."dt_created",
       "api_library_book"."dt_updated",
       "api_library_book"."name",
       "api_library_book"."nb_pages",
       "api_library_book"."author_id",
       "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_book"
 INNER JOIN "api_library_author"
    ON ("api_library_book"."author_id" = "api_library_author"."id")
 ORDER BY "api_library_book"."name" DESC
 LIMIT 10

Nos viewsets deviennent

# api_library/views.py

from django_filters.rest_framework import DjangoFilterBackend

from rest_framework import viewsets
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import AllowAny

from .models import Author, Book
from .serializers import AuthorSerializer, BookSerializer

class AuthorViewSet(viewsets.ModelViewSet):
    queryset = Author.objects.prefetch_related('books').all()
    serializer_class = AuthorSerializer
    permission_classes = [AllowAny]
    filter_backends =  (DjangoFilterBackend, SearchFilter, OrderingFilter, )

    search_fields = ['first_name', 'last_name']
    filterset_fields = ('last_name',)
    ordering_fields = ('id', 'last_name',)

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.select_related('author').all()
    serializer_class = BookSerializer
    permission_classes = [AllowAny]
    filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter,)

    search_fields = ['name', 'author__first_name', 'author__last_name']
    filterset_fields = ('name', 'nb_pages', 'author', 'author__last_name', 'author__first_name',)
    ordering_fields = ('id', 'name',)

On va pouvoir trier Author sur l'id et le last_name, et Book sur son id et le name

Si l'on souhaite préciser un tri par défaut, il suffira d'utiliser ordering qui peut être une liste de champs, à ajouter à la viewset, par exemple : ordering = ['first_name', 'last_name'] sur AuthorViewSet, triera par défaut les auteurs sur le prénom et nom et non sur leur id qui est la clé par défaut du tri pour les modèles.

On aura alors la requête suivante de générée (remarquer le ORDER BY) :

SELECT "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_author"
 ORDER BY "api_library_author"."first_name" ASC, "api_library_author"."last_name" ASC
 LIMIT 10

on aurait pu faire un tri sur first_name ASC et last_name DESC grâce au - : ordering = ['first_name', '-last_name'], cela donnera


SELECT "api_library_author"."id",
       "api_library_author"."dt_created",
       "api_library_author"."dt_updated",
       "api_library_author"."first_name",
       "api_library_author"."last_name"
  FROM "api_library_author"
 ORDER BY "api_library_author"."first_name" ASC, "api_library_author"."last_name" DESC
 LIMIT 10

La liste des APIs disponibles pour Book et Author sera disponible sur http://plateform

Dans une prochaine partie, nous verrons à débuter une application en Angular qui utilise ces APIs.

Restez connectés !

Posted on by:

zorky profile

DUVAL Olivier

@zorky

CP technique / Scrummaster, développeur sénior ancien blog : https://medium.com/zorky

Discussion

pic
Editor guide