Cómo hacer el PARCHE correctamente en lenguajes fuertemente tipeados basados en Spring-ejemplo


Según mi conocimiento:{[16]]}

  • PUT - actualizar el objeto con toda su representación (reemplazar)
  • PATCH - update object with given fields only (update)

Estoy usando Spring para implementar un servidor HTTP bastante simple. Cuando un usuario quiere actualizar sus datos necesita hacer un HTTP PATCH a algún punto final (digamos: api/user). Su cuerpo de solicitud se asigna 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
    ...
}

Entonces utilizo un objeto de esta clase para actualizar (patch) el objeto user:

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

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

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

Puedo ver dos opciones aquí:

  • Acordar con el cliente que si quiere retirar una propiedad debe enviarme una cadena vacía (pero ¿qué pasa con las fechas y otros tipos que no son cadenas?)
  • Deje de usar la asignación 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 entonces? Yo uso @Valid ahora mismo.

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

EDITAR:

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

Author: Peter David Carter, 2016-04-28

4 answers

TL;DR{[15]]}

Patchy es una pequeña biblioteca que se me ha ocurrido que se encarga del código repetitivo mayor necesario para manejar correctamente 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

Desde PATCH request representa los cambios que se aplicarán al recurso que necesitamos para modelarlo explícitamente.

Una forma es usar un antiguo Map<String,Any?> donde cada key enviado por un cliente representaría un cambio en el atributo correspondiente de la 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)
}

Lo anterior es muy fácil de seguir sin embargo:

  • we do not have validation of the request values

Lo anterior se puede mitigar 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 validación condicional dependiendo del estado del objeto de dominio o del papel del principal que realiza un cambio. Más importante aún, después de que el producto viva por un tiempo y se introduzcan nuevas reglas de validación, es bastante común permitir que una entidad se actualice en contextos de edición no de usuario. Parece ser más pragmático hacer cumplir 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 lo siguiente sería trabajo:

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

Validando la solicitud

Gracias a propiedades delegadas en Kotlin es muy fácil construir un wrapper 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 utilizando Validator interfaz podemos filtrar los errores relacionados con atributos no presentes en la solicitud de la siguiente manera:

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 agilizar el desarrollo con HandlerMethodArgumentResolver lo cual hice abajo.

La solución más simple

Pensé que tendría sentido envolver lo que hemos descrito anteriormente en una biblioteca fácil de usar - he aquí patchy. Con patchy uno puede tener un modelo de entrada de solicitud fuertemente escrito 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.

Lectura adicional

 14
Author: miensol,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:32:33

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

Le sugeriría que implemente el parche como debe ser, por lo que si

  • una clave está presente con un valor > el valor se establece
  • una clave está presente con una cadena vacía > la cadena vacía se establece
  • una clave está presente con un valor null > el campo se establece en null
  • una clave está ausente > el valor de esa clave no se cambia

Si no haces eso, pronto obtendrás una api lo cual es difícil de entender.

Así que me gustaría dejar su primera opción

Está de acuerdo con el cliente en que si quiere eliminar una propiedad debe enviarme una cadena vacía (pero ¿qué pasa con las fechas y otros tipos que no son cadenas?)

La segunda opción es en realidad una buena opción en mi opinión. Y eso es también lo que hicimos (tipo de).

No estoy seguro de si puede hacer que las propiedades de validación funcionen con esta opción, pero de nuevo, si esta validación no está activada la capa de dominio? Esto podría lanzar una excepción del dominio que es manejada por la capa rest y traducida 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 instanciará el PatchUserRequest pero solo llamará al método setter para los campos que estén presentes. Por lo tanto, el contenido booleano para los campos faltantes seguirá siendo falso.

En otra aplicación utilizamos el mismo principio, pero un poco diferente. (Prefiero esto uno)

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

Usted también podría hacer lo mismo dejando que su PatchUserRequest extienda el mapa.

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

Se podría decir que el PARCHE no debería usarse en tal ejemplo y yo 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 dijo:

  • Objeto PUT-update con toda su representación (reemplazar)
  • PATCH-update object with given fields only (update)
 8
Author: niekname,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-05-02 06:34:01

Como ha señalado, el principal problema es que no tenemos múltiples valores similares a null para distinguir entre null explícitos e implícitos. Desde que etiquetaste esta pregunta Kotlin he intentado encontrar una solución que use Propiedades Delegadas y Referencias de Propiedades. Una restricción importante es que funciona de forma transparente con Jackson, que es utilizado por Spring Boot.

La idea es almacenar automáticamente la información cuyos campos se han establecido explícitamente en null 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 null 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 obtener un poco más de fantasía y agregar algunos métodos a una interfaz que su DTO debe 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).

 3
Author: Christoph Leiter,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-05-03 18:07:33

Lo que hago en algunas de las aplicaciones es crear una clase OptionalInput que pueda distinguir si un valor está establecido 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 su 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 un @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 se entiende la idea. He utilizado este enfoque para algunas API ya y parece estar haciendo su trabajo bastante bien.

 2
Author: voychris,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-04-04 03:51:17