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:
@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.
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)
}
}
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:
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 .
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))
}
}
}
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.
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.
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
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é:
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)
.
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.