Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Circe encoder/decoder for subclasses types

Tags:

scala

circe

Given the following ADT

sealed abstract class GroupRepository(val `type`: String) {
  def name: String
  def repositories: Seq[String]
  def blobstore: String
}
case class DockerGroup(name: String, repositories: Seq[String], blobstore: String = "default") extends GroupRepository("docker")
case class BowerGroup(name: String, repositories: Seq[String], blobstore: String = "default") extends GroupRepository("bower")
case class MavenGroup(name: String, repositories: Seq[String], blobstore: String = "default") extends GroupRepository("maven")

Where the value type is used to decode which instance to instantiate.

How can I automatically (or semi-automatically) derive encoders and decoders such I get the following behavior:

> println(MavenGroup("test", Seq("a", "b")).asJson.spaces2)
{
  "type" : "maven",
  "name" : "test",
  "repositories" : [
     "a",
     "b"
  ],
  "blobstore" : "default"
}
> println((MavenGroup("test", Seq("a", "b")): GroupRepository).asJson.spaces2)
{
  "type" : "maven",
  "name" : "test",
  "repositories" : [
     "a",
     "b"
  ],
  "blobstore" : "default"
}

The traditional approach of doing

object GroupRepository {
  implicit val encoder = semiauto.deriveEncoder[GroupRepository]
  implicit val decoder = semiauto.deriveDecoder[GroupRepository]
}

Fails on two fronts:

  • It does not serialize the value type.
  • Does not allow MavenGroup("test", Seq("a", "b")).asJson. It only allows the second alternative where MavenGroup is first casted to GroupRepository.

The best solution I could come up with is:

object GroupRepository {
  implicit def encoder[T <: GroupRepository]: Encoder[T] = Encoder.instance(a => Json.obj(
    "type" -> Json.fromString(a.`type`),
    "name" -> Json.fromString(a.name),
    "repositories" -> Json.fromValues(a.repositories.map(Json.fromString)),
    "blobstore" -> Json.fromString(a.blobstore)
  ))
  implicit def decoder[T <: GroupRepository]: Decoder[T] = Decoder.instance(c =>
    c.downField("type").as[String].flatMap {
      case "docker" => c.as[DockerGroup](semiauto.deriveDecoder[DockerGroup])
      case "bower" => c.as[BowerGroup](semiauto.deriveDecoder[BowerGroup])
      case "maven" => c.as[MavenGroup](semiauto.deriveDecoder[MavenGroup])
    }.right.map(_.asInstanceOf[T])
  )
}

However it was several shortcomings:

  • The encoder was to be specified manually.
  • The decoder for each subtype is not being cached since it is necessary to pass the encoder explicitly.
like image 488
Simão Martins Avatar asked Jun 23 '17 15:06

Simão Martins


1 Answers

You can define encoder/decoder (codec) for each case class as val it will not be created each time:

import io.circe.generic.semiauto.deriveCodec
import io.circe.Codec

private implicit val dockerCodec: Codec.AsObject[DockerGroup] = deriveCodec[DockerGroup]
private implicit val bowerCodec: Codec.AsObject[BowerGroup] = deriveCodec[BowerGroup]
private implicit val mvnCodec: Codec.AsObject[MavenGroup] = deriveCodec[MavenGroup]

Approach 1: something like fabric of codecs

I think there is no way to do such a universal codec without isInstanceOf but I can mistake.

So, I would define codec for some T which uses concrete codec depends on type field:

import io.circe.Decoder.Result
import io.circe.generic.semiauto.deriveCodec
import io.circe.{Codec, HCursor, Json}
import io.circe.syntax._


object Codecs {
  private implicit val dockerCodec: Codec.AsObject[DockerGroup] = deriveCodec[DockerGroup]
  private implicit val bowerCodec: Codec.AsObject[BowerGroup] = deriveCodec[BowerGroup]
  private implicit val mvnCodec: Codec.AsObject[MavenGroup] = deriveCodec[MavenGroup]
  
  implicit def codec[T <: GroupRepository]: Codec[T] = new Codec[T] {
    override def apply(a: T): Json =
      (a match {
        case d: DockerGroup => d.asInstanceOf[DockerGroup].asJsonObject
        case b: BowerGroup => b.asInstanceOf[BowerGroup].asJsonObject
        case m: MavenGroup => m.asInstanceOf[MavenGroup].asJsonObject
      }).+:("type", a.`type`.asJson).asJson

    override def apply(c: HCursor): Result[T] = c.downField("type").as[String].flatMap {
      case "docker" => c.as[DockerGroup]
      case "bower" => c.as[BowerGroup]
      case "maven" => c.as[MavenGroup]
    }.map(_.asInstanceOf[T])
  }
}

object Test extends App {

  import Codecs._
  println(DockerGroup("doc", Seq("a", "b")).asJson)
  println(DockerGroup("doc", Seq("a", "b")).asJson.as[DockerGroup])

  println(BowerGroup("bow", Seq("a", "b")).asJson)
  println(BowerGroup("bow", Seq("a", "b")).asJson.as[BowerGroup])

  println(MavenGroup("mvn", Seq("a", "b")).asJson)
  println(MavenGroup("mvn", Seq("a", "b")).asJson.as[MavenGroup])
}

output:

{
  "type" : "docker",
  "name" : "doc",
  "repositories" : [
    "a",
    "b"
  ],
  "blobstore" : "default"
}
Right(DockerGroup(doc,List(a, b),default))
{
  "type" : "bower",
  "name" : "bow",
  "repositories" : [
    "a",
    "b"
  ],
  "blobstore" : "default"
}
Right(BowerGroup(bow,List(a, b),default))
{
  "type" : "maven",
  "name" : "mvn",
  "repositories" : [
    "a",
    "b"
  ],
  "blobstore" : "default"
}
Right(MavenGroup(mvn,List(a, b),default))

Approach 2: get rid of type field and use configurable derivation

We can define discriminator field type in our Configuration for parsing JSON and get rid of this field in parent abstract class GroupRepository (it can be a trait now). Here I use some ad-hoc discriminator to get similar result of type field in result json, but I think in this approach it could be more elegant:

constructorName => constructorName.toLowerCase.dropRight("Group".length)

Remember, for using ConfiguredJsonCodec annotation you should define implicit val config: Configuration in the companion object. Also, you should add -Ymacro-annotations flag to the scala compiler for macroses:

scalacOptions ++= Seq("-Ymacro-annotations")

Full code:

import io.circe.Decoder.Result
import io.circe.generic.extras.{Configuration, ConfiguredJsonCodec}
import io.circe.syntax._
import io.circe.{Codec, HCursor, Json}
import ru.hardmet.GroupRepository._

@ConfiguredJsonCodec
sealed trait GroupRepository {
  def name: String

  def repositories: Seq[String]

  def blobstore: String
}

case class DockerGroup(name: String, repositories: Seq[String], blobstore: String = "default")
  extends GroupRepository

case class BowerGroup(name: String, repositories: Seq[String], blobstore: String = "default")
  extends GroupRepository

case class MavenGroup(name: String, repositories: Seq[String], blobstore: String = "default")
  extends GroupRepository

object GroupRepository {
  implicit val config: Configuration =
    Configuration.default
      .withDiscriminator("type").copy(
      transformConstructorNames = _.toLowerCase.dropRight("Group".length)
    )
}

object GroupRepositoryCodec {
  implicit def codec[T <: GroupRepository]: Codec[T] = new Codec[T] {
    override def apply(a: T): Json = a.asInstanceOf[GroupRepository].asJson

    override def apply(c: HCursor): Result[T] = c.as[GroupRepository].map(_.asInstanceOf[T])
  }
}

object JsonExperiments extends App {

  import GroupRepositoryCodec._

  println(DockerGroup("doc", Seq("a", "b")).asJson)
  println(DockerGroup("doc", Seq("a", "b")).asJson.as[DockerGroup])

  println(BowerGroup("bow", Seq("a", "b")).asJson)
  println(BowerGroup("bow", Seq("a", "b")).asJson.as[BowerGroup])

  println(MavenGroup("mvn", Seq("a", "b")).asJson)
  println(MavenGroup("mvn", Seq("a", "b")).asJson.as[MavenGroup])
}

the output will be the same with a difference - type field in JSON goes to the end of object:

{
  "name" : "doc",
  "repositories" : [
    "a",
    "b"
  ],
  "blobstore" : "default",
  "type" : "docker"
}
like image 90
Boris Azanov Avatar answered Oct 23 '22 22:10

Boris Azanov