Django-ninja ? 🥷

Qu'est ce que c'est, Django-ninja ?

Django-ninja est une librairie python, qui, comme son nom peut le laisser deviner, s’intègre avec django. À quoi ça sert ? Comment on fait ? Et bien c’est ce que je vais raconter dans cet article.

Django-ninja est une alternative à django-rest-framework pour le développement d’API pour une application django. L’objectif est d’avoir une librarie simple d’utilisation et rapide, à la mise en place comme à l’exécution.

Pour l’écriture de cet article, j’ai développé un mini projet contenant deux modèles, un modèle Product, contenant un nom, un prix et une Foreign key vers un modèle Category, comportant un nom. Le code que j'ai écrit est dans un dépôt gitlab dont je mettrai le lien à la fin de l'article.

Comment ça marche ?

django-ninja s'inspire beaucoup de FastAPI, avec des endpoints déclarés par des fonctions.

La première étape est d'instancier une classe NinjaAPI. En général, celle-ci sera déclarée dans un fichier api.py situé au même niveau que le fichier urls.py de votre projet. Dans l'extrait de code suivant, le paramètre urls_namespace servira pour l'usage du raccourci reverse de django.

from ninja import NinjaAPI

api = NinjaAPI(urls_namespace="api")

Ensuite, dans mon application, je vais instancier une classe Router dans un fichier router.py, de la façon suivante. Le paramètre tags sera utilisé pour la génération de la documentation, pour regrouper ensemble les URL possédant le même tag.

from ninja import Router

router = Router(tags=["produits"])

Je vais ensuite retourner dans le fichier api contenant mon instance de NinjaAPI et pouvoir ajouter mon router à celle-ci. Dans mon projet, j'ai créé deux routers, un pour mon modèle Category, l'autre pour mon modèle Product.

from ninja import NinjaAPI

from demo.products import routers

api = NinjaAPI(urls_namespace="api")

api.add_router("/products/", routers.product.router)
api.add_router("/categories/", routers.category.router)

Et enfin, dans mon fichiers urls.py, je vais pouvoir ajouter Ă  la variable urlpatterns les URL de mon API Ninja.

from django.urls import path
from .api import api

urlpatterns = [
    path("api/", api.urls),
]

Ok, c'est super, j'ai pu ajouter les URL de mon api aux URL de django, mais maintenant, il va falloir définir des endpoints sinon ça nous fera une belle jambe. Ci-dessous vous trouverez un exemple d'un endpoint permettant de retrouver un Product à partir de son id. Cette définition se fera dans le fichier router créé plus tôt.

router = Router(tags=["produits"])

@router.get(
    "/{id}/",
    response={
        HTTPStatus.OK: schemas.ProductOut,
        HTTPStatus.NOT_FOUND: MessageSchema
    },
    url_name="product-details",
    summary="Retrouver un produit par ID",
    description="Point d'API permettant de retrouver un produit par son ID",
)
def retrieve_product(request, id: int):
    try:
        return HTTPStatus.OK, services.retrieve_product(id=id)
    except exceptions.ErrorNotFound:
        return HTTPStatus.NOT_FOUND, {
            "message": f"Le produit avec l'id {id} n'a pas été trouvé"
        }

La déclaration de l'endpoint se découpe en deux parties.

  • L'utilisation du router comme dĂ©corateur, permettant en premier de dĂ©clarer le verbe HTTP, et en paramètre de celui-ci on va retrouver l'URL et ses Ă©ventuels paramètres et les types de rĂ©ponses attendus (ici, une 200 avec le dĂ©tail d'un produit, ou une 404 avec un message d'erreur). Le paramètre url_name dĂ©finit le nom qu'il faudra utiliser pour le reverse. S'il n'est pas dĂ©fini, il aura comme valeur par dĂ©faut le nom de la fonction. Les paramètres summary et description sont utilisĂ©s pour la gĂ©nĂ©ration de la documentation.
  • La dĂ©claration de la fonction pour mon endpoint. En paramètre, j'aurai ma requĂŞte, ainsi que l'id que j'ai dĂ©fini dans mon URL.

Dans mon code, j'ai fait le choix (sans doute discutable, en fonction des habitudes de code de chacun) de séparer ma logique métier de la gestion de mes endpoints. Ainsi, mes fonctions permettant de créer/lister/créer/modifier/supprimer des Product en utilisant l'ORM sont placés dans un fichier services.py. J'ai fait ce choix pour plusieurs raisons:

  • Pour une question de lisibilitĂ©, j'aime bien sĂ©parer mon code et ranger les trucs, ainsi j'ai un fichier pour la gestion de mes endpoints, un pour mes services, et un autre pour mes schĂ©mas. Cependant, j'entends tout Ă  fait que certains n'apprĂ©cient pas ce choix, vu que par nature, ça va impliquer de multiplier les fichiers.
  • Pour pouvoir rĂ©utiliser mes fonctions de services ailleurs. Par exemple, ici, il y a de fortes chances que ma fonction service retrieve_product soit elle-mĂŞme utilisĂ©e dans un autre endpoint permettant la modification d'un Product.
  • Si on pousse cette logique jusqu'au bout (ce que je n'ai pas fait dans mon projet), on peut avoir des services qu'on peut tester sans instancier de client http, et de l'autre des endpoints que l'on peut tester sans accès Ă  une base de donnĂ©es, en mockant les fonctions services. Ça peut permettre d'accĂ©lĂ©rer les tests (au prix d'un peu plus d'Ă©criture de code) et d'ĂŞtre plus prĂ©cis sur ce que l'on teste.

Pour les types de réponses attendus, un Schema Ninja (basé sur Pydantic) est attendu pour décrire le format de données sortantes.

Comme Product est un modèle django, je peux utiliser la classe ModelSchema de django-ninja pour créer rapidement ce schéma, comme ci-dessous.

from ninja import ModelSchema

from . import models

class ProductOut(ModelSchema):
    category_name: str

    class Meta:
        model = models.Product
        fields = ["id", "name", "price", "category"]

Le format est assez similaire à ce qu'on peut retrouver dans les ModelForm de django, avec l'usage d'une classe Meta permettant de définir les champs voulus. Ici, j'ai également ajouté manuellement un champ category_name qui fait référence à une property de mon modèle.

De la même façon que l'on peut définir un format de données en sortie dans le décorateur, on peut aussi définir un format de données en entrée avec un schéma. Par exemple, pour mon endpoint de création de Product, la signature de ma fonction ressemblera à ça:

def create_product(request, payload: schemas.ProductIn):
    ...

Une fois les différents endpoints définis, un swagger sera généré pour l'API. Pour chaque endpoint seront décrits les paramètres, les codes de réponse possibles ainsi que le format de réponse pour chaque status_code.

Capture d'écran du swagger généré par django-ninja

Pourquoi je trouve ça intéressant?

J'ai pas mal pratiqué le DRF, et une des choses que je pourrais lui reprocher, c'est d'être monolithique, avec des classes qui font un peu tout. Par exemple, le ModelSerializer de django s'occupe à la fois de valider le type de données en entrée, de faire des validations métier, de faire l'insertion en base de données, et de définir le format de sortie. Bref, il fait tout, tout seul, comme un champion. Et c'est très pratique en vrai. Mais j'ai aussi remarqué que dès lors qu'on a besoin faire beaucoup de validations custom, de gérer des cas spécifiques, ou de surcharger des méthodes, le serializer peut vite devenir conséquent.

J'ai l'impression qu'avec django-ninja, si on le couple à une bonne séparation des logiques et des responsabilités, la bonne compréhension du code peut être plus aisée, surtout pour des développeurs qui connaissent peu la librarie ; là ou je trouve que DRF, comme Django d'ailleurs, demande un certain temps d'apprentissage et d'adaptation pour bien comprendre le framework. Bien entendu, en contrepartie, on perd la "magie" de DRF qui fait plein de choses tout seul comme un grand, comme l'insertion des données en base.

Django-ninja est également relativement jeune, surtout par rapport à DRF et ses 14 ans d'existence (il grandit si vite...). DRF est un outil beaucoup plus complet et éprouvé que django-ninja. Typiquement, Ninja ne propose pas de gestion de token comme peut le faire DRF. La librarie propose des outils pour gérer l'authentification, mais c'est moins magique aussi. Cependant, de courageux développeurs ont déjà proposé des librairies permettant d'ajouter contenu et fonctionnalités à django-ninja, comme django-ninja-extra, qui introduit une notion de "api_controller", permettant une gestion des endpoints par classe et non par fonction ou encore django-ninja-jwt qui justement va permettre la gestion des tokens.

Sur ce, je vais m'arrêter de blablater ici, j'espère que la lecture de cet article vous a été agréable.

Ci-dessous, voici les deux liens promis :

Derniers articles

Django-ninja ? 🥷

Django-ninja est une librairie python, qui, comme son nom peut le laisser deviner, s’intègre avec django. À quoi ça sert ? Comment on fait ? Je vais le raconter dans cet article.

Rencontres en non-mixité choisie : retour d’expérience et mise en perspective

En 2023 et 2024, Hashbang a accueilli des rencontres réservées aux femmes et aux personnes non-binaires. Nous revenons sur ces rencontres et leurs enseignements.

Pourquoi nous utilisons Wagtail : le CMS Django qui sépare les casquettes

Une introduction à Wagtail et son Zen : comment laisser à l'éditeur·i·ce de contenu un peu de choix, mais pas trop.

Comment devenir un bureau d'enregistrement ?

Devenir bureau d'enregistrement est un parcours de la combattante au niveau technique, administratif et financier. Il y a des cas simples et d'autres qui demandent de raser un Yack.