Valider le contenu des JSONFields avec Django

Valider le contenu de vos JSONFields dans un projet Django avec JSONSchema (et comment le tester).

Publié par Pierre le 10 février 2024 16:54

Sauvegarder de la donnée dans une table d’une base de donné relationnelle en utilisant du JSON apporte les avantages suivants:

  • De la flexibilité: il est possible de modifier la structure de la donnée sans avoir besoin de modifier le schema de la base de donnée. A titre d’exemple, il est commun d’utiliser un JSONField pour stocker des métadonnées en provenance de services tiers.
  • Possibilité de sauvegarder des modèles de données complexes dans la base de donnée avec un faible impact sur les opérations de lecture et d’écriture, évitant des requêtes complexes impliquant des jointures couteuses.

Depuis la version 3.1 de Django, le champs de modèle JSONField est disponible pour tous les moteurs de base de données supportés par le le framework. Précédemment, ce champs faisait parti du package django.contrib.postgres.fields et était limité aux base de données PostgreSQL.

Un des inconvénients auquel vous pourriez faire face en utilisant des JSONField dans votre projet Django est que la donnée contenue dans ce type de champs risque de devenir imprévisible:

  • vous pouvez difficilement être certains de ce qui va être stocké dans ces champs, au fil du temps, dans votre base de données de production.
  • en tant que développeur, il peut être difficile de savoir précisément comment est structuré le contenu d’un JSONField et de savoir ce qu’il contient (sans avoir à relire beaucoup de code).

Dans cet article, nous allons essayer de répondre à cette problématique en:

  • Utilisant des JSON Schema afin de valider le contenu d’un dict.
  • Créant notre propre champs de model Django, JSONSchemaField, afin d’être en mesure de déclarer un JSON Schema et de l’utiliser pour valider le contenu d’un json field avant son insertion dans la base de donnée.

Les sources de ce projet sont disponibles sur Github.

here we go

A propos de JSON Schema

JSONSchema est un vocabulaire que vous pouvez utiliser pour annoter et valider des documents JSON: https://json-schema.org/learn/getting-started-step-by-step#creating-your-first-schema.

En bref, un JSONSchema vous permet de facilement déclarer:

  • La liste des propriétés de votre objet JSON.
  • pour chaque propriété, vous pouvez valider son type (et, optionnellement, son format).
  • chaque propriété peut être, au choix, requise ou optionnelle.
  • il est possible d’autoriser, ou non, l’ajout de propriété non listées dans le JSON Schema.

Voici un exemple assez simple qui représente un produit avec un nom et un prix:

{
  "title": "Product",
  "description": "A product from Acme's catalog",
  "type": "object",
  "properties": {
    "productId": {
      "description": "The unique identifier for a product",
      "type": "integer"
    },
    "productName": {
      "description": "Name of the product",
      "type": "string"
    },
    "price": {
      "description": "The price of the product",
      "type": "number",
      "exclusiveMinimum": 0
    },
    "tags": {
      "description": "Tags for the product",
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 1,
      "uniqueItems": true
    }
  },
  "required": [ "productId", "productName", "price" ]
}

Dans cet exemple:

  • title et description sont des propriété purement informatives. Ces mots clé n’ajoutent pas de contrainte au schéma de données, vous n’avez pas à les déclarer (mais vous pouvez les utiliser en tant que documentation naturelle dans votre code).
  • type est un mot clé de validation. La donnée saisie doit correspondre au type déclaré dans le schéma.
  • Afin d’être considéré comme valide, notre objet JSON doit contenir les 3 propriétés suivantes: productId, productName, price. La propriété tags est optionnelle car non déclarée dans le champs required de notre schéma.
  • Vous pouvez interdire l’ajout, à un objet JSON, de propriétés non listées dans le schéma en utilisant le mot clé additionalProperties et en définissant sa valeur à false.

Vous pouvez trouver plus d’exemples sur la page miscellaneous-examples de la documentation.

python-jsonschema

Nous allons utiliser la librairie python-jsonschema afin de valider le contenu d’un dictionnaire Python, en se basant sur un JSON Schema. Cette libraire est une implémentation des spécification de JSON Schema pour Python.

pip install jsonschema

Voici un exemple simple tiré de la documentation:

>>> from jsonschema import validate

>>> # A sample schema, like what we'd get from json.load()
>>> schema = {
    "type" : "object",
    "properties" : {
        "price" : {"type" : "number"},
        "name" : {"type" : "string"},
    },
}

>>> # If no exception is raised by validate(), the instance is valid.
>>> validate(instance={"name" : "Eggs", "price" : 34.99}, schema=schema)

>>> validate(
>>>    instance={"name" : "Eggs", "price" : "Invalid"}, schema=schema,
>>> )                                   
Traceback (most recent call last):
    ...
ValidationError: 'Invalid' is not of type 'number'

Ajout de la validation à vos JSONFields Django

Comme nous avons pu le voir juste au-dessus, la libraire python-jsonschema est assez simple d’utilisation. Nous voulons maintenant l’utiliser pour valider le contenu de nos JSONField Django avant de le sauvegarder dans notre base de donnée. Afin d’y arriver, nous allons créer notre propre champs de model Django que nous allons appeler JSONSchemaField. Ce champs va hériter du champs JSONField disponible avec Django, nous n’allons pas modifier son comportement initiale mais nous allons lui ajouter une couche de validation.

Nous allons prendre l’exemple d’un model Django destiné à sauvegarder des informations complémentaires au sujet d’un utilisateur. Notre but est d’arriver au model suivant:

from django.db import models
from django.contrib.auth.models import User
from django.conf import settings

from .fields import JSONSchemaField

class UserInformation(models.Model)
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    information = JSONSchemaField(
        schema={
            "type": "object",
            "properties": {
                "name": {"type": "string", "maxLength": 255},
                "email": {"type": "string", "format": "email"},
                "centers_of_interest": {
                    "type": "array",
                    "items": {
                        "type": "string",
                        "maxLength": 255,
                    },
                },
            },
            "required": ["name", "email"],
            "additionalProperties": False,
        },
    )

Ce model déclare:

  • Une relation 1:1 vers un utilisateur.
  • Un champs information utilisé pour sauvegarder les informations complémentaires au format JSON.

Bien sur, il ne s’agit que d’un exemple simple pour valider notre démonstration. Vous avez sûrement des cas d’utilisation plus intéressants!


Nous allons commencer par créer un nouveau fichier (que je vais nommer fields.py) et le placer dans une app Django:

from django.db import models
from django.core import checks, exceptions

from jsonschema import SchemaError
from jsonschema import exceptions as jsonschema_exceptions
from jsonschema import validate
from jsonschema.validators import validator_for


class JSONSchemaField(models.JSONField):
    """
    JSONField with a schema validation by using `python-jsonschema`.
    Cf: https://python-jsonschema.readthedocs.io/en/stable/validate/
    """

    def __init__(self, *args, **kwargs):
        self.schema = kwargs.pop("schema", None)
        super().__init__(*args, **kwargs)

    def validate(self, value, model_instance):
        return super().validate(value, model_instance)

    def check(self, **kwargs):
        return super().check(**kwargs)

Notre nouveau champs déclare:

  • un argument nommé schema. Il sera utilisé pour déclarer le schéma json à appliquer pour valider la donnée au niveau du champs de notre model.
  • Une fonction validate(): elle est responsable de valider le contenu qui s’apprête à être stocké dans le champs. Cette fonction est utilisé par Django afin de valider des formulaires, sur l’admin, dans le serializers DRF … Si vous n’êtes pas familier avec cette fonction, je vous recommande la lecture de la page Form and field validation documentation.
  • Une fonction check(): elle est responsable d’exécuter des vérifications, de valider que notre champs est correctement implémenté au niveau de notre model (qu’il est au bon type et qu’il représente un schéma json valide). Les Django’s system checks sont exécutés au lancement du serveur Django (ou bien lorsque vous lancez python manage.py check) et vous informent d’erreurs présentes dans votre projet. Pour cette implémentation, je me suis inspiré de ce que fait Django pour le champs de model CharField.

Les checks

Je recommande de débuter par l’implémentation des checks. Cette manière de faire devrait vous faire gagner du temps en vous assurant que vous travailler sur un schéma JSON valide.

La méthode check() est prototypée de la manière suivante:

  • Elle prends des **kwargs qui contiennent les paramètres que vous déclarez au niveau de votre champs (par exemple, max_length pour un CharField). Nous allons recevoir le paramètre schema parmi les kwargs.
  • Elle retourne une liste d’objets CheckMessage (une liste vide signifiant qu’il n’y à pas d’erreurs) pouvant chacun avoir différents niveaux de sévérité (Debug, Info, Warning, Error, Critical). Si la méthode check() retourne un ou des messages avec un niveau de sévérité égal ou supérieur à Error, alors Django empêchera toute commande de management de s’exécuter. Autrement, les messages seront reportés dans la console mais n’empêcheront pas le serveur de démarrer.

Nous implémentons notre check de la manière suivante:

from jsonschema.validators import validator_for

class JSONSchemaField(models.JSONField):
    def __init__(self, *args, **kwargs):
        self.schema = kwargs.pop("schema", None)
        super().__init__(*args, **kwargs)

    @property
    def _has_valid_schema(self) -> bool:
        if not isinstance(self.schema, dict):
            return False

        # Determine validator class for the schema.
        schema_cls = validator_for(self.schema)
        try:
            # Check the schema.
            schema_cls.check_schema(self.schema)
        except SchemaError:
            return False
        return True

    def _check_schema_attribute(self):
        if self.schema is None:
            return [
                checks.Error(
                    "JSONSchemaField must define a 'schema' attribute.", obj=self
                )
            ]
        elif not self._has_valid_schema:
            return [
                checks.Error("Given 'schema' is not a valid json schema.", obj=self)
            ]
        else:
            return []

    def check(self, **kwargs):
        return [*super().check(**kwargs), *self._check_schema_attribute()]

Nous effectuons les vérifications suivantes sur le paramètre schema:

  • S’assurer qu’il s’agit d’une instance de dict.
  • S’assurer qu’il s’agit d’un schéma JSON valide et conforme aux spécifications.
  • Pendant cette étape, nous appelons la fonction validator_for(). Cette fonction est capable de déterminer la classe de validation appropriée pour valider un schéma JSON. Par exemple, si vous déclarer la propriété $schema dans votre schéma, elle sera utilisée pour déterminer la classe à utiliser pour la validation de ce dernier.

Vous pouvez valider que votre implémentation est correcte en exécutant le code suivant:

>>> python manage.py check
System check identified no issues (0 silenced).

Maintenant, essayez de remplacer le paramètre schema de votre champs de model en le modifiant de la façon suivante:

    information = JSONSchemaField(
        schema={
            "type": "foo",
            "properties": {
            },
        },
    )
>>> python manage.py check
SystemCheckError: System check identified some issues:

ERRORS:
jsonschemafield.UserInformation.information: Given 'schema' is not a valid json schema.

System check identified 1 issue (0 silenced).

La validation

Il est maintenant temps de valider le contenu s’apprêtant à être stocké en base de données via notre JSON Field.

Pour ce faire, nous modifions la méthode JSONSchemaField.validate() afin qu’elle appelle la fonction validate() de la librairie python-jsonschema.

from jsonschema import validate

class JSONSchemaField(models.JSONField):

    def validate(self, value, model_instance):
        """Validate the content of the json field."""
        super().validate(value, model_instance)
        try:
            validate(instance=value, schema=self.schema)
        except jsonschema_exceptions.ValidationError as e:
            raise exceptions.ValidationError(
                "Invalid json content: %(value)s",
                code="invalid_content",
                params={"value": e.message},
            )

Vous pouvez valider que votre implémentation fonctionne en jouant avec l’admin Django ou bien en ouvrant un shell:

>>> from myapp.models import UserInformation
>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> user = User.objects.get(pk=42)
>>> user_information = UserInformation(user=user, information={'name': 'Joe', 'email': 'joe@fasterthan.fr'})

>>> user_information.full_clean()
>>> user_information = UserInformation(user=user, information={'name': 'Joe', 'email': 'joe@fasterthan.fr', 'foo': 'bar'})
>>> user_information.full_clean()
---------------------------------------------------------------------------
ValidationError                           Traceback (most recent call last)
Cell In[9], line 1
----> 1 user_information.full_clean()

File ~/.venvs/blog-entry-jsonschemafield/lib/python3.10/site-packages/django/db/models/base.py:1552, in Model.full_clean(self, exclude, validate_unique, validate_constraints)
   1549         errors = e.update_error_dict(errors)
   1551 if errors:
-> 1552     raise ValidationError(errors)

ValidationError: {'information': ["Invalid json content: Additional properties are not allowed ('foo' was unexpected)"]}

(Optionnel) Valider le format d’une propriété

JSONSchema expose un mot clé format qui peut être utilisé pour valider le contenu d’une propriété. Par exemple, il est possible de valider que la propriété représente une adresse mail correctement formatée.

Regardons à nouveau le schéma que nous avons déclaré plus tôt:

    information = JSONSchemaField(
        schema={
            "type": "object",
            "properties": {
                "name": {"type": "string", "maxLength": 255},
                "email": {"type": "string", "format": "email"},
                "centers_of_interest": {
                    "type": "array",
                    "items": {
                        "type": "string",
                        "maxLength": 255,
                    },
                },
            },
            "required": ["name", "email"],
            "additionalProperties": False,
        },
    )

Vous pouvez voir que la propriété email déclare un champs format de type email. Seulement, si nous tentons de valider un contenu comportant une adresse mail mal formée:

>>> user_information = UserInformation(user=user, information={'name': 'Joe', 'email': 'bar'})
>>> user_information.full_clean()

aucune ValidationError n’est levée cette fois ci …

C’est parce que la spécification de jsonschema ne force pas la validation des formats. La librairie python suit donc ce comportement: https://python-jsonschema.readthedocs.io/en/stable/validate/#validating-formats. Il est toutefois possible de valider le format de nos propriétés en mettant à jour notre code afin de passer format_checker en argument de la fonction validate() de python-jsonschema:

class JSONSchemaField(models.JSONField):

    def validate(self, value, model_instance):
        super().validate(value, model_instance)

        # Determine validator class for the schema ...
        schema_cls = validator_for(self.schema)
        try:
            validate(
                instance=value, 
                schema=self.schema,
                # ... use it to validate the content of the json.
                format_checker=schema_cls.FORMAT_CHECKER,
            )
        except jsonschema_exceptions.ValidationError as e:
            raise exceptions.ValidationError(
                "Invalid json content: %(value)s",
                code="invalid_content",
                params={"value": e.message},
            )

Vous pouvez maintenant relancer le même code encore une fois:

>>> user_information = UserInformation(user=user, information={'name': 'Joe', 'email': 'bar'})
>>> user_information.full_clean()
---------------------------------------------------------------------------
ValidationError                           Traceback (most recent call last)
Cell In[7], line 1
----> 1 user_information.full_clean()

File ~/.venvs/blog-entry-jsonschemafield/lib/python3.10/site-packages/django/db/models/base.py:1552, in Model.full_clean(self, exclude, validate_unique, validate_constraints)
   1549         errors = e.update_error_dict(errors)
   1551 if errors:
-> 1552     raise ValidationError(errors)

ValidationError: {'information': ["Invalid json content: 'bar' is not a 'email'"]}

Veuillez noter que, tel que spécifié dans la documentation de python-jsonschema:

  • certains formats nécessite l’installation de dépendances python supplémentaires.
  • ce n’est pas le cas du format email que nous utilisons dans cet exemple (et d’autres formats tels que date ou bien ipv4).
  • si ces dépendances requises ne sont pas installées, alors la validation va réussir sans lever d’erreurs liées aux formats.

Si vous souhaitez installer ces dépendances supplémentaires, vous pouvez le faire de la manière suivante:

pip install jsonschema'[format]'

ou bien

pip install jsonschema'[format-nongpl]'

La seconde manière de faire n’installera pas de dépendances python couverte par une licence GPL. Veuillez noter que si votre application utilise des librairies couvertes par la licence GPL alors cela implique pour votre logiciel d’être distribué sous la licence GPL.

Pour conclure

Nous arrivons au terme de cet article. J’espère qu’il vous aura intéressé et que vous y aurez appris des choses.

Voici la version finale du code que nous avons produit:

fields.py

from django.db import models
from django.core import checks, exceptions

from jsonschema import SchemaError
from jsonschema import exceptions as jsonschema_exceptions
from jsonschema import validate
from jsonschema.validators import validator_for


class JSONSchemaField(models.JSONField):
    """
    JSONField with a schema validation by using `python-jsonschema`.
    Cf: https://python-jsonschema.readthedocs.io/en/stable/validate/
    """

    def __init__(self, *args, **kwargs):
        self.schema = kwargs.pop("schema", None)
        super().__init__(*args, **kwargs)

    @property
    def _has_valid_schema(self):
        """Check that the given `schema` is a valid json schema."""
        schema_cls = validator_for(self.schema)
        try:
            schema_cls.check_schema(self.schema)
        except SchemaError:
            return False
        return True

    def check(self, **kwargs):
        return [*super().check(**kwargs), *self._check_schema_attribute()]

    def _check_schema_attribute(self):
        """Ensure that the given schema is a valid json schema during Django's checks."""
        if self.schema is None:
            return [
                checks.Error(
                    "JSONSchemaField must define a 'schema' attribute.", obj=self
                )
            ]
        elif not self._has_valid_schema:
            return [
                checks.Error("Given 'schema' is not a valid json schema.", obj=self)
            ]
        else:
            return []

    def validate(self, value, model_instance):
        """Validate the content of the json field."""
        super().validate(value, model_instance)
        schema_cls = validator_for(self.schema)
        try:
            validate(instance=value, schema=self.schema, format_checker=schema_cls.FORMAT_CHECKER)
        except jsonschema_exceptions.ValidationError as e:
            raise exceptions.ValidationError(
                "Invalid json content: %(value)s",
                code="invalid_content",
                params={"value": e.message},
            )

models.py

from django.db import models
from django.contrib.auth.models import User
from django.conf import settings

from .fields import JSONSchemaField


class UserInformation(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    information = JSONSchemaField(
        schema={
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "type": "object",
            "properties": {
                "name": {"type": "string", "maxLength": 255},
                "email": {"type": "string", "format": "email"},
                "centers_of_interest": {
                    "type": "array",
                    "items": {
                        "type": "string",
                        "maxLength": 255,
                    },
                },
            },
            "required": ["name", "email"],
            "additionalProperties": False,
        },
    )

Les sources de ce projet sont disponibles sur github.

Le code que nous avons produit va valider le contenu de vos JSONField dans les formulaires, sur l’admin Django, les serializers DRF, … En fonction de votre projet, vous pourriez vouloir forcer la validation à la sauvegarde d’un model. C’est possible en appelant le fonction full_clean() dans la méthode save() de votre model:

class UserInformation(models.Model):

    def save(self, *args, **kwargs):
        super().full_clean()
        super().save(*args, **kwargs)

Veuillez noter que la validation des JSONSchemaField se fait au niveau applicatif (via du code Python) et non au niveau de votre base de données. Cela signifie que cela ne fonctionnera pas sur des création ou mise à jour en batch (eg: bluk_create).

Il est aussi possible de réaliser des validations de propriété d’un objet JSON de manière plus poussée que ce que nous avons fait aujourd’hui. Par exemple, le schéma suivant peut être utiliser pour valider qu’une url commence par https:

"url": {"type": "string", "format": "uri", "pattern": "^https://"},