docs - Cómo hacer PATCH correctamente en lenguajes fuertemente tipados basados en Spring-example
spring form select (4)
De acuerdo a mi conocimiento:
-
PUT
- actualizar objeto con su representación completa (reemplazar) -
PATCH
- actualizar objeto solo con campos determinados (actualización)
Estoy usando Spring para implementar un servidor 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 solicitud 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 objeto de esta clase para actualizar (parchear) el objeto 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 objeto 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 fechas y otros tipos de cadenas?)
- Deje de usar el mapeo DTO y use un mapa simple, que me permitirá verificar si un campo se dio vacío o no se dio en absoluto. ¿Qué pasa con la validación del cuerpo de solicitud? 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 solicitud, api/v2/user
que acepta PUT
con un nuevo cuerpo de solicitud, etc. Supongo que no es la solución y PATCH
existe por alguna razón.
TL; DR
patchy es una pequeña biblioteca que he creado que se ocupa del código repetitivo principal necesario para manejar adecuadamente PATCH
en primavera, 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 solicitud 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 validación de los valores de solicitud
Lo anterior puede mitigarse introduciendo anotaciones de validación en los objetos de capa de dominio. Si bien esto es muy conveniente en escenarios simples, tiende a ser poco práctico tan pronto como introducimos la validación condicional dependiendo del estado del objeto 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 tiempo y se introducen nuevas reglas de validación, es bastante común que aún permita que una entidad se actualice en contextos de edición que no sean de usuario. Parece ser más pragmático imponer invariantes en la capa de dominio, pero mantener la validación 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 solicitud
Gracias a las propiedades delegadas en Kotlin , es muy fácil crear un contenedor alrededor 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 usar la interfaz Validator
, podemos filtrar los errores relacionados con los atributos que no están presentes en la solicitud, 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 patchy . Con parches uno puede tener un modelo de entrada de solicitud fuertemente tipado junto con validaciones declarativas. Todo lo que tiene que hacer es importar la configuración @Import(PatchyConfiguration::class)
e implementar la interfaz PatchyRequest
en su modelo.
Otras lecturas
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 propiedades . 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 propiedades 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 propiedades 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 métodos 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)
.
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 clave está presente con un valor> el valor está establecido
- una clave está presente con una cadena vacía> la cadena vacía está configurada
- una clave está presente con un valor nulo> el campo está configurado como nulo
- no hay una clave> el valor de esa clave 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 fechas y otros tipos 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 menos).
No estoy seguro de si puede hacer que las propiedades de validación funcionen con esta opción, pero, de nuevo, ¿esta validación no debería estar en su capa de dominio? Esto podría arrojar una excepción desde el dominio que es manejado por la capa de descanso y traducido en una solicitud 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 booleano 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 objeto con su representación completa (reemplazar)
- PATCH - actualizar objeto solo con campos determinados (actualización)
Lo que hago en algunas de las aplicaciones es crear una clase OptionalInput
que puede distinguir si un valor está configurado o no:
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
}
}
Luego, en tu clase de solicitud:
class PatchUserRequest {
@OptionalInputLength(max = 100L)
final OptionalInput<String> name = new OptionalInput<>()
void setName(String name) {
this.name.set(name)
}
}
Las propiedades se pueden validar creando una @OptionalInputLength
.
El uso es:
void update(@Valid @RequestBody PatchUserRequest request) {
if (request.name.isSet()) {
// Do the stuff
}
}
NOTA: El código está escrito en groovy
pero entiendes la idea. Ya he usado este enfoque para algunas API y parece estar haciendo bastante bien su trabajo.