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 }
- 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
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)