Django-rest-framework: Les appels PUT ne "remplacent pas complètement l'état de la ressource cible"

Créé le 30 juin 2016  ·  68Commentaires  ·  Source: encode/django-rest-framework

EDIT : Pour connaître l'état actuel du problème, passez à https://github.com/encode/django-rest-framework/issues/4231#issuecomment -332935943

===

J'ai un problème pour implémenter une bibliothèque Optimistic Concurrency sur une application qui utilise DRF pour interagir avec la base de données. J'essaye de:

  • Confirmer que le comportement que je constate est attribuable au DRF
  • Confirmez qu'il s'agit du comportement prévu
  • Déterminez s'il existe un moyen pratique de surmonter ce comportement

J'ai récemment ajouté la concurrence optimiste à mon application Django. Pour vous épargner la recherche Wiki :

  • Chaque modèle a un champ de version
  • Lorsqu'un éditeur modifie un objet, il obtient la version de l'objet qu'il modifie
  • Lorsque l'éditeur enregistre l'objet, le numéro de version inclus est comparé à la base de données
  • Si les versions correspondent, l'éditeur a mis à jour le dernier document et la sauvegarde est effectuée
  • Si les versions ne correspondent pas, nous supposons qu'une modification « conflictuelle » a été soumise entre le moment où l'éditeur a été chargé et enregistré, nous rejetons donc la modification.
  • Si la version est manquante, nous ne pouvons pas effectuer de test et devons rejeter la modification

J'avais une interface utilisateur héritée qui parlait via DRF. L'interface utilisateur héritée ne gérait pas les numéros de version. Je m'attendais à ce que cela provoque des erreurs de concurrence, mais ce n'est pas le cas. Si j'ai bien compris la discussion au #3648 :

  • DRF fusionne le PUT avec l'enregistrement existant. Cela entraîne le remplissage d'un ID de version manquant avec l'ID de base de données actuel
  • Étant donné que cela fournit toujours une correspondance, l'omission de cette variable cassera toujours un système de concurrence optimiste qui communique à travers DRF
  • ~ Il n'y a pas d'options simples (comme rendre le champ "obligatoire") pour s'assurer que les données sont soumises à chaque fois. ~ (modifier : vous pouvez contourner le problème en le rendant obligatoire, comme indiqué dans ce commentaire )

Étapes à reproduire

  1. Configurer un champ Concurrence optimiste sur un modèle
  2. Créez une nouvelle instance et mettez-la à jour plusieurs fois (pour vous assurer de ne plus avoir de numéro de version par défaut)
  3. Soumettre une mise à jour (PUT) via DRF en excluant l'ID de version

    Comportement attendu

L'ID de version manquant ne doit pas correspondre à la base de données et provoquer un problème de concurrence.

Comportement réel

L'ID de version manquant est rempli par DRF avec l'ID actuel afin que la vérification de la concurrence réussisse.

Enhancement

Tous les 68 commentaires

D'accord, je ne peux pas promettre que je serai en mesure d'examiner immédiatement ce ticket assez détaillé, car la prochaine version 3.4 est prioritaire. Mais merci pour un sujet aussi détaillé et bien pensé. Cela sera probablement considéré à l'échelle des semaines, et non des jours ou des mois. Si vous faites des progrès, si vous avez d'autres idées, veuillez mettre à jour le ticket et nous tenir informés.

D'ACCORD. Je suis presque sûr que mon problème est la combinaison de deux facteurs:

  1. DRF ne nécessite pas le champ dans le PUT (même s'il est requis dans le modèle) car il a une valeur par défaut (version=0)
  2. DRF fusionne les champs PUT avec l'objet courant (sans injecter la valeur par défaut)

Par conséquent, DRF utilise la valeur actuelle (base de données) et rompt le contrôle de la concurrence. La seconde moitié du problème est liée à la discussion dans # 3648 (également citée ci-dessus) et il y a une discussion (avant 3.x) dans # 1445 qui semble toujours pertinente.

J'espère qu'un cas concret (et de plus en plus courant) où le comportement par défaut est pervers suffira à rouvrir la discussion sur le comportement "idéal" d'un ModelSerializer. Évidemment, je n'ai qu'un pouce de profondeur sur DRF, mais mon intuition est que le comportement suivant est approprié pour un champ obligatoire et un PUT :

  • Lors de l'utilisation d'un sérialiseur non partiel, nous devons soit recevoir la valeur, soit utiliser la valeur par défaut, soit (si aucune valeur par défaut n'est disponible) déclencher une erreur de validation. La validation à l'échelle du modèle doit s'appliquer uniquement aux entrées/valeurs par défaut.
  • Lors de l'utilisation d'un sérialiseur partiel, nous devrions soit recevoir la valeur, soit nous rabattre sur les valeurs actuelles. La validation à l'échelle du modèle doit s'appliquer à ces données combinées.
  • Je pense que le sérialiseur "non partiel" actuel est vraiment quasi partiel :

    • C'est non partiel pour les champs qui sont obligatoires et qui n'ont pas de valeur par défaut

    • C'est partiel pour les champs qui sont obligatoires et qui ont une valeur par défaut (puisque la valeur par défaut n'est pas utilisée)

    • C'est partiel pour les champs qui ne sont pas obligatoires

Nous ne pouvons pas modifier la puce (1) ci-dessus ou les valeurs par défaut deviennent inutiles (nous avons besoin de l'entrée même si nous connaissons la valeur par défaut). Cela signifie que nous devons résoudre le problème en modifiant le point 2 ci-dessus. Je suis d'accord avec votre argument dans # 2683 que:

Les valeurs par défaut du modèle sont les valeurs par défaut du modèle. Le sérialiseur doit omettre la valeur et confier la responsabilité au Model.object.create() de gérer cela.

Pour être cohérent avec cette séparation des préoccupations, la mise à jour doit créer une nouvelle instance (déléguant toutes les valeurs par défaut au modèle) et appliquer les valeurs soumises à cette nouvelle instance. Cela se traduit par le comportement demandé dans #3648.

Essayer de décrire le chemin de migration permet de mettre en évidence à quel point le comportement actuel est étrange. Le but final est de

  1. Corrigez le ModelSerializer,
  2. Ajoutez un indicateur pour cet état quasi-partiel, et
  3. Faites de ce drapeau la valeur par défaut (pour la rétrocompatibilité)

Quel est le nom de ce drapeau ? Le sérialiseur de modèle actuel est en fait un sérialiseur partiel qui (un peu arbitrairement) nécessite des champs remplissant la condition required==True and default==None . Nous ne pouvons pas utiliser explicitement le drapeau partial sans casser la rétrocompatibilité, nous avons donc besoin d'un nouveau drapeau (temporaire, espérons-le). Il me reste quasi_partial , mais mon incapacité à exprimer l'exigence arbitraire required==True and default==None est la raison pour laquelle il est si clair pour moi que ce comportement devrait être obsolète de toute urgence.

Vous pouvez ajouter extra_kwargs dans Meta du sérialiseur, faisant version un champ obligatoire.

class ConcurrentModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = ConcurrentModel
        extra_kwargs = {'version': {'required': True}}

Merci @anoopmalev. Cela me gardera sur la branche de la production.

Après avoir "dormi dessus", je me rends compte qu'il y a une ride supplémentaire. Tout ce que j'ai dit devrait s'appliquer aux champs du sérialiseur. Si un champ n'est pas inclus dans le sérialiseur, il ne doit pas être modifié. De cette façon, tous les sérialiseurs sont (et devraient être) partiels pour les champs non inclus. C'est un peu plus compliqué que mon "créer une nouvelle instance" ci-dessus.

Je crois que cette question doit être réduite à une proposition plus contraignante afin d'aller de l'avant.
Semble trop large pour être exploitable dans son état actuel.
Pour l'instant, je ferme ceci - si quelqu'un peut le réduire à une déclaration concise et exploitable du comportement souhaité, nous pouvons alors reconsidérer. Jusque-là, je pense que c'est simplement trop large.

Voici une proposition concise... pour un sérialiseur non partiel :

  1. Pour tout champ non répertorié dans le sérialiseur (implicitement ou explicitement) ou marqué en lecture seule, conservez la valeur existante
  2. Pour tous les autres champs, utilisez la première option disponible :

    1. Remplir avec la valeur soumise

    2. Remplir avec une valeur par défaut, y compris une valeur impliquée par blank et/ou null

    3. Lever une exception

Pour plus de clarté, la validation est exécutée sur le produit final de ce processus.

C'est-à-dire que vous souhaitez définir required=True sur n'importe quel champ de sérialiseur qui n'a pas de modèle par défaut, pour les mises à jour ?

Ai-je bien compris?

Oui (et plus). C'est ainsi que je comprends la distinction partial (tous les champs facultatifs) et non-partial (tous les champs obligatoires). La seule fois où un sérialiseur non-partial ne nécessite pas de champ est la présence d'une valeur par défaut (définie de manière étroite ou large) _puisque le sérialiseur peut utiliser cette valeur par défaut si aucune valeur n'est fournie._

La section en italique est ce que DRF ne fait pas actuellement et le changement le plus important dans ma proposition. L'implémentation actuelle ignore simplement le champ.

J'avais une deuxième proposition mélangée, mais c'est vraiment une question distincte de savoir à quel point vous voulez être généreux avec l'idée d'un "par défaut". Le comportement actuel est "strict" en ce sens que seul un default est traité comme tel. Si vous vouliez _vraiment_ réduire la quantité de données requises, vous pourriez également rendre les champs blank=True facultatifs... en supposant qu'une valeur absente est une valeur vide.

@claytondaley J'utilise OOL avec DRF depuis 2x de cette façon :

class VersionModelSerializer(serializers.ModelSerializer, BaseSerializer):
    _initial_version = 0

    _version = VersionField()

    def __init__(self, *args, **kwargs):
        super(VersionModelSerializer, self).__init__(*args, **kwargs)

        # version field should not be required if there is no object
        if self.instance is None and '_version' in self.fields and\
                getattr(self, 'parent', None) is None:
            self.fields['_version'].read_only = True
            self.fields['_version'].required = False

        # version field is required while updating instance
        if self.instance is not None and '_version' in self.fields:
            self.fields['_version'].required = True

        if self.instance is not None and hasattr(self.instance, '_version'):
            self._initial_version = self.instance._version

    def validate__version(self, value):
        if self.instance is not None:
            if not value and not isinstance(value, int):
                raise serializers.ValidationError(_(u"This field is required"))

        return value
   # more code & helpers

cela fonctionne très bien avec tout type de logique métier et n'a jamais causé de problème.

Cela a-t-il été laissé fermé par accident ? J'ai répondu à la question spécifique et je n'ai pas entendu la raison de ce qui n'allait pas avec la proposition.

@claytondaley pourquoi OOL devrait faire partie de DRF ? Vérifiez mon code - cela fonctionne juste trouver dans une grande application (1400 tests). VersionField est juste un IntegerField .

Vous avez codé en dur l'OOL dans le sérialiseur. Ce n'est pas le bon endroit pour le faire parce que vous avez une condition de concurrence. Les mises à jour parallèles (avec la même version précédente) passeraient toutes au sérialiseur... mais une seule gagnerait à l'action de sauvegarde.

J'utilise django-concurrency qui place la logique OOL dans l'action de sauvegarde (à laquelle elle appartient). Fondamentalement UPDATE... WHERE version = submitted_version . C'est atomique donc il n'y a pas de condition de concurrence. Cependant, il expose une faille dans la logique de sérialisation ::

  • Si la valeur par défaut est définie sur un champ du modèle, DRF définit required=False . L'idée (valide) est que DRF peut utiliser cette valeur par défaut si aucune valeur n'est soumise.
  • Si ce champ est manquant, cependant, DRF n'utilise pas la valeur par défaut. Au lieu de cela, il fusionne les données soumises avec la version actuelle de l'objet.

Lorsque nous n'avons pas besoin du champ, nous le faisons parce que nous avons une valeur par défaut à utiliser. DRF ne remplit pas ce contrat car il n'utilise pas la valeur par défaut... il utilise la valeur existante.

Le problème sous-jacent a déjà été discuté, mais ils n'avaient pas de cas concret et agréable. OOL est ce cas idéal. La valeur existante d'un champ de version passe toujours OOL afin que vous puissiez contourner l'ensemble du système OOL en omettant la version. Ce n'est (évidemment) pas le comportement souhaité d'un système OOL.

@claytondaley

Vous avez codé en dur l'OOL dans le sérialiseur.

Ai-je? Avez-vous trouvé une logique OOL dans mon sérialiseur à côté de l'exigence de champ ?

Ce n'est pas le bon endroit pour le faire parce que vous avez une condition de concurrence.

Sry, je ne peux pas voir où est la condition de course ici.

J'utilise django-concurrency qui place la logique OOL dans l'action de sauvegarde (à laquelle elle appartient).

J'utilise aussi django-concurrency :) Mais c'est au niveau du modèle, pas du sérialiseur. Au niveau du sérialiseur, il vous suffit de :

  • assurez-vous que le champ _version est toujours requis (quand il devrait l'être)
  • assurez-vous que votre sérialiseur sait comment gérer les erreurs OOL (cette partie que j'ai omise)
  • assurez-vous que votre apiview sait comment gérer les erreurs OOL et déclenche HTTP 409 avec un contexte de différence possible

en fait, je n'utilise pas django-concurrency en raison d'un problème que l'auteur a marqué comme "ne résoudra pas": il contourne OOL lorsque obj.save(update_fields=['one', 'two', 'tree']) est utilisé, ce que j'ai trouvé une mauvaise pratique, alors j'ai forké le paquet.

voici la méthode save manquante du sérialiseur que j'ai mentionné plus tôt. cela devrait résoudre tous vos problèmes:

    def save(self, **kwargs):
        try:
            self.instance = super(VersionModelSerializer, self).save(**kwargs)
            return self.instance
        except VersionException:
            # Use select_for_update so we have some level of guarantee
            # that object won't be modified at least here at the same time
            # (but it may be modified somewhere else, where select_for_update
            # is not used!)
            with transaction.atomic():
                db_instance = self.instance.__class__.objects.\
                    select_for_update().get(pk=self.instance.pk)
                diff = self._get_serializer_diff(db_instance)

                # re-raise exception, so api client will receive friendly
                # printed diff with writable fields of current serializer
                if diff:
                    raise VersionException(diff)

                # otherwise re-try saving using db_instance
                self.instance = db_instance
                if self.is_valid():
                    return super(VersionModelSerializer, self).save(**kwargs)
                else:
                    # there are errors that could not be displayed to a user
                    # so api client should refresh & retry by itself
                    raise VersionException

        # instance.save() was interrupted by application error
        except ApplicationException as logic_exc:
            if self._initial_version != self.instance._version:
                raise VersionException

            raise logic_exc

Pardon. Je n'ai pas lu votre code pour comprendre ce que vous faisiez. J'ai vu un sérialiseur. Vous pouvez évidemment contourner le problème en piratant le sérialiseur, mais vous ne devriez pas avoir à le faire... parce que la faille dans la logique DRF est autonome. J'utilise juste OOL pour faire le point.

Et vous devriez essayer ce code avec la dernière version de django-concurrency (en utilisant IGNORE_DEFAULT=False ). django-concurrency ignorait également les valeurs par défaut, mais j'ai soumis un correctif. Il y avait un étrange cas d'angle que je devais traquer pour le faire fonctionner pour les cas normaux.

Je pense que cela s'appelle étendre la fonctionnalité par défaut, pas vraiment du piratage. Je pense que le meilleur endroit pour une telle prise en charge de fonctionnalités est le package django-concurrency .

J'ai relu toute la discussion sur le problème et j'ai trouvé votre proposition trop large et elle échouerait dans de nombreux endroits (en raison de l'utilisation magique de valeurs par défaut provenant de différentes sources dans différentes conditions). DRF 3.x est devenu beaucoup plus facile et prévisible que 2.x, continuons comme ça :)

Vous ne pouvez pas résoudre ce problème dans la couche de modèle car il est cassé au niveau du sérialiseur (avant qu'il n'atteigne le modèle). Mettez OOL de côté... pourquoi n'avons-nous pas besoin d'un champ si default est défini ?

Un sérialiseur non partiel "nécessite" tous les champs (essentiellement) et pourtant nous laissons celui-ci passer. Est-ce un bogue ? Ou avons-nous une raison logique?

comme vous pouvez le voir dans mon exemple de code - le champ _version est toujours correctement requis dans tous les cas possibles.

btw, il s'est avéré que j'ai emprunté le code lvl du modèle à https://github.com/gavinwahl/django-optimistic-lock et non à django-concurrency , ce qui est trop complexe pour presque aucune raison.

... donc le bogue est "les sérialiseurs non partiels définissent de manière incorrecte certains champs sur non requis". C'est l'alternative. Parce que c'est l'engagement (implicite) d'un sérialiseur non partiel.

je peux le citer :

Par défaut, les sérialiseurs doivent recevoir des valeurs pour tous les champs obligatoires, sinon ils généreront des erreurs de validation.

Cela ne dit rien sur requis (sauf lorsqu'une valeur par défaut est fournie).

(et je comprends que je parle de deux niveaux différents, mais le ModelSerializer ne devrait pas supprimer les champs s'il ne prend pas la responsabilité de cette décision)

Je pense que j'ai perdu ton point..

(et je comprends que je parle de deux niveaux différents, mais le ModelSerializer ne devrait pas supprimer les champs s'il ne prend pas la responsabilité de cette décision)

Qu'est-ce qui ne va pas avec ça?

OK, laissez-moi essayer un angle différent.

  • Supposons que j'ai un sérialiseur de modèle non partiel (édition : tous les paramètres par défaut) qui couvre tous les champs de mon modèle.

Si un CREATE ou UPDATE avec les mêmes données devait produire un objet différent (moins l'ID)

Pouvez-vous décrire vos idées à l'aide d'un modèle et d'un sérialiseur vraiment simples et de quelques lignes qui montrent un comportement échoué/attendu ?

Je préparerai quelque chose demain car il se fait tard ici ... mais plus j'approfondis, plus le numéro 3648 a de sens pour un sérialiseur non partiel. En attendant, pourquoi un ModelSerializer n'exige-t-il pas tous les champs du modèle ? Peut-être que votre raisonnement est différent du mien.

ModelSerializer inspecte le modèle délimité et décide s'il doit être requis, n'est-ce pas ?

Je ne veux pas dire mécaniquement comment . L'hypothèse de base pour un sérialiseur non partiel est d'exiger tout (cité ci-dessus). Si get_field_kwargs va s'écarter de cette hypothèse (en particulier, ici ), il devrait y avoir une bonne raison. Quelle est cette raison ?

Ma réponse préférée est celle que je continue à donner, "parce qu'elle peut utiliser cette valeur par défaut si aucune valeur n'est soumise" (mais DRF doit alors utiliser la valeur par défaut). Y a-t-il une autre réponse qui me manque? Une raison pour laquelle les champs avec des valeurs par défaut ne devraient pas être obligatoires ?

Évidemment, je préfère la solution "complète". Cependant, je concéderai qu'il y a une deuxième réponse. Nous pourrions exiger ces champs par défaut. Cela élimine le cas particulier (actuellement arbitraire). Cela simplifie/réduit le code. C'est cohérent en interne. Cela répond à mon souci.

Fondamentalement, cela rend le sérialiseur non partiel vraiment non partiel.

Maintenant, je sais au moins ce que tu veux dire. avez-vous vérifié quel est le comportement de ModelForm dans ce cas ? (Impossible de le faire moi-même sur mobile)

La documentation de Django indique que "vide" contrôle si le champ est obligatoire ou non. Je vous suggère d'ouvrir un ticket séparé pour ce problème car celui-ci contient beaucoup de commentaires sans rapport. À mon avis, modelserializer pourrait fonctionner comme modelform : des contrôles d'option vides sont requis, 'null' indique si None est une entrée acceptable et 'default' n'a aucun effet sur cette logique.

Je suis prêt à ouvrir un deuxième ticket, mais je crains que le blanc nécessite un code similaire. Du groupe de discussion django :

si nous prenons un modèle de formulaire et un modèle existants qui fonctionnent, ajoutez un champ de caractère facultatif au modèle mais ne parvenez pas à ajouter un champ correspondant au modèle HTML (par exemple, erreur humaine, oubli d'un modèle, n'a pas dit à l'auteur du modèle de faire un changement, n'a pas réalisé qu'un changement devait être apporté à un modèle), lorsque ce formulaire est soumis Django supposera que l'utilisateur a fourni une valeur de chaîne vide pour le champ manquant et l'enregistrera dans le modèle, en effaçant toute valeur existante .

Pour être cohérent, nous aurions l'obligation de remplir la seconde moitié du contrat, en mettant la valeur absente à blanc. C'est un peu moins problématique car un blanc peut être rempli sans référence à un modèle, mais c'est très similaire (et, je pense, cohérent avec # 3648).

@tomchristie pouvez-vous donner quelques brèves informations à ce sujet : pourquoi l'état required dépend de la propriété du champ de modèle defaults ?

Pourquoi l'état requis dépend de la propriété par défaut du champ de modèle ?

Simplement ceci : si un champ de modèle a une valeur par défaut, vous pouvez omettre de la fournir en entrée.

En fait, je suis d'accord avec ce comportement. ModelForm malgré le code fait la même chose (le HTML généré fournira des valeurs par défaut). Si DRF aurait une logique différente, alors 'default' ne s'appliquera jamais. J'en ai fini avec ce problème.

@pySilver en fait, voici le comportement ModelForm :

# models.py

from django.db import models

class MyModel(models.Model):
    no_default = models.CharField(max_length=100)
    has_default = models.CharField(max_length=100, default="iAmTheDefault")

Pour plus de clarté, les éléments sont toujours nommés "partiel" car la _mise à jour_ est partielle. Je testais également une mise à jour complète ("full"), mais le code n'était pas nécessaire pour montrer le comportement :

# in manage.py shell
>>> from django import forms
>>> from django.conf import settings
>>> from form_serializer.models import MyModel
>>>
>>> class MyModelForm(forms.ModelForm):
...     class Meta:
...         model = MyModel
...         fields = ['no_default', 'has_default']
...
>>>
>>> partial = MyModel.objects.create()
>>> partial.id = 2
>>> partial.no_default = "Must replace me"
>>> partial.has_default = "I should be replaced"
>>> partial.save()
>>>
>>>
>>> POST_PARTIAL = {
...     "id": 2,
...     "no_default": "must change me",
... }
>>>
>>>
>>> form_partial = MyModelForm(POST_PARTIAL)
>>> form_partial.is_valid()
False
>>> form_partial._errors
{'has_default': [u'This field is required.']}

ModelForm nécessite cette entrée même s'il a une valeur par défaut. C'est l'un des deux comportements cohérents en interne.

Pourquoi l'état requis dépend de la propriété par défaut du champ de modèle ?

Simplement ceci : si un champ de modèle a une valeur par défaut, vous pouvez omettre de la fournir en entrée.

@tomchristie est d'accord sur le principe. Mais quel est le comportement attendu ?

  • Lors de la création, j'obtiens la valeur par défaut (trivial, tout le monde convient que c'est vrai)
  • Lors de la mise à jour, que dois-je obtenir ?

Il me semble que je devrais également obtenir la valeur par défaut lors de la mise à jour. Je ne vois pas pourquoi un sérialiseur non partiel devrait se comporter différemment dans les deux cas. Non partiel signifie que j'envoie l'enregistrement "complet". Ainsi, le dossier complet doit être remplacé.

Je m'attendrais à ce que la valeur soit inchangée si elle n'est pas fournie lors de la mise à jour. Je vois le point, mais écraser de manière transparente avec la valeur par défaut serait contre-intuitif de mon POV.

(Si quoi que ce soit, je pense qu'il serait probablement préférable que toutes les mises à jour soient une sémantique partielle pour tous les champs - PUT serait toujours idempotent, ce qui est l'aspect important, bien qu'il soit peut-être difficile de modifier le comportement actuel)

Je ne partage certainement pas vos préférences; Je veux que toutes mes interfaces soient strictes à moins que je ne les fasse délibérément autrement. Cependant, votre distinction PARTIEL vs NON-PARTIEL fournit déjà (en théorie) ce que nous voulons tous les deux.

Je crois que partial se comporte exactement comme vous le souhaitez:

  • Les mises à jour sont 100 % partielles
  • Les CREATE (je suppose) sont partiels par rapport à default et blank (exceptions logiques). Dans tous les autres cas, les contraintes modèle/base de données sont liées.

J'essaie juste d'obtenir une cohérence dans le sérialiseur non partiel. Si vous éliminez le cas spécial pour default , vos sérialiseurs non partiels existants deviennent le sérialiseur strict que je veux. Ils atteignent également la parité avec ModelForm.

Je me rends compte que cela crée une petite discontinuité au sein du projet, mais ce n'est pas la première fois que quelqu'un fait un tel changement. Ajoutez un indicateur "hérité" par défaut au comportement actuel, ajoutez un avertissement (indiquant que le comportement par défaut changera) et modifiez la valeur par défaut dans une version majeure ultérieure.

Plus important encore, si vous voulez que vos sérialiseurs soient les nouveaux de facto pour Django, vous finirez par faire ce changement de toute façon. Le nombre de personnes converties à partir de ModelForm dépassera largement la base d'utilisateurs existante et ils s'attendront au moins à ce changement.

Insérant mes deux cents :
Je suis enclin à être d'accord avec @claytondaley. PUT est un remplacement de ressource idempotent, PATCH est une mise à jour de la ressource existante. Prenons l'exemple suivant :

class Profiles(models.Model):
    username = models.CharField()
    role = models.CharField(default='member', choices=(
        ('member', 'Member'), 
        ('moderator', 'Moderator'),
        ('admin', 'Admin'), 
    ))

Les nouveaux profils ont raisonnablement le rôle de membre par défaut. Prenons les requêtes suivantes :

POST /profiles username=moe
PUT /profiles/1 username=curly
PATCH /profiles/1 username=larry&role=admin
PUT /profiles/1 username=curly

Dans l'état actuel des choses, après le premier PUT, les données de profil contiendraient {'username': 'curly', 'role': 'member'} . Après le deuxième PUT, vous auriez {'username': 'curly', 'role': 'admin'} . Cela ne rompt-il pas l'idempotence ? (Je ne suis pas tout à fait sûr - je demande légitimement)

Éditer:
Je pense que tout le monde est d'accord sur la sémantique de PATCH.

Après le deuxième PUT, vous auriez {'username': 'curly', 'role': 'admin'}

Personnellement, je serais surpris si le rôle revenait à la valeur par défaut (même si je vois la raison de cette discussion sur l'objet replace , je n'ai encore jamais eu de problèmes réels avec)

Je n'ai encore jamais eu de problèmes dans le monde réel avec

Pareil ici, mais jusqu'à présent, nos projets se sont appuyés sur PATCH :)
Cela dit, le cas d'utilisation de l'OP avec la gestion des versions de modèle pour gérer la simultanéité est logique pour moi. Je m'attendrais à ce que PUT utilise la valeur par défaut (si la valeur est omise), en levant l'exception de concurrence.

Permettez-moi de commencer par reconnaître qu'un sérialiseur ne doit pas nécessairement suivre la RFC RESTful. Cependant, ils devraient au moins proposer des modes _sont_ compatibles - en particulier dans un package offrant un support REST.

Mon argument initial était basé sur les premiers principes, mais la RFC (section 4.3.4) dit spécifiquement (nous soulignons):

La différence fondamentale entre les méthodes POST et PUT est mise en évidence par l'intention différente de la représentation jointe. La ressource cible dans une requête POST est destinée à gérer la représentation incluse selon la propre sémantique de la ressource, tandis que la représentation incluse dans une demande PUT est définie comme remplaçant l'état de la ressource cible.
...
Un serveur d'origine qui autorise PUT sur une ressource cible donnée DOIT envoyer une réponse 400 (Bad Request) à une demande PUT qui contient un champ d'en-tête Content-Range (Section 4.2 de [RFC7233]), puisque la charge utile est susceptible d'être un contenu partiel qui a été mis par erreur en tant que représentation complète . Des mises à jour de contenu partielles sont possibles en ciblant une ressource identifiée séparément avec un état qui chevauche une partie de la plus grande ressource, ou en utilisant une méthode différente qui a été spécifiquement définie pour les mises à jour partielles (par exemple, la méthode PATCH définie dans [RFC5789])

Ainsi, un PUT ne doit jamais être partiel (voir aussi, ici ). Cependant, la section sur PUT clarifie également :

La méthode PUT demande que l'état de la ressource cible soit créé ou remplacé par l'état défini par la représentation contenue dans la charge utile du message de demande. Un PUT réussi d'une représentation donnée suggérerait qu'un GET ultérieur sur cette même ressource cible entraînera l'envoi d'une représentation équivalente dans une réponse 200 (OK).

Le point sur le GET (bien que non obligatoire) plaide pour ma solution de "compromis". Bien que l'injection de blancs/valeurs par défaut soit pratique, cela ne fournirait pas ce comportement. Le clou du cercueil est probablement que cette solution minimise la confusion puisqu'il n'y aura pas de champs manquants pour soulever le doute.

De toute évidence, PATCH est une option spécifiée pour les mises à jour partielles, mais il est décrit comme un "ensemble d'instructions" plutôt que comme un simple PUT partiel, donc cela me rend toujours un peu nerveux. La section sur POST (4.3.3) indique en fait :

La méthode POST demande à la ressource cible de traiter la représentation incluse dans la requête selon la sémantique spécifique de la ressource. Par exemple, POST est utilisé pour les fonctions suivantes (entre autres) :

  • Fournir un bloc de données, tel que les champs saisis dans un formulaire HTML, à un processus de traitement des données ;

...

  • Ajout de données à la ou aux représentations existantes d'une ressource.

Je pense qu'il y a un argument en faveur de l'utilisation de POST pour les mises à jour partielles puisque :

  • conceptuellement, la modification des données n'est pas différente de l'ajout
  • POST est autorisé à utiliser ses propres règles afin que ces règles puissent être une mise à jour partielle
  • cette opération se distingue facilement d'un CREATE par la présence d'un ID

Même si DRF n'aspire pas à une conformité totale, nous avons besoin d'un sérialiseur compatible avec l'opération spec PUT (c'est-à-dire remplaçant l'objet entier). La réponse la plus simple (et clairement la moins déroutante) est d'exiger tous les champs. Il suggère également que PUT doit être non partiel par défaut et que les mises à jour partielles doivent utiliser un mot-clé différent (PATCH ou même POST).

Je pense que je viens de rencontrer mon premier problème PUT lors de la migration de notre application vers drf3.4.x :)

<strong i="6">@cached_property</strong>
    def _writable_fields(self):
        return [
            field for field in self.fields.values()
            if (not field.read_only) or (field.default is not empty)
        ]

Cela fait que mes .validated_data contiennent des données que je n'ai pas fournies dans la requête PUT et que je n'ai pas fournies manuellement dans le sérialiseur. Les valeurs ont été extraites de default= au niveau du sérialiseur. Donc, fondamentalement, tout en étant destiné à mettre à jour un champ particulier, j'écrase également certains de ces champs avec des valeurs par défaut à l'improviste.

Heureux pour moi, j'utilise ModelSerializer personnalisé, donc je peux résoudre le problème facilement.

@pySilver Je ne comprends pas le contenu du dernier commentaire.

@rpkilby "Prenons les requêtes suivantes... Cela ne rompt-il pas l'idempotence"

Non, chaque requête PUT est idempotente en ce sens qu'elle peut être répétée plusieurs fois, ce qui aboutit au même état. Cela ne signifie pas que si une autre partie de l'état a été modifiée entre-temps, elle sera en quelque sorte réinitialisée.

Voici quelques options différentes pour le comportement PUT .

  • Les champs sont obligatoires sauf si required=False ou s'ils ont un default . (Existant)
  • Tous les champs sont requis. (Sémantique plus stricte et plus étroitement alignée de la mise à jour complète _mais_ maladroite car elle est en fait plus stricte que la sémantique de création initiale pour POST)
  • Aucun champ n'est requis (c'est-à-dire qu'il suffit de refléter le comportement PATCH)

Il est clair qu'il n'y a pas de réponse _absolute_ correcte, mais je pense que nous avons le comportement le plus pratique tel qu'il se présente actuellement.

Je pense que certains cas d'utilisation pourraient trouver problématique s'il existe un champ qui n'a pas besoin d'être fourni pour les requêtes POST , mais le fait ensuite pour les requêtes PUT . De plus, PUT-as-create est lui-même une opération valide, donc encore une fois, ce serait étrange si cela avait une sémantique "requise" différente de POST.

Si quelqu'un veut aller de l'avant, je suggérerais _fortement_ de commencer en tant que package tiers, qui implémente différentes classes de sérialiseur de base. Nous pouvons ensuite créer un lien vers cela à partir de la documentation des sérialiseurs. Si le cas est bien présenté, nous pouvons alors envisager d'adapter le comportement par défaut à un moment donné dans le futur.

@tomchristie ce que j'ai pensé à dire :

J'ai un sérialiseur avec un champ language en lecture seule et un modèle :

class Book(models.Model):
      title = models.CharField(max_length=100)
      language = models.ChoiceField(default='en', choices=(('pl', 'Polish'), ('en', 'English'))

class BookUpdateSerialzier(serializers.ModelSerializer):
      # language is readonly, I dont want to let users update that field using this serializer
      language = serializers.ChoiceField(default='en', choices=(('pl', 'Polish'), ('en', 'English'), read_only=True)
      class Meta:
          model = MyModel
          fields = ('title', 'language', )

book = Book(title="To be or 42", language="pl")
book.save()

s = BookUpdateSerialzier(book, data={'title': 'Foobar'}, partial=True)
s.is_valid()
assert 'language' in s.validated_data # !!! 
assert 'pl' == s.validated_data # AssertionError... here :(
  • Je n'ai pas transmis language en requête et je ne m'attends pas à voir cela dans les données validées. Être passé à update écraserait mon instance avec les valeurs par défaut malgré le fait que l'objet a déjà une valeur non par défaut attribuée.
  • Ce serait moins problématique si validated_data['language'] serait book.language dans ce cas.

@pySilver - Oui, cela a été résolu dans https://github.com/tomchristie/django-rest-framework/pull/4346 juste aujourd'hui.

En l'occurrence, vous n'avez pas besoin default= sur le champ du sérialiseur dans l'exemple que vous avez, car vous avez une valeur par défaut sur le ModelField .

@tomchristie Êtes-vous au moins d'accord pour dire que le comportement actuel de PUT n'est pas conforme à la spécification RFC ? Et que mes deux suggestions (exiger tout ou injecter les valeurs par défaut) le feraient?

@tomchristie super nouvelle !

En l'occurrence, vous n'avez pas besoin de default= sur le champ du sérialiseur dans l'exemple que vous avez, car vous avez une valeur par défaut sur le ModelField.

Ouais, je voulais juste le rendre super explicite pour la démo.

Il s'enfonce enfin dans le fait que @tomchristie ne plaide pas pour/contre le comportement du sérialiseur isolément. Je crois que ses objections découlent (implicitement) de l'exigence qu'un seul sérialiseur prenne en charge tous les modes REST. Cela se manifeste dans ses plaintes sur la façon dont un sérialiseur strict affectera un POST. Étant donné que les modes REST sont incompatibles, la solution actuelle est un sérialiseur qui n'est spécifié pour aucun mode unique.

Si c'est la vraie racine de l'objection, prenons-la de front. Comment un seul sérialiseur peut-il fournir un comportement spécifique pour tous les modes REST ? Ma réponse impromptue est que PARTIAL vs. NON-PARTIAL est implémenté au mauvais niveau :

  • Nous avons des sérialiseurs partiels et non partiels. Cette approche signifie que nous avons besoin de plusieurs sérialiseurs pour prendre en charge le comportement de spécification pour tous les modes.
  • Nous avons en fait besoin d'une validation partielle ou non partielle (ou quelque chose dans cette veine). Les différents modes REST doivent demander différents modes de validation au sérialiseur.

Pour séparer les problèmes, un sérialiseur ne doit pas connaître le mode REST, il ne peut donc pas être implémenté en tant que sérialiseur tiers (et je suppose que le sérialiseur n'a même pas accès au mode). Au lieu de cela, DRF doit transmettre une information supplémentaire au sérialiseur (environ replace=True pour PUT ). Le sérialiseur peut décider comment l'implémenter (exiger tous les champs ou injecter les valeurs par défaut).

Évidemment, ce n'est qu'une proposition grossière, mais peut-être qu'elle sortira de l'impasse.

De plus, PUT-as-create est lui-même une opération valide, donc encore une fois, ce serait étrange si cela avait une sémantique "requise" différente de POST.

Je suis d'accord que vous pouvez créer avec un PUT, mais je ne suis pas d'accord que la sémantique soit la même. PUT fonctionne sur une ressource spécifique :

La méthode PUT demande que l'état de la ressource cible soit créé ou remplacé par l'état défini par la représentation contenue dans la charge utile du message de demande.

Je crois donc que la sémantique de création diffère en réalité :

  • PUBLIERà /citizen/ s'attend à ce qu'un SSN (numéro de sécurité sociale) soit généré
  • METTREà /citizen/<SSN> met à jour les données pour un SSN spécifique. S'il n'y a pas de données sur ce SSN, cela entraîne une création.

Étant donné que "l'id" doit être inclus dans l'URI de PUT, vous pouvez le traiter comme requis. En revanche, le "id" est facultatif dans un POST.

Étant donné que "l'id" doit être inclus dans l'URI de PUT, vous pouvez le traiter comme requis. En revanche, le "id" est facultatif dans un POST.

En effet. Je faisais spécifiquement référence au fait que le changement proposé de "faire en sorte que PUT requière strictement _tous_ les champs" signifierait que PUT-as-create aurait un comportement différent de POST-as-create wrt. si les champs sont obligatoires ou non.

Cela dit, je reviens à la valeur d'avoir une option de comportement PUT-is-strict.

(Imposez que _tous_ les champs sont strictement requis dans ce cas, appliquez qu'aucun_ champ n'est requis dans PATCH et utilisez le drapeau required= pour POST)

Comment un seul sérialiseur peut-il fournir un comportement spécifique pour tous les modes REST ?

Nous pouvons différencier la création, la mise à jour et la mise à jour partielle étant donné la façon dont le sérialiseur est instancié, donc je ne pense pas que ce soit un problème.

Vous avez déjà fait remarquer que vous pouvez create en utilisant un PUT ou POST . Ils ont une sémantique différente et des exigences différentes, donc create doit être indépendant du mode REST. Je pense que la distinction se produit vraiment dans le cadre de is_valid . Nous demandons un mode de validation spécifique :

  • pas de validation de présence sur le terrain (PATCH)
  • validation basée sur les drapeaux required (POST)
  • validation stricte de la présence sur le terrain (PUT)

En gardant la logique spécifique aux mots clés hors des opérations CRUD, nous réduisons également le couplage entre le sérialiseur et DRF. Si les modes de validation étaient configurables, ils seraient complètement généralistes (même si nous n'implémentons que 3 cas spécifiques pour nos 3 mots-clés).

Vous faites du bon travail en me défendant cette fonctionnalité, là. :)

Des "modes de validation" différents lors de l'appel de .is_valid() est un bouleversement qui ne va pas voler.

Nous _pourrions_ envisager une contrepartie 'complet=vrai' à l'unité existante 'partial=vrai' kwarg peut-être. Cela cadrerait assez facilement avec la façon dont les choses fonctionnent actuellement et soutiendrait toujours le cas des "champs stricts".

Le sérialiseur est-il le bon endroit pour résoudre ce problème ? Cette exigence est étroitement liée aux mots-clés REST, c'est donc peut-être le bon endroit pour l'appliquer. Pour prendre en charge cette approche, le sérialiseur n'a qu'à exposer une liste de champs qu'il accepte comme entrées,

Plus d'un aparté ... y a-t-il une bonne discussion sur la séparation (répartition) des préoccupations de Django quelque part? J'ai du mal à me limiter aux réponses adaptées à Django car je ne connais pas la réponse à des questions telles que "pourquoi la validation fait-elle partie de la sérialisation". Les documents de sérialisation pour 1.9 ne mentionnent même pas la validation. Et, strictement du premier principe, il semble que :

  1. Le modèle devrait être chargé de valider la cohérence interne et
  2. La "vue" (dans ce cas, le processeur en mode REST) ​​doit être responsable de l'application des règles métier (comme la RFC) liées à cette vue.

Si la responsabilité de la validation disparaît, les sérialiseurs peuvent être 100 % partiels (par défaut) et spécialisés pour les règles d'E/S comme "lecture seule". Un ModelSerializer construit de cette manière prendrait en charge une grande variété de vues.

Le sérialiseur est-il le bon endroit pour résoudre ce problème ?

Oui.

Les documents de sérialisation pour 1.9 ne mentionnent même pas la validation.

La sérialisation intégrée de Django n'est pas utile pour les API Web, elle est vraiment limitée au vidage et au chargement des appareils.

Vous connaissez mieux que moi les hypothèses architecturales de Django et de DRF, je dois donc vous en remettre au comment. Certes, un kwarg init a la bonne sensation... reconfigurant le sérialiseur "à la demande". La seule limitation est qu'ils ne peuvent pas être reconfigurés "à la volée", mais je suppose que les instances sont à usage unique, donc ce n'est pas un problème important.

Je vais dé-étaper cela pour l'instant. Nous pouvons réévaluer après la v3.7

À vous de décider, mais je veux m'assurer que vous comprenez bien qu'il ne s'agit pas d'un ticket pour ajouter la prise en charge de la simultanéité. Le vrai problème est qu'un seul sérialiseur ne peut pas valider correctement à la fois un PUT et un POST dans l'architecture actuelle. La concurrence vient de fournir le "test d'échec".

TL;DR Vous pouvez voir pourquoi ce problème est bloqué en commençant par le correctif proposé par Tom .

En résumé, la solution proposée est de rendre tous les champs obligatoires pour une requête PUT . Il y a (au moins) deux problèmes avec cette approche :

  1. Les sérialiseurs pensent aux actions et non aux méthodes HTTP, il n'y a donc pas de mappage un à un. L'exemple évident est create car il est partagé par PUT et POST . Notez que create-by- PUT est désactivé par défaut, donc le correctif proposé est probablement mieux que rien.
  2. Nous n'avons pas besoin d'exiger tous les champs dans un PUT (un sentiment partagé par #3648, #4703). Si un champ nillable est absent, nous savons qu'il peut être None. Si un champ avec une valeur par défaut est absent, nous savons que nous pouvons utiliser la valeur par défaut. PUT s ont en fait les mêmes exigences de champ (dérivées du modèle) que POST .

Le vrai problème est de savoir comment nous traitons les données manquantes et la proposition de base dans # 3648, # 4703, et ici reste la bonne solution. Nous pouvons prendre en charge tous les modes HTTP (y compris create-by- PUT ) si nous introduisons un concept comme if_missing_use_default . Ma proposition originale le présentait comme un remplacement de partial , mais il est plus facile (et peut-être nécessaire) de le considérer comme un concept orthogonal.

si nous introduisons un concept comme if_missing_use_default.

Rien n'empêche quiconque d'implémenter ceci, ou un strict "exiger tous les champs" en tant que classe de sérialiseur de base, et de l'envelopper en tant que bibliothèque tierce.

Mon opinion est qu'un mode strict "exiger tous les champs" pourrait également être en mesure d'en faire le noyau, c'est un comportement évident très clair, et je peux voir pourquoi cela serait utile.

Je ne suis pas convaincu qu'un "autoriser les champs à être facultatifs, mais tout remplacer, en utilisant les valeurs par défaut du modèle s'ils existent" - Cela semble présenter un comportement très contre-intuitif (par exemple, les champs "created_at", qui se terminent automatiquement jusqu'à se mettre à jour). Si nous voulons un comportement plus strict, nous devrions simplement avoir un comportement plus strict.

Dans tous les cas, la bonne façon d'aborder cela est de le valider en tant que package tiers, puis de mettre à jour nos documents afin que nous puissions y accéder.

Alternativement, si vous êtes convaincu qu'il nous manque un comportement du noyau dont nos utilisateurs ont vraiment besoin, vous pouvez alors faire une demande d'extraction, mettre à jour le comportement et la documentation, afin que nous puissions évaluer les mérites de manière très manière concrète.

Heureux de prendre les demandes d'extraction comme point de départ pour cela, et encore plus heureux d'inclure un package tiers démontrant ce comportement.

venir à la valeur d'avoir une option de comportement PUT-est-strict.

Cela tient toujours. Je pense que nous pourrions considérer cet aspect dans le noyau, si quelqu'un s'en soucie suffisamment pour faire une demande d'extraction dans ce sens. Il faudrait que ce soit un comportement facultatif.

Cela semble présenter un comportement très contre-intuitif (par exemple, les champs "created_at", qui finissent automatiquement par se mettre à jour).

Un champ created_at doit être read_only (ou exclu du sérialiseur). Dans ces deux cas, il serait inchangé (le comportement normal du sérialiseur). Dans le cas contre-intuitif où le champ n'est pas en lecture seule dans le sérialiseur, vous obtiendrez le comportement contre-intuitif de le modifier automatiquement.

Heureux de prendre les demandes d'extraction comme point de départ pour cela, et encore plus heureux d'inclure un package tiers démontrant ce comportement.

Absolument. La variation "utiliser les valeurs par défaut" est un cas idéal pour un package tiers car la modification est un wrapper trivial autour (d'une méthode) du comportement existant et (si vous achetez l'argument par défaut) fonctionne pour tous les sérialiseurs non partiels.

tomchristie a fermé ceci il y a 4 heures

Vous pourriez peut-être envisager d'ajouter une étiquette comme "PR Welcome" ou "3rd Party Plugin" et de laisser les problèmes valides/reconnus comme celui-ci ouverts. Je recherche souvent des problèmes ouverts pour voir si un problème a déjà été signalé et sa progression vers la résolution. Je perçois les problèmes fermés comme "invalides" ou "fixés". Mélanger quelques problèmes "valides mais fermés" dans des milliers de problèmes invalides/résolus n'invite pas à une recherche efficace (même si vous saviez qu'ils pourraient être là).

Vous pourriez peut-être envisager d'ajouter une étiquette comme "PR Welcome" ou "3rd Party Plugin"

Ce serait assez raisonnable, mais nous aimerions que notre outil de suivi des problèmes reflète le travail actif ou réalisable sur le projet lui-même.

Il est vraiment important pour nous d'essayer de garder nos problèmes étroitement circonscrits. Changer les priorités peut signifier que nous choisissons parfois de rouvrir des problèmes que nous avons précédemment fermés. À l'heure actuelle, je pense que cela est tombé du "l'équipe de base veut résoudre ce problème dans un avenir immédiat".

Si cela revient à plusieurs reprises et qu'il n'y a toujours pas de solution tierce, nous réévaluerons peut-être la situation.

laissant des problèmes valides / reconnus comme celui-ci ouverts.

Un peu plus de contexte sur le style de gestion des problèmes - https://www.dabapps.com/blog/sustainable-open-source-management/

Cette page vous a été utile?
0 / 5 - 0 notes