Comment faire correctement le PATCH dans les langues fortement dactylographiées basées sur le ressort-exemple

selon ma connaissance:

  • PUT - mise à jour de l'objet avec l'ensemble de sa représentation (remplacer)
  • PATCH - mise à jour de l'objet donné, avec uniquement des champs (mise à jour)

J'utilise Spring pour implémenter un serveur HTTP assez simple. Quand un utilisateur veut mettre à jour ses données, il a besoin de faire un HTTP PATCH à un point final (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 (patch) 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 faire si un client (application web par exemple) à effacer une propriété? Je ne tiendrait pas compte d'un tel changement.

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

je peux voir deux options ici:

  • sont D'accord 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 d'autres types sans chaîne?)
  • arrêter d'utiliser DTO mapping et utiliser une carte simple, qui me permettra de vérifier si un champ a été donné vide ou pas du tout. Qu'en est-il de la validation request body alors? I utiliser @Valid droit maintenant.

comment ces cas devraient-ils être traités correctement, en harmonie avec le repos 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 D'API (ajout d'une nouvelle propriété par exemple)? Je devrais mettre en version mon API (ou version user endpoint seul)); après chaque changement D'utilisateur, api/v1/user , qui accepte PUT avec un ancien corps de requête, api/v2/user qui accepte PUT avec un nouveau corps de requête, etc. Je suppose que ce n'est pas la solution et PATCH existe pour une raison.

44
demandé sur Peter David Carter 2016-04-28 10:11:46

4 réponses

TL; DR

"patchy est une petite bibliothèque que j'ai inventé qui prend soin du code principal de boilerplate nécessaire pour manipuler correctement PATCH au printemps i.e.:

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

Depuis PATCH demande de représenter les changements à appliquer à la ressource, nous avons besoin de modéliser explicitement.

Une façon 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)
}

ci-dessus est très facile à suivre, cependant:

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

ce qui précède peut être atténué par l'introduction d'annotations de validation sur le objets de la couche domaine. Bien que cela soit très pratique dans les scénarios simples, cela tend à être impraticable dès que nous introduisons validation conditionnelle en fonction de l'état de l'objet de domaine ou du rôle du principal exécutant un changement. Plus important encore, après la vie du produit pendant un certain temps et l'introduction de nouvelles règles de validation, il est assez courant de permettre à une entité d'être mise à jour dans des contextes d'édition Non utilisateurs. Il semble être plus pragmatique appliquer les invariants sur la couche de domaine mais garder la validation aux bords .

  • sera très similaire potentiellement de nombreux endroits

c'est en fait très facile à aborder et dans 80% des cas, le suivant fonctionnerait:

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 requête

merci à propriétés déléguées à Kotlin il est très facile de construire une enveloppe 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 interface 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)
            }
        }
    }
}

é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 logique d'envelopper ce qui ont décrit ci - dessus dans une bibliothèque simple à utiliser-voici "patchy . Avec patchy on peut avoir un modèle d'entrée de requête fortement dactylographié avec des validations déclaratives. Tout ce que vous avez à faire est d'importer la configuration @Import(PatchyConfiguration::class) et d'implémenter PatchyRequest interface dans votre modèle.

autres lectures

14
répondu miensol 2017-05-23 12:32:33

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

je suggère que vous implémentiez 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 null > le champ est défini sur null
  • une clé est absente > la valeur pour cette clé n'est pas changé

si vous ne le faites pas, vous obtiendrez bientôt une api qui est difficile à comprendre.

pour que je laisse tomber votre première option

est D'accord 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 d'autres types sans chaîne?)

La deuxième option est en fait une bonne option à mon avis. Et c'est également ce que nous avons fait (genre de).

Je ne suis pas sûr que vous puissiez 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 domaine? Cela pourrait jeter une exception du domaine qui est géré par la couche rest et traduit en une mauvaise requête.

C'est ainsi que nous l'avons fait dans une 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 appellera seulement la méthode setter pour les champs qui sont présents. Donc le booléen contains 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 celui-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 la même chose en laissant votre PatchUserRequest étendre la carte.

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

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

Je ne suis pas d'accord. J'utilise aussi PATCH & PUT de la même façon que vous avez déclaré:

  • MISE à jour de l'objet avec l'ensemble de sa représentation (remplacer)
  • PATCH de mise à jour de l'objet donné, avec uniquement des champs (mise à jour)
8
répondu niekname 2016-05-02 06:34:01

comme vous l'avez noté, le problème principal est que nous n'avons pas de valeurs nulles multiples pour distinguer les nulls explicites et implicites. Puisque vous avez étiqueté 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 information indiquant les champs qui ont été explicitement définis à null en utilisant des propriétés déléguées.

définir 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 donné MutableSet .

maintenant dans votre DTO :

class User {
    val explicitNulls = mutableSetOf<KProperty<*>>() 
    var name: String? by ExpNull(explicitNulls)
}
"151980920 d'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 parce que Jackson explicitement appelle 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 quelques méthodes à une interface que votre DTO devrait 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) .

3
répondu Christoph Leiter 2016-05-03 18:07:33

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

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 .

Usage:

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

NOTE: le code est écrit dans groovy mais vous obtenez l'idée. J'ai utilisé cette approche pour quelques API déjà et il semble faire son travail assez bien.

2
répondu voychris 2018-04-04 03:51:17