Django Ninja quickstart

31 mars 2023 16:19 dans data-pipelines / publications

Créer des apis rapidement dans Django: une promesse que tient Django Ninja. Directement inspirée de FastAPI et utilisant les mêmes concepts, cette librairie permet de créer des endpoints flexibles et maintenables. Nous allons explorer l'écriture d'une api avec Django Ninja. Le code contenu dans cet article peut être complété par…

Slider Image

Django Ninja quickstart

Créer des apis rapidement dans Django: une promesse que tient Django Ninja. Directement inspirée de FastAPI et utilisant les mêmes concepts, cette librairie permet de créer des endpoints flexibles et maintenables. Nous allons explorer l'écriture d'une api avec Django Ninja. Le code contenu dans cet article peut être complété par un repository starter template django-spaninja et par la documentation officielle de Django Ninja

Créer un endpoint

Nous allons créer un endpoint GET simple à partir du modèle User standard de Django. Dans un fichier api.py dans une app Django nommée account:


      from typing import Tuple
      from ninja import Router
      from django.http import HttpRequest
      from django.contrib.auth import logout

      router = Router(tags=["account"])
      
      @router.get(
        "/logout",
        response={200: None, 403: None},
      )
      def auth_logout(request: HttpRequest) -> Tuple[int, None]:
          if request.user.is_anonymous is True:
              return 403, None
          logout(request)  # returns a 200
          return 200, None            
    

Status codes http

Ici le endpoint ne retourne pas de données. Les status codes http sont déclarés dans le decorator de la fonction, indiquant au frontend le résultat du traitement. Le endpoint renverra un code 403 si l'utilisateur est anonyme, sinon un 200 après avoir délogué le user

Définir le schéma de réponse

 

Une des grandes forces de Django Ninja réside dans sa capacité à valider les données entrantes et sortantes grâce aux schémas Pydantic. Nous allons créer un endpoint GET simple qui retourne de l'information sur un user. Définissons d'abord le schéma de réponse dans un fichier schemas.py:

 


        from django.contrib.auth import get_user_model
        from ninja.schema import ModelSchema
        
        class UserSchema(ModelSchema):
            class Meta:
                model = get_user_model()
                fields = ["username", "first_name", "last_name"]          
      

Ce schéma déclare les champs username, first_name et last_name du modèle User. Nous allons l'utiliser pour définir le data type de la réponse http du endpoint:


      from django.contrib.auth import get_user_model
      from ninja import Router
      from .schemas import UserSchema
      
      router = Router()
      
      @router.get(
          "/users/{user_id}",
          response=UserSchema,
          status={200: UserSchema, 404: None},
      )
      def get_user(request: HttpRequest, user_id: int):
          try:
              user = get_user_model().objects.get(id=user_id)
          except get_user_model().DoesNotExist:
              return 404, None
          return user, None
      

Paramètres url

Le endpoint accepte une variable user_id de type int dans son url que nous utilisons pour le query.

Sérialisation

Si le user existe nous retournons simplement l'instance user qui sera automatiquement sérialisée selon le schéma de réponse UserSchema, retournant un payload json conforme:


      {
        username: "jeandupont",
        first_name: "Jean",
        last_name: "Dupont",
      }
      

Documentation autogénérée

Maintenant que le schéma de réponse est défini et les paramètres statiquement typés nous bénéficions d'une api doc autogénérée pour tester le endpoint sur http://localhost:8000/api/doc

Formulaires

Nous allons maintenant examiner comment utiliser un formulaire dans un endpoint POST avec un exemple de formulaire de login. Créons d'abord des schémas pour définir les données d'entrée et de sortie:


          from typing import List, Dict, Any
          from ninja.schema import Schema
          
          class LoginFormContract(Schema):
              username: str | None
              password: str | None

          class MsgResponseContract(Schema):
              message: str

          class FormInvalidResponseContract(Schema):
              errors: Dict[str, List[Dict[str, Any]]]
        

Nous allons maintenant utiliser ces schémas pour définir le endpoint:


    from typing import Tuple
    from django.http import HttpRequest
    from django.contrib.auth.forms import AuthenticationForm
    from django.contrib.auth import authenticate, login
    from .schemas import (
      LoginFormContract,
      MsgResponseContract,
      FormInvalidResponseContract,
    )
    
    @router.post(
        "/login",
        auth=None,
        response={
            200: None,
            422: FormInvalidResponseContract,
            401: MsgResponseContract,
        },
    )
    def auth_login(
        request: HttpRequest, data: LoginFormContract
    ) -> Tuple[int, None | FormInvalidResponseContract | MsgResponseContract]:
        form = AuthenticationForm(data=data.dict())
        if form.is_valid() is False:
            return 422, FormInvalidResponseContract.parse_obj(
                {"errors": form.errors.get_json_data(escape_html=True)}
            )
        user = authenticate(
            username=form.cleaned_data.get("username"),
            password=form.cleaned_data.get("password"),
        )
        if user is not None:
            login(request, user)  # returns a 200
            return 200, None
        else:
            return 401, MsgResponseContract(message="Login refused")    
  

Status codes http

Les status codes retournés par le endpoint informent le frontend du résultat du traitement du formulaire:

  • 200: l'utilisateur est logué, succès
  • 401: le backend refuse le login de l'utilisateur
  • 422: le formulaire contient des erreurs

Erreurs du formulaire

Si le formulaire contient des erreurs, comme par exemple un champ obligatoire non rempli, nous récupérons les messages d'erreur générés par Django en json pour les transmettre au frontend

Authentification et sécurité

Il est possible de protéger l'api en la réservant aux utilisateurs authentifiés. Pour cela nous allons le déclarer dans le fichier api.py de l'app Django principale ou est déclaré le router Ninja:


      from django.contrib.admin.views.decorators import staff_member_required
      from ninja import NinjaAPI
      from ninja.security import django_auth
      from ninja.errors import ValidationError
      
      from apps.account.api import router as account_router
      
      api_kwargs = {
          "auth": django_auth,
          "csrf": True,
          "docs_decorator": staff_member_required,
      }
      api = NinjaAPI(**api_kwargs)

      api.add_router("/account/", account_router)
      

Auth par défaut

Spécifier le paramètre auth avec django_auth permet de réserver par défaut l'api uniquement aux utilisateurs logués. Pour faire une exception pour un endpoint, par exemple comme pour le formulaire de login ci-dessus, utiliser auth=None dans décorateur.

Csrf

Le paramètre "csrf": True indique que le frontend doit fournir un token csrf dans le header de chaque request.

Api doc

Le paramètre "docs_decorator": staff_member_required indique que la documentation de l'api est réservée aux utilisateurs staff.

Status codes et erreurs

Les status codes étant centraux, pour déterminer la nature des réponses des endpoints, nous allons ajouter un status code spécial 418 pour discriminer les erreurs de validation de schémas des autres. Les réponses 422 par exemple représentent une erreur de validation de formulaire qui peut arriver au runtime. Les erreurs de validation de schémas sont en revanche des erreurs développeurs et ne sont pas supposées arriver au runtime. Créons un validateur custom qui sera chargé de retourner un code 418: dans le fichier api.py principal:


    @api.exception_handler(ValidationError)
    def custom_validation_errors(
        request: HttpRequest, exc: ValidationError
    ) -> HttpResponse:
        # print the error to the terminal
        print(json.dumps(exc.errors, indent=2))
        # send a 418 status response with the error data
        return api.create_response(request, {"detail": exc.errors}, status=418)
    

De cette manière, le développeur sera informé sans ambiguité si une donnée ne passe pas la validation d'un schéma.

Pour aller plus loin

Pour approfondir cet article, voir aussi: