9

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.
Simão Martins
  • 1,210
  • 11
  • 22
  • 2
    Disclaimer: I am the creator of the linked project. It looks like you have the same problem I tried solving in a generic way in this project: https://github.com/drivetribe/circe-field-hints Have a look at it, if you think that that's the solution I'll create a proper answer for it. – Aldo Stracquadanio Jun 28 '17 at 11:11
  • Its not exactly what I want since my class hierarchy already defines the values for the `type`, whereas you explicitly pass the `type` when you invoke the HintedEncoder.derive. However it helps me a lot and I can inspect your code to come up with a solution to my problem. Thanks! – Simão Martins Jul 01 '17 at 14:41

1 Answers1

2

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"
}
Boris Azanov
  • 4,408
  • 1
  • 15
  • 28