Подтвердить что ты не робот

Как правильно делать 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 существует по причине.

4b9b3361

Ответ 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).