Как правильно делать PATCH в строго типизированных языках на основе Spring - пример
По моим сведениям:
-
PUT
- обновить объект со всем его представлением (заменить)
-
PATCH
- обновлять объект только с заданными полями (обновление)
Я использую Spring для реализации довольно простого HTTP-сервера. Когда пользователь хочет обновить свои данные, ему нужно сделать HTTP PATCH
до некоторой конечной точки (пусть говорят: api/user
). Его тело запроса сопоставляется с DTO через @RequestBody
, который выглядит следующим образом:
class PatchUserRequest {
@Email
@Length(min = 5, max = 50)
var email: String? = null
@Length(max = 100)
var name: String? = null
...
}
Затем я использую объект этого класса для обновления (исправления) объекта пользователя:
fun patchWithRequest(userRequest: PatchUserRequest) {
if (!userRequest.email.isNullOrEmpty()) {
email = userRequest.email!!
}
if (!userRequest.name.isNullOrEmpty()) {
name = userRequest.name
}
...
}
Я сомневаюсь, что если клиент (например, веб-приложение) хотел бы очистить свойство? Я бы проигнорировал такое изменение.
Как я могу узнать, хочет ли пользователь очистить свойство (он отправил меня пустым намеренно) или он просто не хочет его менять? В обоих случаях он будет null в моем объекте.
Здесь я вижу два варианта:
- Согласитесь с клиентом, что если он хочет удалить свойство, он должен отправить мне пустую строку (но как насчет дат и других нестроковых типов?)
- Остановить использование сопоставления DTO и использовать простую карту, которая позволит мне проверить, было ли поле пустым или вообще не указано. Как насчет проверки тела запроса? Я использую
@Valid
прямо сейчас.
Как следует надлежащим образом обрабатывать такие случаи в соответствии с REST и всеми хорошими практиками?
EDIT:
Можно сказать, что PATCH
не следует использовать в таком примере, и я должен использовать PUT
для обновления моего пользователя. Но тогда, что о обновлениях API (например, добавление нового свойства)? Я должен был бы изменить свой API (или конечную точку пользователя версии самостоятельно); после каждого изменения пользователя api/v1/user
, который принимает PUT
со старым телом запроса, api/v2/user
, который принимает PUT
с новым телом запроса и т.д. Я думаю, что это не решение и PATCH
существует по причине.
Ответы
Ответ 1
TL; DR
patchy - это крошечная библиотека, которую я придумал, которая заботится о главном шаблоне код, необходимый для правильной обработки PATCH
в Spring, т.е.:
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)
}
}
Простое решение
Так как PATCH
запрос представляет собой изменения, которые необходимо применить к ресурсу, который нам нужен, чтобы его явно моделировать.
Один из способов - использовать простой старый Map<String,Any?>
, где каждый key
, представленный клиентом, будет представлять собой изменение соответствующего атрибута ресурса:
@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)
}
Вышеизложенное очень легко выполнить, однако:
- мы не проверяем значения значений запроса
Вышеупомянутое можно смягчить, введя аннотации проверки целостности объектов домена. Хотя это очень удобно в простых сценариях, оно имеет тенденцию быть непрактичным, как только мы вносим условную проверку в зависимости от состояния объекта домена или роли главного исполнителя изменение. Что еще более важно после того, как продукт живет какое-то время, и введены новые правила проверки правильности, это довольно часто, когда все еще разрешено обновлять сущность в контекстах редактирования, не относящихся к пользователю. Кажется, что более прагматично применять инварианты на уровне домена, но сохраните проверку по краям.
- будет очень похож на потенциально много мест.
На самом деле это очень легко решить, и в 80% случаев будет работать следующее:
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))
}
}
}
Проверка запроса
Благодаря делегированные свойства в Kotlin очень легко создать обертку вокруг 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
}
И используя Validator
, мы можем отфильтровать ошибки, связанные с атрибутами, отсутствующими в запросе:
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)
}
}
}
}
Очевидно, что мы можем оптимизировать разработку HandlerMethodArgumentResolver
, которую я сделал ниже.
Простейшее решение
Я подумал, что имеет смысл обернуть то, что описано выше, в простое в использовании библиотеку - вот patchy. С patchy можно иметь сильно типизированную модель ввода запроса вместе с декларативными проверками. Все, что вам нужно сделать, это импортировать конфигурацию @Import(PatchyConfiguration::class)
и реализовать интерфейс PatchyRequest
в вашей модели.
Дальнейшее чтение
Ответ 2
У меня была такая же проблема, так вот мои опыты/решения.
Я бы предположил, что вы реализуете патч, как и должно быть, поэтому, если
- присутствует ключ со значением > значение установлено
- Ключ присутствует с пустой строкой > пустая строка установлена
- Ключ присутствует с нулевым значением > для поля установлено значение null
- отсутствует ключ > значение для этого ключа не изменяется
Если вы этого не сделаете, вы скоро получите api, который трудно понять.
Итак, я бы сбросил ваш первый вариант
Согласитесь с клиентом, что, если он хочет удалить свойство, он должен отправить мне пустую строку (но как насчет дат и других нестроковых типов?)
Второй вариант - на самом деле хороший вариант, на мой взгляд. И это также то, что мы сделали (вид).
Я не уверен, что вы можете заставить свойства проверки работать с этой опцией, но опять же, должна ли эта проверка не быть на вашем домене? Это может вызвать исключение из домена, который обрабатывается остальным слоем и преобразовывается в неверный запрос.
Так мы сделали это в одном приложении:
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;
}
}
...
json deserializer создаст экземпляр PatchUserRequest, но он вызовет только метод setter для присутствующих полей. Таким образом, boolean для отсутствующих полей останется ложным.
В другом приложении мы использовали тот же принцип, но немного другой. (Я предпочитаю этот)
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);
}
}
...
Вы также можете сделать то же самое, разрешив вашему PatchUserRequest расширять карту.
Другой вариант может заключаться в том, чтобы написать собственный десериализатор json, но я сам этого не пробовал.
Можно сказать, что PATCH не следует использовать в таком примере, и я должен использовать PUT для обновления моего пользователя.
Я не согласен с этим. Я также использую PATCH и PUT так же, как вы заявили:
- PUT - обновить объект со всем его представлением (заменить)
- PATCH - обновлять объект только с заданными полями (обновление)
Ответ 3
Как вы заметили, основная проблема заключается в том, что у нас нет нескольких нулевых значений для различения явных и неявных нулей. Поскольку вы отметили этот вопрос Котлин, я попытался найти решение, в котором делегированные свойства и Ссылки на свойства. Одним из важных ограничений является то, что он работает прозрачно с Jackson, который используется Spring Boot.
Идея состоит в том, чтобы автоматически сохранять информацию, поля которой явно установлены в null с использованием делегированных свойств.
Сначала определите делегата:
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
}
}
Это действует как прокси для свойства, но сохраняет нулевые свойства в заданном MutableSet
.
Теперь в вашем DTO
:
class User {
val explicitNulls = mutableSetOf<KProperty<*>>()
var name: String? by ExpNull(explicitNulls)
}
Использование выглядит примерно так:
@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))
}
Это работает, потому что Джексон явно вызывает user.setName(null)
во втором случае и опускает вызов в первом случае.
Вы можете, конечно, получить немного больше фантазии и добавить некоторые методы в интерфейс, который должен реализовать ваш DTO.
interface ExpNullable {
val explicitNulls: Set<KProperty<*>>
fun isExplicitNull(property: KProperty<*>) = property in explicitNulls
}
Что делает проверки немного приятнее с user.isExplicitNull(User::name)
.