lift-json

How to add type hints for class with custom serializer in lift-json?


We have an existing class A, that previously was the only type that could appear in a certain position in the JSON output of our REST API endpoint. However, I'm now making it extend a common base class B so that a range of different types, all inheriting from B, can appear in that position in the output.

I added a type hint to the Formats, but even though the Formats methods correctly looked up the type hint information in both directions, the type hints were ignored on serialization by lift-json.

It turned out that the reason lift-json wasn't adding the type hint fields to the JSON was because there was also a custom serializer configured for that class in our Formats instance, and custom serializers override type hints.

So how can we have a class that both has a custom serializer, and emits and produces type hints to allow its type to be unambiguously identified (on both the clients and servers)?


Solution

  • This isn't very well-documented any more, but the TypeHints trait has two methods:

    def deserialize: PartialFunction[(String, JObject), Any] = Map()
    def serialize: PartialFunction[Any, JObject] = Map()
    

    These methods can be overridden when implementing the TypeHints trait (or when extending the provided default implementation of TypeHints) to specify custom serialization and deserialization logic for JSON objects which have type hints specified. The default implementations (show above) are just in effect partial functions which never match anything, so they don't have any effects.

    There are some differences with the deserialize and serialize methods in Serializer, which is what our code was previously using:

    1. These methods don't take a Formats argument, which means it is necessary to rely a Formats instance that's in scope.

    2. They operate on JObject on the JSON side of the conversion, instead of its supertype JValue (obviously, when you think about it - because anything that has a type hint inevitably must be a JSON object, not a string or a number or whatever).

    3. They don't take a type parameter, and just operate on Any on the Scala side of the conversions - that is because they just handle all type-hinted types that require custom serialization logic in one big partial function.

    4. Instead of a TypeInfo, the deserialize partial function takes a String, which is the value of the type hint field.

    I think most of these differences are because this is older lift-json code, from before the Serializer trait existed, and when there was only one way to do custom serialization.

    So what worked for me was:

    def typeHints(implicit formats: Formats) = new OurTypeHintsImpl( ... type hints information ... ) {

    override def deserialize = {

    case ("type-hint-for-A", o: JObject) => ... existing deserialization code ...

    }

    override def deserialize = {

    case A(...) => ... existing serialization code ...

    }

    and to add another type with both type hints and custom serialization logic, it would just be necessary to add one new case branch to both of the above.

    With this approach, the correct type hints are added automatically by lift-json, but you still get to completely customise how the rest of the serialisation and deserialisation is done. So I think it's the most convenient and suitable approach for most cases (but it did require a bit of refactoring). It should also be possible to reimplement type hinting in the custom Serializer, but why reinvent the wheel?

    Warning: case matching on types has limitations with respect to generic types by default, but this shouldn't generally matter for this purpose - unless you are not independently serializing a generic type contained within another type, but are instead merging it into the outer type in the JSON.