Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Fail-fast json4s serialisation of sealed trait and object enum when missing serializer

Set up

I'm using json4s 3.2.11 and Scala 2.11.

I have an enumeration defined using sealed trait, and a custom serializer for it:

import org.json4s.CustomSerializer
import org.json4s.JsonAST.JString
import org.json4s.DefaultFormats
import org.json4s.jackson.Serialization

sealed trait Foo
case object X extends Foo
case object Y extends Foo

object FooSerializer
    extends CustomSerializer[Foo](
      _ =>
        ({
          case JString("x") => X
          case JString("y") => Y
        }, {
          case X => JString("x")
          case Y => JString("y")
        })
    )

This is great, and works well when added to the formats:

{
  implicit val formats = DefaultFormats + FooSerializer
  Serialization.write(X) // "x"
}

This is great!

Problem

If the serializer is not added to the formats, json4s will use reflection to create a default representation of the fields, which is extremely unhelpful for these objects that don't have fields. It does this silently, seemingly without a way to control it.

{
  implicit val formats = DefaultFormats
  Serialization.write(X) // {}
}

This is a problematic, as there's no indication of what's gone wrong until much later. This invalid/useless data might be sent around the network or written to databases, if tests don't happen to catch it. And, this may be exposed publicly from a library, meaning downstream users have to remember it as well.

NB. this is different to read, which throws an exception on failure, since the Foo trait doesn't have any useful constructors:

{
  implicit val formats = DefaultFormats
  Serialization.read[Foo]("\"x\"")
}
org.json4s.package$MappingException: No constructor for type Foo, JString(x)
  at org.json4s.Extraction$ClassInstanceBuilder.org$json4s$Extraction$ClassInstanceBuilder$$constructor(Extraction.scala:417)
  at org.json4s.Extraction$ClassInstanceBuilder.org$json4s$Extraction$ClassInstanceBuilder$$instantiate(Extraction.scala:468)
  at org.json4s.Extraction$ClassInstanceBuilder$$anonfun$result$6.apply(Extraction.scala:515)
...

Question

Is there a way to either disable the default {} formatting for these objects, or to "bake" in the formatting to the object itself?

For instance, having write throw an exception like read would be fine, as it would flag the problem to the caller immediately.

like image 224
huon Avatar asked Apr 17 '19 03:04

huon


1 Answers

There is an old open issue which seems to ask similar question where one of the contributors suggests to

you need to create a custom deserializer or serializer

which makes it sound there is no out-of-the-box way to alter the default behaviour.

Method 1: Disallow default formats via Scalastyle

Try disallowing import of org.json4s.DefaultFormats using Scalastyle IllegalImportsChecker

 <check level="error" class="org.scalastyle.scalariform.IllegalImportsChecker" enabled="true">
  <parameters>
   <customMessage>Import from illegal package: Please use example.DefaultFormats instead of org.json4s.DefaultFormats</customMessage>
   <parameter name="illegalImports"><![CDATA[org.json4s.DefaultFormats]]></parameter>
  </parameters>
 </check>

and provide custom DefaultFormats like so

package object example {
  val DefaultFormats = Serialization.formats(NoTypeHints) + FooSerializer
}

which would allow us to serialise ADTs like so

import example.DefaultFormats
implicit val formats = DefaultFormats
case class Bar(foo: Foo)
println(Serialization.write(Bar(X)))
println(Serialization.write(X))
println(Serialization.write(Y))

which should output

{"foo":"x"}
"x"
"y"

If we try to import org.json4s.DefaultFormats, then Scalastyle should raise the following error:

Import from illegal package: Please use example.DefaultFormats instead of org.json4s.DefaultFormats

Method 2: Bake in serialisation for non-nested values

Perhaps we could "bake in" the formatting into objects by defining write method in Foo which delegates to Serialization.write like so

sealed trait Foo {
  object FooSerializer extends CustomSerializer[Foo](_ =>
      ({
        case JString("x") => X
        case JString("y") => Y
      }, {
        case X => JString("x")
        case Y => JString("y")
      })
  )

  def write: String = 
    Serialization.write(this)(DefaultFormats + FooSerializer)
}
case object X extends Foo
case object Y extends Foo

Note how we hardcoded passing FooSerializer format to write. Now we can serialise with

println(X.write)
println(Y.write)

which should output

"x"
"y"

Method 3: Provide custom DefaultFormats alongside org.json4s.DefaultFormats

We could also try defining custom DefaultFormats in our own package like so

package example

object DefaultFormats extends DefaultFormats {
  override val customSerializers: List[Serializer[_]] = List(FooSerializer)
}

which would allow us to serialise ADTs like so

import example.DefaultFormats
implicit val formats = DefaultFormats
case class Bar(foo: Foo)
println(Serialization.write(Bar(X)))
println(Serialization.write(X))
println(Serialization.write(Y))

which should output

{"foo":"x"}
"x"
"y"

Having two default formats, org.json4s.DefaultFormats and example.DefaultFormats, would at least make the user have to choose between the two, if say, they use IDE to auto-import them.

like image 97
Mario Galic Avatar answered Oct 17 '22 20:10

Mario Galic