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.