Cómo hacer PATCH correctamente en lenguajes fuertemente tipados basados ​​en Spring – example

De acuerdo a mi conocimiento:

  • PUT – actualizar object con su representación completa (replace)
  • PATCH – actualizar object solo con campos determinados (actualización)

Estoy usando Spring para implementar un server HTTP bastante simple. Cuando un usuario quiere actualizar sus datos, necesita hacer un PATCH HTTP a un punto final (digamos: api/user ). Su cuerpo de request está mapeado a un DTO a través de @RequestBody , que se ve así:

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

Luego uso un object de esta class para actualizar (parchear) el object del usuario:

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

Mi duda es: ¿qué sucede si un cliente (aplicación web, por ejemplo) quiere borrar una propiedad? Yo ignoraría tal cambio.

¿Cómo puedo saber si un usuario desea borrar una propiedad (me envió nulo intencionalmente) o simplemente no quiere cambiarla? Será nulo en mi object en ambos casos.

Puedo ver dos opciones aquí:

  • Acuerde con el cliente que si quiere eliminar una propiedad, debe enviarme una cadena vacía (¿pero qué ocurre con las dates y otros types de cadenas?)
  • Deje de usar el mapeo DTO y use un map simple, que me permitirá verificar si un campo se dio vacío o no se dio en absoluto. ¿Qué pasa con la validation del cuerpo de request? Yo uso @Valid ahora mismo.

¿Cómo deben manejarse adecuadamente tales casos, en armonía con REST y todas las buenas prácticas?

EDITAR:

Se podría decir que PATCH no debería usarse en ese ejemplo y debería usar PUT para actualizar a mi Usuario. Pero ¿qué pasa con las actualizaciones de la API (agregando una nueva propiedad, por ejemplo)? Tendría que versionar mi API (o el usuario final de la versión solo); después de cada cambio de usuario, api/v1/user , que acepta PUT con un antiguo cuerpo de request, api/v2/user que acepta PUT con un nuevo cuerpo de request, etc. Supongo que no es la solución y PATCH existe por alguna razón.

    TL; DR

    desigual es una pequeña biblioteca que he creado que se ocupa del código repetitivo principal necesario para manejar adecuadamente PATCH en spring, es decir:

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

    Solución simple

    Dado que la request PATCH representa los cambios que se aplicarán al recurso, necesitamos modelarlo explícitamente.

    Una forma es usar un antiguo Map<String,Any?> simple Map<String,Any?> Donde cada key enviada por un cliente representaría un cambio en el atributo correspondiente del recurso:

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

    Sin embargo, lo anterior es muy fácil de seguir:

    • no tenemos validation de los valores de request

    Lo anterior puede mitigarse introduciendo annotations de validation en los objects de capa de dominio. Si bien esto es muy conveniente en escenarios simples, tiende a ser poco práctico tan pronto como introducimos la validation condicional dependiendo del estado del object del dominio o del papel del director que realiza un cambio. Más importante aún, después de que el producto vive por un time y se introducen nuevas reglas de validation, es bastante común que aún permita que una entidad se actualice en contexts de edición que no sean de usuario. Parece ser más pragmático imponer invariantes en la capa de dominio, pero mantener la validation en los bordes .

    • será muy similar en potencialmente muchos lugares

    Esto es realmente muy fácil de abordar y en el 80% de los casos, funcionaría lo siguiente:

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

    Validar la request

    Gracias a las properties delegadas en Kotlin , es muy fácil crear un contenedor alnetworkingedor 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 } 

    Y al utilizar la interfaz de Validator , podemos filtrar los errores relacionados con los attributes que no están presentes en la request, como por ejemplo:

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

    Obviamente, podemos simplificar el desarrollo con HandlerMethodArgumentResolver que hice a continuación.

    La solución más simple

    Pensé que tendría sentido envolver lo que describí anteriormente en una biblioteca fácil de usar, he aquí una plot . Con parches uno puede tener un model de input de request fuertemente tipado junto con validaciones declarativas. Todo lo que tiene que hacer es importar la configuration @Import(PatchyConfiguration::class) e implementar la interfaz PatchyRequest en su model.

    Otras lecturas

    • Sincronización de spring
    • parche fge / json

    He tenido el mismo problema, así que aquí están mis experiencias / soluciones.

    Sugeriría que implemente el parche como debería ser, así que si

    • una key está presente con un valor> el valor está establecido
    • una key está presente con una cadena vacía> la cadena vacía está configurada
    • una key está presente con un valor nulo> el campo está configurado como nulo
    • no hay una key> el valor de esa key no se cambia

    Si no haces eso, pronto obtendrás una API que es difícil de entender.

    Entonces soltaría tu primera opción

    Acuerde con el cliente que si quiere eliminar una propiedad, debe enviarme una cadena vacía (¿pero qué ocurre con las dates y otros types de cadenas?)

    La segunda opción es en realidad una buena opción en mi opinión. Y eso es también lo que hicimos (más o less).

    No estoy seguro de si puede hacer que las properties de validation funcionen con esta opción, pero, de nuevo, ¿esta validation no debería estar en su capa de dominio? Esto podría arrojar una exception desde el dominio que es manejado por la capa de descanso y traducido en una request incorrecta.

    Así es como lo hicimos en una aplicación:

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

    El deserializador json creará una instancia de PatchUserRequest, pero solo llamará al método setter para los campos que están presentes. Entonces el contenido boolean para los campos faltantes permanecerá falso.

    En otra aplicación utilizamos el mismo principio pero un poco diferente. (Prefiero éste)

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

    También podría hacer lo mismo dejando que PatchUserRequest extienda Map.

    Otra opción podría ser escribir tu propio deserializador json, pero yo mismo no lo he intentado.

    Se podría decir que PATCH no debería usarse en ese ejemplo y debería usar PUT para actualizar a mi Usuario.

    No estoy de acuerdo con esto También uso PATCH & PUT de la misma manera que usted indicó:

    • PUT – actualizar object con su representación completa (replace)
    • PATCH – actualizar object solo con campos determinados (actualización)

    Como observó, el problema principal es que no tenemos valores nulos múltiples para distinguir entre nulos explícitos e implícitos. Desde que etiquetó esta pregunta, Kotlin intenté encontrar una solución que utilice Propiedades delegadas y Referencias de properties . Una restricción importante es que funciona de manera transparente con Jackson, que es utilizada por Spring Boot.

    La idea es almacenar automáticamente la información cuyos campos se han establecido explícitamente como nulos mediante el uso de properties delegadas.

    Primero defina el delegado:

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

    Esto actúa como un proxy para la propiedad pero almacena las properties nulas en el MutableSet dado.

    Ahora en tu DTO :

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

    El uso es algo como esto:

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

    Esto funciona porque Jackson llama explícitamente a user.setName(null) en el segundo caso y omite la llamada en el primer caso.

    Por supuesto, puede hacerse un poco más elegante y agregar algunos methods a una interfaz que su DTO debería implementar.

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

    Lo que hace que las comprobaciones sean un poco más agradables con user.isExplicitNull(User::name) .