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