Circe

Para serializar/deserializar a Json instancias en Scala, más si usamos Cats, una opción bastante mayoritaria es usar la librería Circe.

Voy a usar un caso de uso concreto e ir implementando (o usando derivación automática) para ver el porqué de las decisiones tomadas.

  • imports

    import cats.syntax.functor._
    import io.circe.generic.auto._
    import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
    import io.circe.syntax._
    import io.circe.{Decoder, Encoder}
    
  • Clases base

    sealed abstract class GeoJson(val TYPE: String)
    sealed abstract class CoordinateContainer[T](TYPE: String, coordinates: T) extends GeoJson(TYPE)
    
    • Uso abstract class sobre trait por comodidad a la hora de extraer los parámetros en el encoder Si usamos trait es más tedioso definir el TYPE de cada clase.
    • Sería ideal el uso de enum, pero no se puede (al menos no he podido) hacer referencia a casos internos del enum como miembros de otros casos. Por ejemplo, lo siguiente
      enum GeoJson {
        def TYPE: String
      
        case Foo extends GeoJson
        case Bar(foo: GeoJson.Foo) extends GeoJSON
        // o también
        case Bar(foo: Foo) extends GeoJSON
      }
      

da error al tener Bar una instancia de Foo

  • Punto
type Point = (Float, Float)

given Encoder[Point] = deriveEncoder[Point]
given Decoder[Point] = deriveDecoder[Point]

Pareja de números. Circe puede derivar directamente. Si queremos hacerlo explícitamente podemos usar deriveEncoder y deriveDecoder.

  • Polígono
case class Polygon(coordinates: Vector[Vector[Point]])
  extends CoordinateContainer[Vector[Vector[Point]]](coordinates = coordinates, TYPE = "Polygon")

given Encoder[Polygon] = addType(deriveEncoder[Polygon], "Polygon")

Para poder enviar un parámetro de nombre type, al ser una palabra reservada del lenguaje, tengo que crear otro atributo de nombre diference (TYPE) y luego mapear o insertar. De las varias formas que he probado, usar una función addType es la que mejor resultado me ha dado.

def addType[T <: GeoJson](encoder: Encoder[T], t: String): Encoder[T] =
  encoder.mapJson { _.mapObject(_.add("type", t.asJson)) }

Hay funcionalidades extras en el paquete circe-generic-extra que parece que deberían funcionar, pero no está actualizado a Scala3.

  • Geometry
    type Geometry = Point | Polygon
    
    given Encoder[Geometry] = Encoder.instance {
      case point @ Tuple2(_) => point.asJson
      case pol @ Polygon(_)  => pol.asJson
    }
    
    given Decoder[Geometry] = List[Decoder[Geometry]](
      Decoder[Point].widen,
      Decoder[Polygon].widen
    ).reduceLeft(_ or _)
    

Es un tipo unión. Para codificar necesitamos tener disponibles las instancias de todos los tipos que forman la unión (ya los hemos definido anteriormente). Para decodificar hacemos una lista de los decodificadores y los aplicamos hasta que uno funciona.

Podemos hacer algo similar a la codificación, crear una u otra instancia mediante pattern matching. Posiblemente tenga que ir a este método tarde o temprano.

  • Feature
    given Encoder[Feature] = Encoder.instance { case feat @ Feature(_, _) =>
      feat.asJson(addType(deriveEncoder[Feature], feat.TYPE))
    }
    

deriveEncoder usa todos los codificadores que el compilador tiene disponibles para los miembros de Feature. Por esta razón, no es necesario crear un decodificador, porque de forma automática se deriva (lo mismo que hacer given Decoder[Feature] = deriveDecoder[Feature]).

  • GeoJson Podemos crear un codificador para toda la jerarquía que use las instancias implícitas
    given Encoder[GeoJson] = Encoder.instance {
      case pol @ Polygon(_)           => pol.asJson.mapObject(obj => obj.add("type", pol.TYPE.asJson))
      case feat @ Feature(id, geom)   => feat.asJson
      case col @ FeatureCollection(_) => col.asJson.mapObject(obj => obj.add("type", col.TYPE.asJson))
    }
    

El método .asJson precisamente usa dichas instancias por lo que si el compilador no encuentra alguna en el contexto obviamente nos da error.

final def asJson(implicit encoder: Encoder[A]): Json = encoder(value)