Search code examples
scalaplayframework

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


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?


Solution

  • 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.