How to do PATCH properly in strongly typed languages based on Spring - example

后端 未结 4 1324
甜味超标
甜味超标 2020-12-23 16:10

According to my knowledge:

  • PUT - update object with its whole representation (replace)
  • PATCH - update object with given fiel
4条回答
  •  陌清茗
    陌清茗 (楼主)
    2020-12-23 16:57

    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()
    }
    
    @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 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, @PathVariable id:Long) {
        val entity = db.find(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.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:

    class NameChangeRequest(val changes: Map = 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?, 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

    • Spring Sync
    • fge/json-patch

提交回复
热议问题