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)
TYPE
de cada clase.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
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.
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.
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.
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]
).
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)