Home > Mobile >  How to Json encode a case class that inherits from a trait in Play?
How to Json encode a case class that inherits from a trait in Play?

Time:05-22

I have a trait which is a base class for some case classes:

trait BaseHeaders {
  val timestamp: LocalDateTime
  val host: String
  val os: String
  val method: String
  val userAgent: String
}

object BaseHeaders {
  implicit val traitWrites = new Writes[BaseHeaders] {
    def writes(baseHeaders: BaseHeaders) = JsString(BaseHeaders.toString)
  }
}

One of my case classes is similar to one below:

case class PaymentHeader(
                          timestamp: LocalDateTime,
                          host: String,
                          os: String,
                          method: String,
                          path: String,
                          userAgent: String,
                          paymentStatus: String,
                        ) extends BaseHeaders {
  val kafkaEventPublisher = new KafkaEventPublisher("payment-topic")

  def publish(): Unit = kafkaEventPublisher.publishLog(this)

}

In my companion object I construct the required case class:

object PaymentFunnel {

  def from(req: HttpRequest, status: String): PaymentHeader = {
    val (host, userAgent, channel, os) = HeaderExtractor.getHeaders(req)

    PaymentHeader(
      LocalDateTime.now(),
      host,
      os,
      req.method.value,
      req.uri.path.toString,
      userAgent,
      channel,
      status,
    )
  }

  implicit val format: Format[PaymentHeader] = JsonNaming.snakecase(Json.format[PaymentHeader])

}

Now my final method is publishLog which is defined as below:

def publishLog(message: BaseHeaders) = {
      Source.fromIterator(() => List(message).iterator)
        .map(baseHeader => {
          println("publishing headers data to kafka ::::: "   Json.toJson(baseHeader).toString())
          new ProducerRecord[String, String](topicName, Json.toJson(baseHeader).toString())
        })
        .runWith(Producer.plainSink(producerSettings))
    }

Now what I get on Kafka topics is a string version of the case class:

`PaymentHeader(some data here for fields....)`

A string version of the case class! What I want on Kafka end is to have a json serialized PaymentHeader.

NOTE: I have many other case classes that extends BaseHeaders like PaymentHeaders.

How can I json encode my case classes in Scala/Play?

CodePudding user response:

What you're currently doing, is invoking the toString method which basically returns the string representation obj the object in Scala, and JsString just assumes this as a string, and puts double-quotes around it. I can propose 3 approaches, I'll explain advantages and disadvantages to each one, you can decide to use which one:

Approach #1: do pattern matching


When serializing a trait, what matters is the sub-type in this case, so imagine given:

sealed trait A { val name: String }
case class B(name: String) extends A
case class C(name: String, id: Int) extends A
/// and so on

You can define writer for each of these classes, and then inside the trait's companion object:

object A {
  implicit val traitWriter: Writes[A] = {
    case b: B => Json.toJson(b)
    case c: C => Json.toJson(c)
  }
}

This approach is pretty easy to use, but one thing to note is that as a new class is created which extends the trait, you will need to update the writer in trait's companion object, or else you'll face match error. The advantage to this is that your code stays pretty simple and easy-to-read. The disadvantage is that you cannot be sure about the process in compile time (match error issue)

Approach #2: type constraint and context bound in consumer method


In the publishLog method, change the method signature to this:

// original:
def publishLog(message: BaseHeaders) = ...
// to =>
def publishLog[T <: BaseHeaders : Writes](message: T) = ... // the body will be the same

And just remove the writer from trait's companion object. What this means, is you expect some type "T" which is actually a BaseHeaders, and also has it's own Writer bound to it, so there wouldn't be serialization issues. Advantages to this approach, (1) is that your code still remains simple and easy-to-read, (2) you'll get rid of the headache to define a writer that works in all cases for the trait, (3) You're sure about everything in compile time. The disadvantage to this approach is that whenever you want to use a BaseHeaders value, you'll have to define the same signature for your methods (if you need to serialize), but this seems fair to me.

Approach #3: putting instance-level constraints (not recommended) using self type


trait BaseHeaders[T <: BaseHeaders[T]] { self: T => 
  val timestamp: LocalDateTime
  // ... other fields
  implicit def instanceWriter: Writes[T]
}

case class PaymentHeader(...) extends BaseHeaders[PaymentHeader] {
  implicit def instanceWriter: Writes[PaymentHeader] = implicitly
}

An advantage to this would be that you're sure about serialization in compile time While it has 1 major disadvantage, the code becomes actually so much harder than it should be, which is not good at all. The other one would be that you'll need to invoke instanceWriter whenever you need to serialize.

CodePudding user response:

Json.toJson converts a class based on the compile time type of the argument, not the run-time type, so it is converting it as a BaseHeaders object. The writer for BaseHeaders just converts the case class to a string, so that is what you get in the JSON.

You need to template the publishLog method so that Json.toJson sees the actual type of the message.

def publishLog[T <: BaseHeaders](message: T)

You also need to make sure that the message type is preserved in the calling functions, and that Play knows how to convert the actual type to JSON.

  • Related