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 ClassTag
s, 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?
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")