scalatuplestype-level-computationzio-json

Scala 3 Typed Extractor for Zio-Json objects


UPDATE 2:

Got a solution I'm pretty happy with now, using a more general Extractor[Subject, Selector, Result] that describes the ability to extract some Selector from a Subject to get a Result, and then creating a zio-json Json.Obj-specific implementation of it. Ergonomics could be improved a bit, but overall I think it works alright. There are a pair of warnings however, that I'm not sure how to get rid of, and am sure could lead to problems in certain situations. They happen on line 22 of the above linked scastie:

the type test for Tuple.Head[Sel] cannot be checked at runtime because it refers to an abstract type member or type parameter
the type test for Tuple.Tail[Sel] cannot be checked at runtime because it refers to an abstract type member or type parameter

My general strategy for this type of error is usually to add ClassTags, but that didn't seem to help in this case. While I understand erasure generally, I'm still not sure how to solve this warning.

UPDATE:

Getting a bit closer (I think) with this updated scastie taking a more shapeless-like approach, but having trouble at the call site now with the error:

Recursive value $t needs type

not sure what recursion it's referring to?

ORIGINAL:

This is more for pedagogical purposes, but I'm trying to write a tuple extractor for a zio-json Json.Obj (though I think the concepts can apply to more than just this library). The goal is to pass a Json.Obj, a tuple of Strings representing keys to the caller is interested in extracting from that object, and a tuple type of the same length as the keys tuple representing the types the caller would like to convert the corresponding values to, something like:

def [Keys <: Tuple, Values <: Tuple](json: Json.Obj, keys: Keys)(using
        Tuple.Union[Keys] <: String, Tuple.Size[Keys] =:= Tuple.Size[Values]): V

Here's a scastie I have of trying to actually do something like this with unapply, but am having trouble with a few things, mostly ensuring that a JsonEncoder exists for every type in the Values tuple (as is required by the json.as[V] function).

I have a working solution for a simpler version of this, where all the values are assumed to be strings, and the inputs and outputs are Seq so there's no validating that the number of keys matches the number of extracted values:

case class ZioJsonExtractor(keys: String*):
    def unapplySeq(json: Json.Obj): Option[Seq[String]] =
        keys.foldLeft(Option(List.empty[String])): (vals, key) =>
            for
                vs <- vals
                v <- json.get(key).flatMap(_.asString)
            yield v :: vs
        .map(_.reverse)

which can be used something like

val jsonExtractor = ZioJsonExtractor("desired", "keys")

val json: Json.Obj = ...
json match 
    case jsonExtractor(extracted, values) => // here `extracted` and `values` are both Strings

Is doing the more type-safe version of this possible? Any ideas how to proceed with that?


Solution

  • Ok, here's a pretty close to final version of this, though still open to hearing ideas to improve the look/feel/usability:

    import zio.json.*
    import zio.json.ast.Json
    
    
    // describes the general ability to extract some `Selector` from a `Subject` to obtain a `Result`
    trait Extractor[Subject, Selector, Result]:
        def extract(from: Subject, select: Selector): Option[Result]
    
    // givens for `Tuple`s of size 1 or more
    object Extractor:
        // if we can extract a single `Sel` from a `Sub` to get an `R`,
        // we can turn that into a `Tuple1`
        given tuple1Extract[Sub, Sel, R](using e: Extractor[Sub, Sel, R]): Extractor[Sub, Sel *: EmptyTuple, R *: EmptyTuple] with
            override def extract(from: Sub, sel: Sel *: EmptyTuple): Option[R *: EmptyTuple] =
                sel match
                    case s *: EmptyTuple => e.extract(from, s).map(_ *: EmptyTuple)
    
        // provides the ability to extract a tuple of selectors into a tuple of results,
        // given the ability to extract individual elements of the selector tuple to corresponding elements of the result tuple
        given tupleExtract[Sub, HS, TS <: Tuple, HR, TR <: Tuple](using
            eh: Extractor[Sub, HS, HR],
            et: Extractor[Sub, TS, TR],
        ): Extractor[Sub, HS *: TS, HR *: TR] with
            override def extract(from: Sub, sel: HS *: TS): Option[HR *: TR] =
                sel match
                    case (s: HS) *: (rest: TS) =>
                        for
                            value <- eh.extract(from, s)
                            values <- et.extract(from, rest)
                        yield value *: values
    
    // `zio-json`-specifc `Extractor`s 
    case class ZioJsonExtractor[K, V](keys: K):
        def unapply(json: Json.Obj)(using e: Extractor[Json.Obj, K, V]): Option[V] =
            e.extract(json, keys)
    
    object ZioJsonExtractor:
        // the only thing an implementation needs to provide are given instances for each individual selector type to result type it would like to support,
        // and then the `Extractor` will provide the ability to combine them into extractors of tuples.
        // for `Json.Obj`, we can extract anything that has a `JsonDecoder` instance using a `String` selector
        given baseExtract[A](using JsonDecoder[A]): Extractor[Json.Obj, String, A] with
            override def extract(obj: Json.Obj, key: String): Option[A] =
                for
                    vAst <- obj.get(key)
                    v <- vAst.as[A].toOption
                yield v
    
    // convenience builder class, allows inferring selector type
    class ZioJson[V]:
        def apply[K](keys: K): ZioJsonExtractor[K, V] = ZioJsonExtractor(keys)
    
    
    // test data
    case class User(name: String)
    case class Amount(count: Int)
    
    
    import ZioJsonExtractor.given
    
    given userJsonSchema: JsonCodec[User] = DeriveJsonCodec.gen
    given amountJsonSchema: JsonCodec[Amount] = DeriveJsonCodec.gen
    
    @main
    def tryIt(): Unit =
        val json = Json.Obj(
            "user" -> Json.Obj("name" -> Json.Str("Bobson Dugnutt")),
            "amount" -> Json.Obj("count" -> Json.Num(42)),
            "notMatched" -> Json.Obj("not" -> Json.Str("interested")),
            "some" -> Json.Str("string"),
        )
        
        // create the extractors
        val userAndAmount: ZioJsonExtractor[(String, String), (User, Amount)] = ZioJson[(User, Amount)].apply("user", "amount")
        val amountAndUser = ZioJson[(Amount, User)].apply("amount", "user")
        val someStr = ZioJson[String].apply("some")
    
        // finally, using them!
        json match
                case userAndAmount(u, a) =>
                    println(s"extracted $u and $a!")
                case _ =>
                    println("no match")
    
        json match
                case amountAndUser(a, u) =>
                    println(s"extracted $a and $u!")
                case _ =>
                    println("no match")
    
        json match
                case someStr(str) =>
                    println(s"extracted $str!")
                case _ =>
                    println("no match")
    

    scastie