64 votes

Comment faire PATCH correctement dans les langages fortement typés basés sur Spring - exemple

D'après mes connaissances :

  • PUT - mettre à jour l'objet avec sa représentation complète (remplacer)
  • PATCH - mettre à jour l'objet avec seulement les champs donnés (update)

J'utilise Spring pour implémenter un serveur HTTP assez simple. Lorsqu'un utilisateur veut mettre à jour ses données, il doit faire une requête HTTP PATCH vers un certain point final (disons : api/user ). Le corps de sa demande est mappé à un DTO via @RequestBody qui ressemble à ceci :

class PatchUserRequest {
    @Email
    @Length(min = 5, max = 50)
    var email: String? = null

    @Length(max = 100)
    var name: String? = null
    ...
}

Ensuite, j'utilise un objet de cette classe pour mettre à jour (patcher) l'objet utilisateur :

fun patchWithRequest(userRequest: PatchUserRequest) {
    if (!userRequest.email.isNullOrEmpty()) {
        email = userRequest.email!!
    }
    if (!userRequest.name.isNullOrEmpty()) {
        name = userRequest.name
    }    
    ...
}

Mon doute est le suivant : que se passe-t-il si un client (une application web par exemple) souhaite effacer une propriété ? Je ne tiendrais pas compte d'une telle modification.

Comment puis-je savoir si un utilisateur a voulu effacer une propriété (il m'a envoyé intentionnellement une propriété nulle) ou s'il ne veut tout simplement pas la modifier ? Dans les deux cas, la propriété sera nulle dans mon objet.

Je vois deux options ici :

  • Convenez avec le client que s'il veut supprimer une propriété, il doit m'envoyer une chaîne de caractères vide (mais qu'en est-il des dates et des autres types de chaînes de caractères ?)
  • Arrêtez d'utiliser le mappage DTO et utilisez une simple carte, qui me permettra de vérifier si un champ a été donné vide ou pas donné du tout. Qu'en est-il de la validation du corps de la requête ? J'utilise @Valid en ce moment.

Comment traiter correctement de tels cas, en harmonie avec REST et toutes les bonnes pratiques ?

EDIT :

On pourrait dire que PATCH ne devrait pas être utilisé dans un tel exemple et je devrais utiliser PUT pour mettre à jour mon utilisateur. Mais qu'en est-il des mises à jour de l'API (ajout d'une nouvelle propriété par exemple) ? Il faudrait que je fasse une version de mon API (ou une version du point d'accès utilisateur seul) après chaque changement d'utilisateur, api/v1/user qui accepte PUT avec un ancien corps de demande, api/v2/user qui accepte PUT avec un nouveau corps de demande, etc. Je suppose que ce n'est pas la solution et PATCH existe pour une raison.

24voto

miensol Points 1889

TL;DR

inégale est une petite bibliothèque que j'ai créée et qui s'occupe de l'essentiel du code standard nécessaire à la gestion correcte de PATCH au printemps, c'est-à-dire :

class Request : PatchyRequest {
    @get:NotBlank
    val name:String? by { _changes }

    override var _changes = mapOf<String,Any?>()
}

@RestController
class PatchingCtrl {
    @RequestMapping("/", method = arrayOf(RequestMethod.PATCH))
    fun update(@Valid request: Request){
        request.applyChangesTo(entity)
    }
}

Une solution simple

Depuis PATCH pour représenter les changements à appliquer à la ressource, nous devons la modéliser explicitement.

Une façon de procéder est d'utiliser un bon vieux Map<String,Any?> où chaque key soumis par un client représenterait un changement de l'attribut correspondant de la ressource :

@RequestMapping("/entity/{id}", method = arrayOf(RequestMethod.PATCH))
fun update(@RequestBody changes:Map<String,Any?>, @PathVariable id:Long) {
    val entity = db.find<Entity>(id)
    changes.forEach { entry ->
        when(entry.key){
            "firstName" -> entity.firstName = entry.value?.toString() 
            "lastName" -> entity.lastName = entry.value?.toString() 
        }
    }
    db.save(entity)
}

Ce qui précède est cependant très facile à suivre :

  • nous n'ont pas de validation des valeurs de la demande

Ce problème peut être atténué en introduisant des annotations de validation sur les objets de la couche de domaine. Bien que cette méthode soit très pratique dans des scénarios simples, elle tend à devenir peu pratique dès que l'on introduit des annotations de validation sur les objets de la couche domaine. validation conditionnelle en fonction de l'état de l'objet de domaine ou du rôle du principal qui effectue un changement. Plus important encore, après que le produit ait vécu un certain temps et que de nouvelles règles de validation aient été introduites, il est assez courant de continuer à permettre la mise à jour d'une entité dans des contextes de modification par des non-utilisateurs. Il semble plus pragmatique de appliquer des invariants à la couche du domaine mais garder la validation sur les bords .

  • seront très similaires dans de nombreux endroits potentiels

Ce problème est en fait très facile à résoudre et, dans 80 % des cas, la méthode suivante fonctionne :

fun Map<String,Any?>.applyTo(entity:Any) {
    val entityEditor = BeanWrapperImpl(entity)
    forEach { entry ->
        if(entityEditor.isWritableProperty(entry.key)){
            entityEditor.setPropertyValue(entry.key, entityEditor.convertForProperty(entry.value, entry.key))
        }
    }
}

Validation de la demande

Merci à propriétés déléguées en Kotlin il est très facile de construire un wrapper autour de Map<String,Any?> :

class NameChangeRequest(val changes: Map<String, Any?> = mapOf()) {
    @get:NotBlank
    val firstName: String? by changes
    @get:NotBlank
    val lastName: String? by changes
}

Et en utilisant Validator nous pouvons filtrer les erreurs liées à des attributs qui ne sont pas présents dans la demande comme suit :

fun filterOutFieldErrorsNotPresentInTheRequest(target:Any, attributesFromRequest: Map<String, Any?>?, source: Errors): BeanPropertyBindingResult {
    val attributes = attributesFromRequest ?: emptyMap()
    return BeanPropertyBindingResult(target, source.objectName).apply {
        source.allErrors.forEach { e ->
            if (e is FieldError) {
                if (attributes.containsKey(e.field)) {
                    addError(e)
                }
            } else {
                addError(e)
            }
        }
    }
}

Il est évident que nous pouvons rationaliser le développement avec HandlerMethodArgumentResolver ce que j'ai fait ci-dessous.

La solution la plus simple

J'ai pensé qu'il serait judicieux d'intégrer ce qui a été décrit ci-dessus dans une bibliothèque simple à utiliser. inégale . Avec inégale on peut avoir un modèle d'entrée de requête fortement typé avec des validations déclaratives. Tout ce que vous avez à faire est d'importer la configuration @Import(PatchyConfiguration::class) et mettre en œuvre PatchyRequest dans votre modèle.

Autres lectures

8voto

niekname Points 109

J'ai eu le même problème, voici donc mes expériences / solutions.

Je vous suggère de mettre en œuvre le correctif comme il se doit, de sorte que si

  • une clé est présente avec une valeur > la valeur est définie
  • une clé est présente avec une chaîne vide > la chaîne vide est définie
  • une clé est présente avec une valeur nulle > le champ est défini comme nul
  • une clé est absente > la valeur de cette clé n'est pas modifiée

Si vous ne le faites pas, vous obtiendrez rapidement une api difficile à comprendre.

Donc je laisserais tomber votre première option

Convenez avec le client que s'il veut supprimer une propriété, il doit m'envoyer une chaîne de caractères vide (mais qu'en est-il des dates et des autres types de chaînes de caractères ?)

La deuxième option est en fait une bonne option à mon avis. Et c'est aussi ce que nous avons fait (en quelque sorte).

Je ne suis pas sûr que vous puissiez faire fonctionner les propriétés de validation avec cette option, mais là encore, cette validation ne devrait-elle pas être sur votre couche de domaine ? Cela pourrait déclencher une exception du domaine qui serait traitée par la couche de repos et traduite en une mauvaise demande.

Voici comment nous avons procédé en une seule application :

class PatchUserRequest {
  private boolean containsName = false;
  private String name;

  private boolean containsEmail = false;
  private String email;

  @Length(max = 100) // haven't tested this, but annotation is allowed on method, thus should work
  void setName(String name) {
    this.containsName = true;
    this.name = name;
  }

  boolean containsName() {
    return containsName;
  }

  String getName() {
    return name;
  }
}
...

Le désérialiseur json va instancier le PatchUserRequest mais il n'appellera la méthode setter que pour les champs qui sont présents. Ainsi, le booléen contains pour les champs manquants restera faux.

Dans une autre application, nous avons utilisé le même principe mais de façon un peu différente. (Je préfère celle-ci)

class PatchUserRequest {
  private static final String NAME_KEY = "name";

  private Map<String, ?> fields = new HashMap<>();;

  @Length(max = 100) // haven't tested this, but annotation is allowed on method, thus should work
  void setName(String name) {
    fields.put(NAME_KEY, name);
  }

  boolean containsName() {
    return fields.containsKey(NAME_KEY);
  }

  String getName() {
    return (String) fields.get(NAME_KEY);
  }
}
...

Vous pouvez également faire de même en laissant votre PatchUserRequest étendre Map.

Une autre option pourrait être d'écrire votre propre deserializer json, mais je n'ai pas essayé moi-même.

On pourrait dire que PATCH ne devrait pas être utilisé dans un tel exemple et que je devrais utiliser PUT pour mettre à jour mon utilisateur.

Je ne suis pas d'accord avec cela. J'utilise également PATCH & PUT de la même manière que vous l'avez dit :

  • PUT - mise à jour de l'objet avec sa représentation complète (remplacement)
  • PATCH - met à jour l'objet avec des champs donnés seulement (update)

4voto

Christoph Leiter Points 4350

Comme vous l'avez noté, le principal problème est que nous ne disposons pas de plusieurs valeurs de type null pour distinguer les nullités explicites des nullités implicites. Puisque vous avez étiqueté cette question Kotlin, j'ai essayé de trouver une solution qui utilise le langage Propriétés déléguées et Références de la propriété . Une contrainte importante est qu'il fonctionne de manière transparente avec Jackson qui est utilisé par Spring Boot.

L'idée est de stocker automatiquement les informations dont les champs ont été explicitement définis comme nuls en utilisant des propriétés déléguées.

Définissez d'abord le délégué :

class ExpNull<R, T>(private val explicitNulls: MutableSet<KProperty<*>>) {
    private var v: T? = null
    operator fun getValue(thisRef: R, property: KProperty<*>) = v
    operator fun setValue(thisRef: R, property: KProperty<*>, value: T) {
        if (value == null) explicitNulls += property
        else explicitNulls -= property
        v = value
    }
}

Cela agit comme un proxy pour la propriété mais stocke les propriétés nulles dans le fichier MutableSet .

Maintenant, dans votre DTO :

class User {
    val explicitNulls = mutableSetOf<KProperty<*>>() 
    var name: String? by ExpNull(explicitNulls)
}

L'utilisation est quelque chose comme ça :

@Test fun `test with missing field`() {
    val json = "{}"

    val user = ObjectMapper().readValue(json, User::class.java)
    assertTrue(user.name == null)
    assertTrue(user.explicitNulls.isEmpty())
}

@Test fun `test with explicit null`() {
    val json = "{\"name\": null}"

    val user = ObjectMapper().readValue(json, User::class.java)
    assertTrue(user.name == null)
    assertEquals(user.explicitNulls, setOf(User::name))
}

Cela fonctionne parce que Jackson appelle explicitement user.setName(null) dans le second cas et omet l'appel dans le premier cas.

Vous pouvez bien sûr aller un peu plus loin et ajouter des méthodes à une interface que votre DTO doit mettre en œuvre.

interface ExpNullable {
    val explicitNulls: Set<KProperty<*>>

    fun isExplicitNull(property: KProperty<*>) = property in explicitNulls
}

Ce qui rend les contrôles un peu plus agréables avec user.isExplicitNull(User::name) .

3voto

voychris Points 51

Ce que je fais dans certaines applications, c'est de créer un OptionalInput qui peut distinguer si une valeur est définie ou non :

class OptionalInput<T> {

    private boolean _isSet = false

    @Valid
    private T value

    void set(T value) {
        this._isSet = true
        this.value = value
    }

    T get() {
        return this.value
    }

    boolean isSet() {
        return this._isSet
    }
}

Puis dans votre classe de demande :

class PatchUserRequest {

    @OptionalInputLength(max = 100L)
    final OptionalInput<String> name = new OptionalInput<>()

    void setName(String name) {
        this.name.set(name)
    }
}

Les propriétés peuvent être validées en créant un @OptionalInputLength .

L'usage est :

void update(@Valid @RequestBody PatchUserRequest request) {
    if (request.name.isSet()) {
        // Do the stuff
    }
}

NOTE : Le code est écrit en groovy mais vous voyez l'idée. J'ai déjà utilisé cette approche pour quelques API et il semble qu'elle fasse très bien son travail.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X