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

À ma connaissance:

  • PUT – mettre à jour l’object avec toute sa représentation (remplacer)
  • PATCH – met à jour l’object avec des 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 PATCH HTTP vers un noeud final (par exemple, api/user ). Son corps de requête est mappé sur un DTO via @RequestBody , qui ressemble à ceci:

 class PatchUserRequest { @Email @Length(min = 5, max = 50) var email: Ssortingng? = null @Length(max = 100) var name: Ssortingng? = null ... } 

Ensuite, j’utilise un object de cette classe pour mettre à jour (patch) l’object 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 (application Web par exemple) souhaite effacer une propriété? J’ignorerais un tel changement.

Comment puis-je savoir si un utilisateur souhaite effacer une propriété (il m’a envoyé de manière intentionnelle nulle) ou s’il ne veut tout simplement pas le modifier? Il sera nul dans mon object dans les deux cas.

Je peux voir deux options ici:

  • D’accord avec le client, 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 chaînes?)
  • Arrêtez d’utiliser le mappage DTO et utilisez une carte simple, ce qui me permettra de vérifier si un champ a été donné vide ou pas du tout. Qu’en est-il de la validation du corps de requête alors? J’utilise @Valid maintenant.

Comment ces cas devraient-ils être correctement traités, 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 que je devrais utiliser PUT pour mettre à jour mon utilisateur. Mais alors qu’en est-il des mises à jour de l’API (ajout d’une nouvelle propriété par exemple)? Je devrais mettre à jour mon API (ou le sharepoint terminaison utilisateur de la version seule); 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 que PATCH existe pour une raison.

TL; DR

patchy est une minuscule bibliothèque que j’ai créée et qui prend en charge le code principal nécessaire pour gérer correctement PATCH au spring, à savoir:

 class Request : PatchyRequest { @get:NotBlank val name:Ssortingng? by { _changes } override var _changes = mapOf() } @RestController class PatchingCtrl { @RequestMapping("/", method = arrayOf(RequestMethod.PATCH)) fun update(@Valid request: Request){ request.applyChangesTo(entity) } } 

Solution simple

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

L’une des méthodes consiste à utiliser une ancienne Map laquelle chaque key soumise par un client représenterait une modification de l’atsortingbut correspondant de la ressource:

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

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

  • nous n’avons pas de validation des valeurs de la demande

Ce qui précède peut être atténué en introduisant des annotations de validation sur les objects de la couche de 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 une validation conditionnelle en fonction de l’état de l’object du domaine ou du rôle du principal effectuant une modification. Plus important encore, après que le produit a vécu pendant un certain temps et que de nouvelles règles de validation ont été introduites, il est assez courant de permettre à une entité d’être mise à jour dans des contextes de modification non utilisateur. Il semble plus pragmatique d’ imposer des invariants sur la couche domaine mais de conserver la validation sur les bords .

  • sera très similaire dans de nombreux endroits potentiellement

C’est en fait très facile à aborder et dans 80% des cas, cela fonctionnerait comme suit:

 fun Map.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 aux propriétés déléguées de Kotlin, il est très facile de créer un wrapper autour de Map :

 class NameChangeRequest(val changes: Map = mapOf()) { @get:NotBlank val firstName: Ssortingng? by changes @get:NotBlank val lastName: Ssortingng? by changes } 

Et en utilisant l’interface Validator , nous pouvons filtrer les erreurs liées aux atsortingbuts non présents dans la requête, comme ceci:

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

Evidemment, nous pouvons rationaliser le développement avec HandlerMethodArgumentResolver que j’ai fait ci-dessous.

Solution la plus simple

Je pensais qu’il serait logique d’envelopper ce qui a été décrit ci-dessus dans une bibliothèque simple à utiliser: Avec patchy, 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 d’implémenter l’interface PatchyRequest dans votre modèle.

Lectures complémentaires

  • Synchronisation du spring
  • fge / json-patch

J’ai eu le même problème, alors 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 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 aurez bientôt un api difficile à comprendre.

Donc, je laisserais tomber votre première option

D’accord avec le client, 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 chaînes?)

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 encore une fois, cette validation ne doit-elle pas se trouver sur la couche de votre domaine? Cela pourrait provoquer une exception du domaine qui est géré par la couche restante et traduit en une requête incorrecte.

Voici comment nous l’avons fait en une seule application:

 class PatchUserRequest { private boolean containsName = false; private Ssortingng name; private boolean containsEmail = false; private Ssortingng email; @Length(max = 100) // haven't tested this, but annotation is allowed on method, thus should work void setName(Ssortingng name) { this.containsName = true; this.name = name; } boolean containsName() { return containsName; } Ssortingng getName() { return name; } } ... 

Le désérialiseur json instanciera PatchUserRequest, mais il n’appellera que la méthode setter pour les champs présents. Le booléen contient donc les champs manquants qui restront 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 Ssortingng NAME_KEY = "name"; private Map 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 consister à é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 aussi PATCH & PUT de la même manière que vous avez déclaré:

  • PUT – mettre à jour l’object avec toute sa représentation (remplacer)
  • PATCH – met à jour l’object avec des champs donnés uniquement (mise à jour)

Comme vous l’avez noté, le problème principal est que nous n’avons pas plusieurs valeurs nulles pour distinguer les valeurs null explicites et implicites. Depuis que vous avez tagué cette question, Kotlin J’ai essayé de trouver une solution qui utilise les propriétés déléguées et les références de propriété . Une contrainte importante est que cela 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(private val explicitNulls: MutableSet>) { 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 NULL dans le MutableSet donné.

Maintenant dans votre DTO :

 class User { val explicitNulls = mutableSetOf>() var name: Ssortingng? 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 obtenir un peu plus de fantaisie et append des méthodes à une interface que votre DTO devrait implémenter.

 interface ExpNullable { val explicitNulls: Set> fun isExplicitNull(property: KProperty<*>) = property in explicitNulls } 

Ce qui rend les vérifications un peu plus user.isExplicitNull(User::name) avec user.isExplicitNull(User::name) .

Ce que je fais dans certaines applications est de créer une classe OptionalInput capable de distinguer si une valeur est définie ou non:

 class OptionalInput { 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 name = new OptionalInput<>() void setName(String name) { this.name.set(name) } } 

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

Utilisation est:

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

NOTE: Le code est écrit en groovy mais vous avez l’idée. J’ai déjà utilisé cette approche pour quelques API et cela semble bien fonctionner.