web-dev-qa-db-fra.com

Comment faire correctement PATCH dans des langues fortement typées basées sur Spring - exemple

Selon mes connaissances:

  • PUT - mettre à jour l'objet avec toute sa représentation (remplacer)
  • PATCH - mise à jour de l'objet avec les champs donnés uniquement (mise à jour)

J'utilise Spring pour implémenter un serveur HTTP assez simple. Lorsqu'un utilisateur souhaite mettre à jour ses données, il doit créer un HTTP PATCH vers un point de terminaison (disons: api/user). Son corps de 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: que se passe-t-il si un client (application web par exemple) souhaite effacer une propriété? J'ignorerais un tel changement.

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

Je peux voir deux options ici:

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

Comment ces cas doivent-ils être traités correctement, en harmonie avec REST et toutes les bonnes pratiques?

MODIFIER:

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 changements de modèle (par exemple, l'ajout d'une nouvelle propriété)? Je devrais mettre à jour mon API (ou le point de terminaison utilisateur seul) après chaque changement d'utilisateur. Par exemple. J'aurais api/v1/user endpoint qui accepte PUT avec un ancien corps de requête, et api/v2/user endpoint qui accepte PUT avec un nouveau corps de requête. Je suppose que ce n'est pas la solution et PATCH existe pour une raison.

49
KlimczakM

TL; DR

inégal est une petite bibliothèque que j'ai inventée qui prend en charge le code principal standard nécessaire pour gérer correctement 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)
    }
}

Solution simple

Puisque PATCH la requête représente les changements à appliquer à la ressource, nous devons la modéliser explicitement.

Une façon consiste à utiliser un ancien Map<String,Any?> Simple où chaque key soumis par un client représenterait une modification 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'avons pas de validation des valeurs de demande

Ce qui précède peut être atténué en introduisant des annotations de validation sur les objets de la couche domaine. Bien que cela soit très pratique dans des scénarios simples, cela a tendance à ne pas être pratique dès que nous introduisons validation conditionnelle selon l'état de l'objet de domaine ou le rôle du principal effectuant un changement. Plus important encore, après la durée de vie du produit et l'introduction de nouvelles règles de validation, il est assez courant de permettre à une entité d'être mise à jour dans des contextes de modification non utilisateur. Il semble plus pragmatique de appliquer des invariants sur la couche domaine mais garder la validation sur les bords .

  • sera très similaire dans de nombreux endroits

C'est en fait très facile à résoudre et dans 80% des cas, les éléments suivants fonctionneraient:

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

Valider la demande

Grâce à propriétés déléguées dans 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 l'interface Validator , nous pouvons filtrer les erreurs liées aux attributs non présents dans la demande comme ceci:

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

Évidemment, nous pouvons rationaliser le développement avec HandlerMethodArgumentResolver que j'ai fait ci-dessous.

Solution la plus simple

J'ai pensé qu'il serait judicieux d'envelopper ce qui a été décrit ci-dessus dans une bibliothèque simple à utiliser - voici inégale . Avec irrégulier on peut avoir un modèle d'entrée de requête fortement typé avec des validations déclaratives. Il vous suffit d'importer la configuration @Import(PatchyConfiguration::class) et d'implémenter l'interface PatchyRequest dans votre modèle.

Lectures complémentaires

16
miensol

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

Je vous suggère d'implémenter le patch comme il se doit, donc 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 sur null
  • une clé est absente> la valeur de cette clé n'est pas modifiée

Si vous ne le faites pas, vous obtiendrez bientôt une API difficile à comprendre.

Je laisserais donc tomber votre première option

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

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 sais pas si vous pouvez faire fonctionner les propriétés de validation avec cette option, mais encore une fois, cette validation ne devrait-elle pas être sur votre couche de domaine? Cela pourrait lever une exception du domaine qui est gérée par la couche restante et traduite en une mauvaise requête.

Voici comment nous l'avons fait dans 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 instanciera la PatchUserRequest mais il n'appellera la méthode setter que pour les champs qui sont présents. Ainsi, le booléen contient pour les champs manquants restera faux.

Dans une autre application, nous avons utilisé le même principe mais un peu différent. (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 la carte.

Une autre option pourrait être d'écrire votre propre désérialiseur json, mais je ne l'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 ça. J'utilise également PATCH & PUT de la même manière que vous l'avez indiqué:

  • PUT - mettre à jour l'objet avec toute sa représentation (remplacer)
  • PATCH - mettre à jour l'objet avec les champs donnés uniquement (mise à jour)
8
niekname

Comme vous l'avez noté, le principal problème est que nous n'avons pas plusieurs valeurs de type nul pour distinguer les nulls explicites et implicites. Depuis que vous avez tagué cette question Kotlin, j'ai essayé de trouver une solution qui utilise Propriétés déléguées et Références de 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 sur null 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 MutableSet donné.

Maintenant dans votre DTO:

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

L'utilisation est quelque chose comme ceci:

@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 car Jackson appelle explicitement user.setName(null) dans le deuxième cas et omet l'appel dans le premier cas.

Vous pouvez bien sûr obtenir un peu plus de fantaisie et ajouter des méthodes à une interface que votre DTO devrait implémenter.

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

4

Ce que je fais dans certaines applications est de créer une classe 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
    }
}

Ensuite, 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'utilisation est:

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

REMARQUE: le code est écrit en groovy mais vous avez l'idée. J'ai déjà utilisé cette approche pour quelques API et il semble que cela fonctionne plutôt bien.

2
voychris