Logique contractuelle des structures de données dans Django Ninja

26 avril 2023 14:15 dans data-pipelines / publications

Logique contractuelle des structures de données dans Django Ninja Assurer l'intégrité des données entre le backend et le frontend: une mission essentielle pour développer une single page app robuste. Nous allons proposer dans cet article de nous appuyer sur les schémas Pydantic fournis par Django Ninja coté backend, et sur…

Slider Image

Logique contractuelle des structures de données dans Django Ninja

Assurer l'intégrité des données entre le backend et le frontend: une mission essentielle pour développer une single page app robuste. Nous allons proposer dans cet article de nous appuyer sur les schémas Pydantic fournis par Django Ninja coté backend, et sur Typescript coté frontend afin d'assurer un typage fort de bout en bout, et structurer les échanges de données.

Nous présenterons ici une méthode de définition et d'utilisation des contrats de données typés entre le backend et le frontend. Note: lire au préalable l'article sur le setup de base Django Ninja qui présente les bases du stack. Nous allons utiliser l'exemple d'un simple formulaire de login et le détailler afin de voir comment traiter les données en frontend.

Backend: déclarer des schémas et des status codes

Schemas

Dans un fichier schemas.py définissons les contrats entre le backend et le frontend, décrivant les types de données dans des schémas Pydantic:


              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]]]
            

Endpoint

Plaçons l'api view dans un fichier api.py


          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")    
        

Les contrats

Nous allons détailler cette view plus loin. L'important à ce stade est que la view définit précisément ce que le frontend doit faire et interpréter, via les contrats de données et les status codes http.

Request

  • Le backend demande un fomat de données entrée auquel le frontend devra se conformer: LoginFormContract. Si le format des données n'est pas validé par le backend en entrée, il renverra un status code 418 au frontend, signifiant que le format du payload est invalide.

Response

  • 200: signifie un succès de l'opération, ici aucune donnée n'est envoyée
  • 401: représente un problème d'authentification: une réponse au format MsgResponseContract est attendue par le frontend
  • 422: le formulaire contient des erreurs: une réponse au format FormInvalidResponseContract est attendue par le frontend

Frontend: créer un formulaire et traiter les status codes

Nous allons maintenant implémenter ce formulaire et son traitement en frontend. Prenons un exemple utilisant Vuejs et des composants Primevue. Nous utiliserons également le package npm djangoapiforms prévu à cet effet. Pour l'installer:


              yarn add djangoapiforms
              # or
              npm install djangoapiforms
            

Note: le code complet de cet exemple peut être trouvé dans le repository du starter template django-spaninja dans l'app account.

Initialisation

Déclarons une instance de forms. Dans un fichier state.ts ou autre:


            import { useApi } from "restmix";
            import { useForms } from "djangoapiforms";
            
            const api = useApi();
            const forms = useForms(api);
            
            export { forms }
          

Documentation du package Restmix qui gère les requests au backend. Note: l'exemple présenté dans cet article est simplifié et ne prend pas en compte les csrf tokens: se référer au repository exemple en lien ci-dessus pour une implémentation plus complète.

Composant

Créons un composant LoginForm.vue. Dans un tag script setup déclarons une variable réactive form pour gérer les erreurs éventuellement renvoyées par Django ainsi que des variables pour gérer le form user input:


    import { reactive, ref } from 'vue';

    // form errors object: will hold the errors if any
    const form = reactive<{ errors: Record< string, string > }>({ errors: {} });            
  

Template

Importons maintenant les widgets nécessaires et créons le template. Ajoutons les imports dans le script et définissons des variables chargées de stocker les input utilisateur:


    import InputText from 'primevue/inputtext';
    import Password from 'primevue/password';

    // to store the user input
    const username = ref();
    const password = ref();
  

Créons le html dans le template:


              <template>
                <div>
                  <div>
                      <InputText id="username" type="text" v-model="username" />
                      <label for="username">Username</label>
                    <small v-if="'username' in form.errors" id="username-help" class="p-error" v-html="form.errors.username"></small>
                  </div>
                  <div class="mt-5">
                      <Password v-model="password" :feedback="false" />
                      <label for="password">Password</label>
                    <small v-if="'password' in form.errors" id="password-help" class="p-error" v-html="form.errors.password"></small>
                  </div>
                  <div class="mt-5">
                    <button @click="postLogin()">Login</button>
                  </div>
                </div>
              </template>
            

Noter les balises small branchées sur la variable réactive form.errors, qui affichera l'erreur correspondante au champ si elle existe. Nous y reviendrons plus loin. Voyons d'abord comment poster le formulaire.

Poster le formulaire


            import { forms } from '@/state';
            
            async function postLogin() {
              const { error, res, errors } = await forms.post("/api/account/login", {
                username: username.value,
                password: password.value,
              });
              if (!error) {
                console.log("Login ok, status code is 200");
              } else {
                if (error.type == "validation") {
                  console.log("422 form validation error", errors)
                  // here we fill the form errors reactive object so the errors 
                  // will be immediatly printed under the form fields in the small tag
                  form.errors = errors;
                  if ("__all__" in form.errors) {
                    console.log("A global validation error:", form.errors)
                  }
                }
                else if (res.status == 401) {
                  console.warn("Login error, 401 unauthorized status code")
                }
              }
            }
          

Traitement des erreurs

Coté frontend

Trois types d'erreurs sont ici traitées:

  • 418: les données ne sont pas valides et une erreur Error sera affichée dans la console automatiquement par le package djangoapiforms. Ceci si l'object envoyé par le frontend est dans un format différent de celui attendu par le schéma backend. Cette erreur est destinée a informer le développeur et n'est pas supposée se produire au runtime.
  • 422: le backend renvoie une erreur de validation de formulaire. Nous savons grâce au status code qu'il s'agit d'une erreur de validation et passons les erreurs à l'objet réactif form.errors. De cette manière les champs de formulaire afficheront immédiatement les erreurs via les tags small que nous avons mis en place.
  • 401 : le cas où l'authentification est refusée, via un status code.

Coté backend

Détaillons la partie backend qui va traiter ce request. D'abord récupérons les données et passons les à un formulaire Django:


            def authlogin(
                request: HttpRequest, data: LoginFormContract
            ) -> Tuple[int, None | FormInvalidResponseContract | MsgResponseContract]:
                form = AuthenticationForm(data=data.dict())
          

Validons ensuite ce formulaire. Si des erreurs sont trouvées nous les renvoyons en json:


            if form.is_valid() is False:
                return 422, FormInvalidResponseContract.parse_obj(
                    {"errors": form.errors.get_json_data(escape_html=True)}
                )
          

Récupérons ensuite les données de formulaires validées pour les traiter et renvoyer les status codes prévus:


            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")  
          

Une double validation

La validation des données et le traitement des erreurs s'effectuent ainsi à deux niveaux : développeur et runtime.

Erreur de niveau développeur

Niveau schéma: les erreurs de type 418 indiquent au développeur que le format de données est incorrect au niveau du type. Cela se produit quand les données ne valident pas le schéma Pydantic défini. Dans ce cas la view n'est même pas appellée et l'erreur est produite en amont et renvoyée au frontend.

Erreur métier au runtime

Les erreurs de validation de formulaire avec de la logique métier sont gérées dans la view par les forms Django, ainsi que par des status codes http bien définis, 422 étant le code signalant au frontend une erreur de validation de formulaire. Chaque endpoint organise ensuite ses status codes de retour en fonction des besoins.

Conclusion

La combinaison des schémas Pydantic en backend et de Typescript en frontend nous permet de garantir un typage fort des données de bout en bout tout en tirant parti de la puissance des formulaires Django. Cela apporte une sécurité et une robustesse indispensable au développement et à la maintenance de serveurs d'api couplés à des apps frontend.

Le typage statique en Python s'améliorant de version en version, il est de notre ressort d'en tirer le meilleur parti.

Note: le code utilisé dans cet article peut être trouvé dans le starter template django-spaninja