Rich
Rich

Reputation: 2885

spray-json and spray-routing: how to invoke JsonFormat write in complete

I am trying to figure out how to get a custom JsonFormat write method to be invoked when using the routing directive complete. JsonFormat created with the jsonFormat set of helper functions work fine, but defining a complete JsonFormat will not get called.

sealed trait Error
sealed trait ErrorWithReason extends Error {
  def reason: String
}

case class ValidationError(reason: String) extends ErrorWithReason
case object EntityNotFound extends Error
case class DatabaseError(reason: String) extends ErrorWithReason

case class Record(a: String, b: String, error: Error)

object MyJsonProtocol extends DefaultJsonProtocol {
  implicit object ErrorJsonFormat extends JsonFormat[Error] {
    def write(err: Error) = failure match {
      case e: ErrorWithReason => JsString(e.reason)
      case x => JsString(x.toString())
    }
    def read(value: JsValue) = {
      value match {
        //Really only intended to serialize to JSON for API responses, not implementing read
        case _ => throw new DeserializationException("Can't reliably deserialize Error")
      }
    }
  }

  implicit val record2Json = jsonFormat3(Record)
}

And then a route like:

import MyJsonProtocol._

trait TestRoute extends HttpService with Json4sSupport {
  path("testRoute") {
    val response: Record = getErrorRecord()
    complete(response)
  }
}

If I add logging, I can see that the ErrorJsonFormat.write method never gets called.

The ramifications are as follows showing what output I'm trying to get and what I actually get. Let's say the Record instance was Record("something", "somethingelse", EntityNotFound)

actual

{
  "a": "something",
  "b": "somethingelse",
  "error": {}
}

intended

{
  "a": "something",
  "b": "somethingelse",
  "error": "EntityNotFound"
}

I was expecting that the complete(record) uses the implicit JsonFormat for Record which in turn relies on the implicit object ErrorJsonFormat that specifies the write method that creates the appropriate JsString field. Instead it seems to both recognize the provided ErrorJsonFormat while ignoring its instructions for serializing.

I feel like there should be a solution that does not involve needing to replace implicit val record2Json = jsonFormat3(Record) with an explicit implicit object RecordJsonFormat extends JsonFormat[Record] { ... }

So to summarize what I am asking

Edit

Digging through the spray-json source code, there is an sbt-boilerplate template that seems to define the jsonFormat series of methods: https://github.com/spray/spray-json/blob/master/src/main/boilerplate/spray/json/ProductFormatsInstances.scala.template

and the relevant product for jsonFormat3 from that seems to be :

def jsonFormat3[P1 :JF, P2 :JF, P3 :JF, T <: Product :ClassManifest](construct: (P1, P2, P3) => T): RootJsonFormat[T] = {
  val Array(p1,p2,p3) = extractFieldNames(classManifest[T])
  jsonFormat(construct, p1, p2, p3)
}

def jsonFormat[P1 :JF, P2 :JF, P3 :JF, T <: Product](construct: (P1, P2, P3) => T, fieldName1: String, fieldName2: String, fieldName3: String): RootJsonFormat[T] = new RootJsonFormat[T]{
  def write(p: T) = {
    val fields = new collection.mutable.ListBuffer[(String, JsValue)]
    fields.sizeHint(3 * 4)

    fields ++= productElement2Field[P1](fieldName1, p, 0)
    fields ++= productElement2Field[P2](fieldName2, p, 0)
    fields ++= productElement2Field[P3](fieldName3, p, 0)

    JsObject(fields: _*)
  }
  def read(value: JsValue) = {
    val p1V = fromField[P1](value, fieldName1)
    val p2V = fromField[P2](value, fieldName2)
    val p3V = fromField[P3](value, fieldName3)

    construct(p1v, p2v, p3v)
  }
}

From this it would seem that jsonFormat3 itself is perfectly fine (if you trace into the productElement2Field it grabs the writer and directly calls write). The problem must then be that the complete(record) doesn't involve JsonFormat at all and somehow alternately marshals the object.

So this seems to answer part 1: Why does the serialization of Record fail to call the ErrorJsonFormat write method (what does it even do instead?). No JsonFormat is called because complete marshals via some other means.

It seems the remaining question is if it is possible to provide a marshaller for the complete directive that will use the JsonFormat if it exists otherwise default to its normal behavior. I realize that I can generally rely on the default marshaller for basic case class serialization. But when I get a complicated trait/case class setup like in this example I need to use JsonFormat to get the proper response. Ideally, this distinction shouldn't have to be explicit for someone writing routes to need to know the situations where its the default marshaller as opposed to needing to invoke JsonFormat. Or in other words, needing to distinguish if the given type needs to be written as complete(someType) or complete(someType.toJson) feels wrong.

Upvotes: 1

Views: 1165

Answers (1)

Rich
Rich

Reputation: 2885

After digging further, it seems the root of the problem has been a confusion of the Json4s and Spray-Json libraries in the code. In trying to track down examples of various elements of JSON handling, I didn't recognize the separation between the two libraries readily and ended up with code that mixed some of each, explaining the unexpected behavior.

In this question, the offending piece is pulling in the Json4sSupport in the router. The proper definition should be using SprayJsonSupport:

import MyJsonProtocol._

trait TestRoute extends HttpService with SprayJsonSupport {
  path("testRoute") {
    val response: Record = getErrorRecord()
    complete(response)
  }
}

With this all considered, the answers are more apparent.

1: Why does the serialization of Record fail to call the ErrorJsonFormat write method (what does it even do instead)?.

No JsonFormat is called because complete marshals via some other means. That other means is the marshaling provided implicitly by Json4s with Json4sSupport. You can use record.toJson to force spray-json serialization of the object, but the output will not be clean (it will include nested JS objects and "fields" keys).

  1. Is there a way to match my expectation while still using complete(record)?

Yes, using SprayJsonSupport will use implicit RootJsonReader and/or RootJsonWriter where needed to automatically create a relevant Unmarshaller and/or Marshaller. Documentation reference

So with SprayJsonSupport it will see the RootJsonWriter defined by the jsonFormat3(Record) and complete(record) will serialize as expected.

Upvotes: 0

Related Questions