9

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.

huon
  • 94,605
  • 21
  • 231
  • 225

1 Answers1

3

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.

Mario Galic
  • 47,285
  • 6
  • 56
  • 98
  • 1
    The `write` approach is nice for converting individual values, but I think it doesn't work for nested values (if I've got `case class Bar(foo: Foo)`, it should serialise to `{"foo": "x"}`, not `{"foo": {}}`), and one still has to remember to use that `write` function. Thanks for finding the issue! It is... unfortunate that it has been open for almost 6 years. – huon May 24 '19 at 05:11
  • I've edited the answer with Scalastyle method which would allow nested-ADT serialisation as well as enabling users to continue using `write`. – Mario Galic May 24 '19 at 23:06
  • Nice; the scalastyle method is interesting! I think that's a reasonable work around, but I'm not a huge fan, as it does require anyone using the library to do that set-up (but at least that's only one time). How does method 3 differ to method 1? It seems like it's just an alternative way to specify an appropriate `DefaultFormats` object (as an `object` instead of a `val`), and still requires some sort of enforcement (e.g. via `scalastyle`)? – huon May 27 '19 at 01:57