Обработка запросов PATCH с помощью Akka HTTP и circe для полей, допускающих значение NULL

Есть ли общий подход к обработке запросов PATCH в REST API с использованием библиотеки circe? По умолчанию circe не позволяет декодировать частичный JSON только с частью указанных полей, т.е. требует, чтобы все поля были установлены. Вы можете использовать конфигурацию withDefaults, но будет невозможно узнать, является ли полученное вами поле null или просто не указано. Вот упрощенный пример возможного решения. Он использует Left[Unit] в качестве значения для обработки случаев, когда поле вообще не указано:

# possible payloads
{
  "firstName": "Foo",
  "lastName": "Bar"
}
{
  "firstName": "Foo"
}
{
  "firstName": null
}
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import io.circe.generic.auto._
import io.circe.{Decoder, HCursor}

case class User(firstName: Option[String], lastName: String)

// In PATCH request only 1 field can be specified. The rest could be omitted. Left represents `not specified`
case class PatchUserRequest(firstName: Either[Unit, Option[String]], lastName: Either[Unit, String])
object PatchUserRequest {
  implicit val decode: Decoder[PatchUserRequest] = new Decoder[PatchUserRequest] {
    final def apply(c: HCursor): Decoder.Result[PatchUserRequest] =
      for {
        // Here we handle `no field specified` error cases as Left[Unit]
        foo <- c.downField("firstName").as[Option[String]] match {
          case Left(noFieldSpecified) => Right(Left(()))
          case Right(result) => Right(Right(result))
        }
        bar <- c.downField("lastName").as[String] match {
          case Left(noFieldSpecified) => Right(Left(()))
          case Right(result) => Right(Right(result))
        }
      } yield PatchUserRequest(foo, bar)
  }
}

object Apis extends Directives {
 var user = User("Foo", "Bar")

 val create = path("user")(post(entity(as[User])(newUser => user = newUser)))
 val patch = path("user")(patch(entity(as[PatchUserRequest])(patchRequest => patch(patchRequest))))


// If field is specified - update the record, ignore otherwise
def patch(request: PatchUserRequest) {
  request.firstName.foreach(newFirstName => user = user.copy(firstName = newFirstName)
  request.lastName.foreach(newlastName => user = user.copy(lastName = newlastName)
}

Есть ли лучший способ обрабатывать запросы PATCH (с полями, допускающими значение NULL) вместо написания пользовательского кодека, который возвращается к no value, если поле не указано в полезной нагрузке JSON? Спасибо


person Alexey Sirenko    schedule 24.01.2020    source источник


Ответы (2)


Вот как я это сделал:

import io.circe.{Decoder, Encoder, FailedCursor, Json}
import java.util.UUID

sealed trait UpdateOrDelete[+A]

case object Missing                      extends UpdateOrDelete[Nothing]
case object Delete                       extends UpdateOrDelete[Nothing]
final case class UpdateWith[A](value: A) extends UpdateOrDelete[A]

object UpdateOrDelete {
  implicit def decodeUpdateOrDelete[A](
    implicit decodeA: Decoder[A]
  ): Decoder[UpdateOrDelete[A]] = Decoder.withReattempt {
    // We're trying to decode a field but it's missing.
    case c: FailedCursor if !c.incorrectFocus => Right(Missing)
    case c => Decoder.decodeOption[A].tryDecode(c).map {
      case Some(a) => UpdateWith(a)
      case None    => Delete
    }
  }

  // Random UUID to _definitely_ avoid collisions
  private[this] val marker: String   = s"$$marker-${UUID.randomUUID()}-marker$$"
  private[this] val markerJson: Json = Json.fromString(marker)

  implicit def encodeUpdateOrDelete[A](
    implicit encodeA: Encoder[A]
  ): Encoder[UpdateOrDelete[A]] = Encoder.instance {
    case UpdateWith(a) => encodeA(a)
    case Delete        => Json.Null
    case Missing       => markerJson
  }

  def filterMarkers[A](encoder: Encoder.AsObject[A]): Encoder.AsObject[A] =
    encoder.mapJsonObject(
      _.filter {
        case (_, value) => value != markerJson
      }
    )
}

А потом:

import io.circe.generic.semiauto._

case class UserPatch(
  id: Long,
  firstName: UpdateOrDelete[String],
  lastName: UpdateOrDelete[String]
)

object UserPatch {
  implicit val decodeUserPatch: Decoder[UserPatch] = deriveDecoder
  implicit val encodeUserPatch: Encoder.AsObject[UserPatch] =
    UpdateOrDelete.filterMarkers(deriveEncoder[UserPatch])
}

А потом:

scala> import io.circe.syntax._
import io.circe.syntax._

scala> UserPatch(101, Missing, Delete).asJson
res0: io.circe.Json =
{
  "id" : 101,
  "lastName" : null
}

scala> UserPatch(101, UpdateWith("Foo"), Missing).asJson
res1: io.circe.Json =
{
  "id" : 101,
  "firstName" : "Foo"
}

scala> io.circe.jawn.decode[UserPatch]("""{"id":1}""")
res2: Either[io.circe.Error,UserPatch] = Right(UserPatch(1,Missing,Missing))

Этот подход позволяет вам более четко моделировать намерение, сохраняя при этом возможность использовать родовое происхождение, чтобы избежать большей части шаблонного написания кодеков.

person Travis Brown    schedule 31.01.2020

Я считаю, что основная проблема здесь (как вы упомянули) заключается в том, что Option[String] выражает 2 состояния, тогда как вам на самом деле требуется 3, а именно:

  • значение присутствует и не равно нулю
  • значение присутствует, и нуль
  • значение нет

Один из способов решить эту проблему - обернуть ваши поля новым типом

case class PatchField[T](value: Option[T])

Это позволит вам написать свой класс запроса следующим образом:

case class PatchUserRequest (
    firstName: Option[PatchField[String]],
    lastName: Option[PatchField[String]]
)

Это означает, что теперь ваши полезные данные будут иметь следующий вид:

{
  "firstName": {"value" : "Foo" },
  "lastName": {"value" : "Bar" }
}

{
  "firstName": {"value": "Foo"}
}

{
  "firstName": {"value": null}
}

Я не уверен, есть ли способ заставить эту систему различать ноль и значения, которых нет вообще, но я считаю, что это может быть хорошим компромиссом.

person Regan Koopmans    schedule 29.01.2020