Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Handling PATCH requests with Akka HTTP and circe for nullable fields

Is there a common approach to handle PATCH requests in REST API using circe library? By default, circe does not allow decoding partial JSON with only a part of the fields specified, i.e. it requires all fields to be set. You could use a withDefaults config, but it will be impossible to know if the field you received is null or just not specified. Here is a simplified sample of the possible solution. It uses Left[Unit] as a value to handle cases when the field is not specified at all:

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

Is there a better way to handle PATCH requests (with nullable fields) instead of writing custom codec that falls back to no value if field is not specified in the JSON payload? Thanks

like image 681
Alexey Sirenko Avatar asked Oct 15 '22 07:10

Alexey Sirenko


1 Answers

Here's how I've done this kind of thing:

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

And then:

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

And then:

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

This approach lets you model the intent more cleanly while still being able to use generic derivation to avoid most of the boilerplate of writing your codecs.

like image 199
Travis Brown Avatar answered Oct 26 '22 22:10

Travis Brown