How to do PATCH properly in strongly typed languag

2019-01-31 17:03发布

According to my knowledge:

  • PUT - update object with its whole representation (replace)
  • PATCH - update object with given fields only (update)

I'm using Spring to implement a pretty simple HTTP server. When a user wants to update his data he needs to make a HTTP PATCH to some endpoint (let's say: api/user). His request body is mapped to a DTO via @RequestBody, which looks like this:

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

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

Then I use an object of this class to update (patch) the user object:

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

My doubt is: what if a client (web app for example) would like to clear a property? I would ignore such a change.

How can I know, if a user wanted to clear a property (he sent me null intentionally) or he just doesn't want to change it? It will be null in my object in both cases.

I can see two options here:

  • Agree with the client that if he wants to remove a property he should send me an empty string (but what about dates and other non-string types?)
  • Stop using DTO mapping and use a simple map, which will let me check if a field was given empty or not given at all. What about request body validation then? I use @Valid right now.

How should such cases should be properly handled, in harmony with REST and all good practices?

EDIT:

One could say that PATCH shouldn't be used in such an example and I should use PUT to update my User. But then what about API updates (adding a new property for example)? I would have to version my API (or version user endpoint alone); after every User change, api/v1/user, which accepts PUT with an old request body, api/v2/user which accepts PUT with a new request body, etc. I guess it's not the solution and PATCH exists for a reason.

4条回答
▲ chillily
2楼-- · 2019-01-31 17:28

TL;DR

patchy is a tiny library I've come up with that takes care of the major boilerplate code needed to properly handle PATCH in Spring 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)
    }
}

Simple solution

Since PATCH request represent changes to be applied to the resource we need to model it explicitly.

One way is to use a plain old Map<String,Any?> where every key submitted by a client would represent a change to the corresponding attribute of the resource:

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

The above is very easy to follow however:

  • we do not have validation of the request values

The above can be mitigated by introducing validation annotations on the domain layer objects. While this is very convenient in simple scenarios it tends to be impractical as soon as we introduce conditional validation depending on the state of the domain object or on the role of the principal performing a change. More importantly after the product lives for a while and new validation rules are introduced it's pretty common to still allow for an entity to be update in non user edit contexts. It seems to be more pragmatic to enforce invariants on the domain layer but keep the validation at the edges.

  • will be very similar in potentially many places

This is actually very easy to tackle and in 80% of cases the following would work:

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

Validating the request

Thanks to delegated properties in Kotlin it's very easy to build a wrapper around 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
}

And using Validator interface we can filter out errors related to attributes not present in the request like so:

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

Obviously we can streamline the development with HandlerMethodArgumentResolver which I did below.

Simplest solution

I thought that it would make sense to wrap what've described above into a simple to use library - behold patchy. With patchy one can have a strongly typed request input model along with declarative validations. All you have to do is to import the configuration @Import(PatchyConfiguration::class) and implement PatchyRequest interface in your model.

Further reading

查看更多
Root(大扎)
3楼-- · 2019-01-31 17:31

What I do in some of the applications is create an OptionalInput class which can distinguish whether a value is set or not:

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

Then in your request class:

class PatchUserRequest {

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

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

The properties can be validated by creating a @OptionalInputLength.

Usage is:

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

NOTE: The code is written in groovy but you get the idea. I've used this approach for a few APIs already and it seems to be doing it's job quite well.

查看更多
何必那么认真
4楼-- · 2019-01-31 17:40

As you noted the main problem is that we don't have multiple null-like values to distinguish between explicit and implicit nulls. Since you tagged this question Kotlin I tried to come up with a solution which uses Delegated Properties and Property References. One important constraint is that it works transparently with Jackson which is used by Spring Boot.

The idea is to automatically store the information which fields have been explicitly set to null by using delegated properties.

First define the delegate:

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

This acts like a proxy for the property but stores the null properties in the given MutableSet.

Now in your DTO:

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

Usage is something like this:

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

This works because Jackson explicitly calls user.setName(null) in the second case and omits the call in the first case.

You can of course get a bit more fancy and add some methods to an interface which your DTO should implement.

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

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

Which makes the checks a bit nicer with user.isExplicitNull(User::name).

查看更多
你好瞎i
5楼-- · 2019-01-31 17:41

I have had the same problem, so here are my experiences / solutions.

I would suggest that you implement the patch as it should be, so if

  • a key is present with a value > the value is set
  • a key is present with an empty string > the empty string is set
  • a key is present with a null value > the field is set to null
  • a key is absent > the value for that key is not changed

If you don't do that, you'll soon get an api which is hard to understand.

So I would drop your first option

Agree with the client that if he wants to remove a property he should send me an empty string (but what about dates and other non-string types?)

The second option is actually a good option in my opinion. And that is also what we did (kind of).

I'm not sure if you can make the validation properties work with this option, but then again, should this validation not be on your domain layer? This could throw an exception from the domain which is handled by the rest layer and translated into a bad request.

This is how we did it in one 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;
  }
}
...

The json deserializer will instantiate the PatchUserRequest but it will only call the setter method for fields that are present. So the contains boolean for missing fields will remain false.

In another app we used the same principle but a little different. (I prefer this one)

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

You could also do the same by letting your PatchUserRequest extend Map.

Another option might be to write your own json deserializer, but I haven't tried that myself.

One could say that PATCH shouldn't be used in such example and I should use PUT to update my User.

I don't agree with this. I also use PATCH & PUT the same way as you stated:

  • PUT - update object with its whole representation (replace)
  • PATCH - update object with given fields only (update)
查看更多
登录 后发表回答