Search code examples
scalajvmscala-3

Deserializing Java String into a Java Class using Lift JSON in Scala 3


I'm trying to implement "generic" deserialization in JSON for simple Scala3 types (e.g. case classes) - I need to pass in the serialized JSON and the name (string) of the target class and to receive an instance of the target class (which I treat as a java.lang.Object afterwards).

I chose to use the Lift framework's JSON library. Due to Lift being Scala2 only, I'm using it through the CrossVersion.for3Use2_13 compatibility layer (this is from my build.sbt):
libraryDependencies += ("net.liftweb" %% "lift-json" % "3.5.0").cross(CrossVersion.for3Use2_13),

I chose Lift's JSON library due to the availability of explicitly passing the target class for deserialization (see Extraction Lift docs):
def extract(json: JValue, target: TypeInfo)(implicit formats: Formats): Any

To make use of that, I'm first "dereferencing" the class name to a java.lang.Class instance in Java:

String targetClassName = (inp.entrypointClass == null ? "Main" : inp.entrypointClass);
String mainClassName = this.getClass().getPackage().getName() + "." + targetClassName;
Class targetClass;
try {
  targetClass = Class.forName(mainClassName);
} catch(ClassNotFoundException err1) {
  throw new InvalidEntrypointClassError(err1);
}

This part seems to run correctly (evidenced by the fact that I'm not getting InvalidEntrypointClassErrors in my debugging attempts).

Then I try to deserialize the string into a Class as follows:

import net.liftweb.json._
import net.liftweb.json.Extraction._
import net.liftweb.json.Serialization._
import reflect._

val formats = net.liftweb.json.DefaultFormats

object ScalaWrapper {
    def deserialize(jsonData: java.lang.String, clazz: java.lang.Class[?]): java.lang.Object = {
        val typeInfo = TypeInfo(clazz, None)
        val data = parse(jsonData)
        val res: Any = extract(data, typeInfo)(formats)
        res.asInstanceOf[java.lang.Object]
    }
}

This fails when I try it with simple examples, such as trying to deserialize into the Input case class defined here:

case class Node(name: String, left: Option[Int], right: Option[Int])
case class Walk(nodes: List[Node])
case class Input(nodes: List[Node])

Note that it is important for me to have "simple" case class definitions, that is passing the complexity of the deserialization problem unto the case classes IS NOT a solution due to other requirements

with the following JSON input:

{
        "nodes": [
            {
                "name": "A",
                "left": 1,
                "right": 2
            },
            {
                "name": "B"
            },
            {
                "name": "C",
                "left": 4,
                "right": 3
            },
            {
                "name": "D"
            },
            {
                "name": "E"
            }
        ]
    }

The error is rather deep inside Lift JSON:

java.lang.NullPointerException
        at scala.tools.scalap.scalax.rules.scalasig.ByteCode$.forClass(ClassFileParser.scala:24)
        at scala.tools.scalap.scalax.rules.scalasig.ScalaSigParser$.parse(ScalaSig.scala:68)
        at net.liftweb.json.ScalaSigReader$.findScalaSig(ScalaSig.scala:126)
        at net.liftweb.json.ScalaSigReader$.$anonfun$findScalaSig$1(ScalaSig.scala:126)
        at scala.Option.orElse(Option.scala:477)
        at net.liftweb.json.ScalaSigReader$.findScalaSig(ScalaSig.scala:126)
        at net.liftweb.json.ScalaSigReader$.findClass(ScalaSig.scala:60)
        at net.liftweb.json.ScalaSigReader$.readConstructor(ScalaSig.scala:44)
        ... <SKIPPED ABOUT 20 OTHER STACK FRAMES>
        at net.liftweb.json.Meta$.mappingOf(Meta.scala:195)
        at net.liftweb.json.Extraction$.extract(Extraction.scala:210)
        at binary_tree_dfs.ScalaWrapper$.deserialize(ScalaWrapper.scala:13)
        at binary_tree_dfs.ScalaWrapper.deserialize(ScalaWrapper.scala)

I've failed at debugging this and I don't know Scala under the hood to really understand what's going on, but my guess is I'm paying the price for trying to use Scala2 generics features in a Scala3 project. Is that correct? Can this code be fixed without a major refactor?

Since I don't know what's going on here, I've thought about several other approaches:

  • Tried to get implicit TypeTag or Manifest (I can post non-working snippets). They need to be explicitly passed from the Java side and I don't find any documentation on how to produce those from just a class name. Is that possible?
  • I'm guessing there's a way to convert izumi-reflect's TypeTags into Scala2 Manifest-compatible objects, but I don't know how to do it and I'm afraid this is hacky and unreliable. Is this approach possible and does it make more sense?
  • Alternatively, I'm guessing that I can use another Scala3 JSON library that can deserialize into a TypeTag that is provided as (implicit?) argument. Is there a Scala3 library that would allow me to do this without modifying the case classes definitions?
  • Maybe I can obtain a Scala3 Manifest in another way that I don't understand? Scala3 documentation on Mirror seems scarce. Can I construct a Scala3 method that receives a class name and returns a tuple of both java.lang.Class and Manifest? Does that even make sense if Manifest doesn't have a generic argument?
  • AFAIU, Scala3 macros are the intended replacement of Scala2's Reflection. Can/should this be done using Scala3 macros?

I know this question is a lot to take in, so thanks for making it this far :) I've tried hard to provide as much possible info without overcomplicating, but I'd gladly share more context - such as a not-working example that I'm sweating over

Just to summarize the problem:
I'm trying to deserialize a JSON string into a Scala3 class I know only by name. I expect this class to be a case class. I'm currently trying and failing to use Scala2's Lift JSON library. I want the case classes that the JSON will be deserialized into to have simple definitions, so I don't want serialize/deserialize methods or similar approaches.

How do I achieve this in Scala 3?


Solution

  • Jackson (with the Scala module) is able to do what you want (i.e. parsing to a class known at runtime).

    package com.myapp
    
    import com.fasterxml.jackson.databind.json.JsonMapper
    import com.fasterxml.jackson.module.scala.{ClassTagExtensions, DefaultScalaModule}
    
    case class Node(name: String, left: Option[Int], right: Option[Int])
    case class Walk(nodes: List[Node])
    case class Input(nodes: List[Node])
    
    
    val objectMapper = JsonMapper.builder().addModule(DefaultScalaModule).build() :: ClassTagExtensions
    
    val input: String = ???
    
    val parsedInput = objectMapper.readValue(input, Class.forName("com.myapp.Input"))
    
    println(parsedInput)
    // Input(List(Node(A,Some(1),Some(2)), Node(B,None,None), Node(C,Some(4),Some(3)), Node(D,None,None), Node(E,None,None)))