Search code examples
scalaakka-httpcirce

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


Solution

  • 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.