pekko/akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala
Patrik Nordwall 93017d05c7 serializer for akka.actor.typed.ActorRef
* most convenient for users to include it akka-serialization-jackson
  and load it when akka-actor-typed is in classpath
* provided dependency to akka-actor-typed
2019-05-30 15:08:48 +02:00

565 lines
21 KiB
Scala

/*
* Copyright (C) 2016-2019 Lightbend Inc. <https://www.lightbend.com>
*/
package akka.serialization.jackson
import java.time.Duration
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Arrays
import java.util.Locale
import java.util.Optional
import java.util.logging.FileHandler
import scala.collection.immutable
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration._
import akka.actor.ActorRef
import akka.actor.ActorSystem
import akka.actor.Address
import akka.actor.BootstrapSetup
import akka.actor.ExtendedActorSystem
import akka.actor.Status
import akka.actor.setup.ActorSystemSetup
import akka.actor.typed.scaladsl.Behaviors
import akka.serialization.Serialization
import akka.serialization.SerializationExtension
import akka.testkit.TestActors
import akka.testkit.TestKit
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.Module
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException
import com.fasterxml.jackson.databind.node.IntNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.afterburner.AfterburnerModule
import com.typesafe.config.ConfigFactory
import org.scalatest.BeforeAndAfterAll
import org.scalatest.Matchers
import org.scalatest.WordSpecLike
object ScalaTestMessages {
trait TestMessage
final case class SimpleCommand(name: String) extends TestMessage
final case class SimpleCommand2(name: String, name2: String) extends TestMessage
final case class OptionCommand(maybe: Option[String]) extends TestMessage
final case class BooleanCommand(published: Boolean) extends TestMessage
final case class TimeCommand(timestamp: LocalDateTime, duration: FiniteDuration) extends TestMessage
final case class CollectionsCommand(strings: List[String], objects: Vector[SimpleCommand]) extends TestMessage
final case class CommandWithActorRef(name: String, replyTo: ActorRef) extends TestMessage
final case class CommandWithTypedActorRef(name: String, replyTo: akka.actor.typed.ActorRef[String])
extends TestMessage
final case class CommandWithAddress(name: String, address: Address) extends TestMessage
final case class Event1(field1: String) extends TestMessage
final case class Event2(field1V2: String, field2: Int) extends TestMessage
final case class Zoo(first: Animal) extends TestMessage
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(
Array(
new JsonSubTypes.Type(value = classOf[Lion], name = "lion"),
new JsonSubTypes.Type(value = classOf[Elephant], name = "elephant")))
sealed trait Animal
final case class Lion(name: String) extends Animal
final case class Elephant(name: String, age: Int) extends Animal
// not defined in JsonSubTypes
final case class Cockroach(name: String) extends Animal
}
class ScalaTestEventMigration extends JacksonMigration {
override def currentVersion = 3
override def transformClassName(fromVersion: Int, className: String): String =
classOf[ScalaTestMessages.Event2].getName
override def transform(fromVersion: Int, json: JsonNode): JsonNode = {
val root = json.asInstanceOf[ObjectNode]
root.set("field1V2", root.get("field1"))
root.remove("field1")
root.set("field2", IntNode.valueOf(17))
root
}
}
class JacksonCborSerializerSpec extends JacksonSerializerSpec("jackson-cbor") {
"JacksonCborSerializer" must {
"have right configured identifier" in {
serialization().serializerFor(classOf[JavaTestMessages.TestMessage]).identifier should ===(
JacksonCborSerializer.Identifier)
}
}
}
class JacksonSmileSerializerSpec extends JacksonSerializerSpec("jackson-smile") {
"JacksonSmileSerializer" must {
"have right configured identifier" in {
serialization().serializerFor(classOf[JavaTestMessages.TestMessage]).identifier should ===(
JacksonSmileSerializer.Identifier)
}
}
}
class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") {
def serializeToJsonString(obj: AnyRef, sys: ActorSystem = system): String = {
val blob = serializeToBinary(obj, sys)
new String(blob, "utf-8")
}
def deserializeFromJsonString(
json: String,
serializerId: Int,
manifest: String,
sys: ActorSystem = system): AnyRef = {
val blob = json.getBytes("utf-8")
deserializeFromBinary(blob, serializerId, manifest, sys)
}
"JacksonJsonSerializer" must {
"have right configured identifier" in {
serialization().serializerFor(classOf[JavaTestMessages.TestMessage]).identifier should ===(
JacksonJsonSerializer.Identifier)
}
"support lookup of same ObjectMapper via JacksonObjectMapperProvider" in {
val mapper = serialization()
.serializerFor(classOf[JavaTestMessages.TestMessage])
.asInstanceOf[JacksonSerializer]
.objectMapper
JacksonObjectMapperProvider(system)
.getOrCreate(JacksonJsonSerializer.Identifier, None) shouldBe theSameInstanceAs(mapper)
val anotherIdentifier = 999
val mapper2 = JacksonObjectMapperProvider(system).getOrCreate(anotherIdentifier, None)
mapper2 should not be theSameInstanceAs(mapper)
JacksonObjectMapperProvider(system).getOrCreate(anotherIdentifier, None) shouldBe theSameInstanceAs(mapper2)
}
}
"JacksonJsonSerializer with Java message classes" must {
import JavaTestMessages._
// see SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
"by default serialize dates and durations as numeric timestamps" in {
val msg = new TimeCommand(LocalDateTime.of(2019, 4, 29, 23, 15, 3, 12345), Duration.of(5, ChronoUnit.SECONDS))
val json = serializeToJsonString(msg)
val expected = """{"timestamp":[2019,4,29,23,15,3,12345],"duration":5.000000000}"""
json should ===(expected)
}
// see SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
"be possible to serialize dates and durations as text with default date format " in {
withSystem("""
akka.serialization.jackson.serialization-features {
WRITE_DATES_AS_TIMESTAMPS = off
}
""") { sys =>
val msg = new TimeCommand(LocalDateTime.of(2019, 4, 29, 23, 15, 3, 12345), Duration.of(5, ChronoUnit.SECONDS))
val json = serializeToJsonString(msg, sys)
// Default format is defined in com.fasterxml.jackson.databind.util.StdDateFormat
// ISO-8601 yyyy-MM-dd'T'HH:mm:ss.SSSZ
// FIXME is this the same as rfc3339, or do we need something else to support interop with the format used by Play JSON?
// FIXME should we make this the default rather than numberic timestamps?
val expected = """{"timestamp":"2019-04-29T23:15:03.000012345","duration":"PT5S"}"""
json should ===(expected)
// and full round trip
checkSerialization(msg)
}
}
// FAIL_ON_UNKNOWN_PROPERTIES = off is default in reference.conf
"not fail on unknown properties" in {
val json = """{"name":"abc","name2":"def","name3":"ghi"}"""
val expected = new SimpleCommand2("abc", "def")
val serializer = serializerFor(expected)
deserializeFromJsonString(json, serializer.identifier, serializer.manifest(expected)) should ===(expected)
}
"be possible to create custom ObjectMapper" in {
pending
}
}
"JacksonJsonSerializer with Scala message classes" must {
import ScalaTestMessages._
"be possible to create custom ObjectMapper" in {
val customJacksonObjectMapperFactory = new JacksonObjectMapperFactory {
override def newObjectMapper(serializerIdentifier: Int, jsonFactory: Option[JsonFactory]): ObjectMapper = {
if (serializerIdentifier == JacksonJsonSerializer.Identifier) {
val mapper = new ObjectMapper(jsonFactory.orNull)
// some customer configuration of the mapper
mapper.setLocale(Locale.US)
mapper
} else
super.newObjectMapper(serializerIdentifier, jsonFactory)
}
override def overrideConfiguredSerializationFeatures(
serializerIdentifier: Int,
configuredFeatures: immutable.Seq[(SerializationFeature, Boolean)])
: immutable.Seq[(SerializationFeature, Boolean)] = {
if (serializerIdentifier == JacksonJsonSerializer.Identifier) {
configuredFeatures :+ (SerializationFeature.INDENT_OUTPUT -> true)
} else
super.overrideConfiguredSerializationFeatures(serializerIdentifier, configuredFeatures)
}
override def overrideConfiguredModules(
serializerIdentifier: Int,
configuredModules: immutable.Seq[Module]): immutable.Seq[Module] =
if (serializerIdentifier == JacksonJsonSerializer.Identifier) {
configuredModules.filterNot(_.isInstanceOf[AfterburnerModule])
} else
super.overrideConfiguredModules(serializerIdentifier, configuredModules)
}
val config = system.settings.config
val setup = ActorSystemSetup()
.withSetup(JacksonObjectMapperProviderSetup(customJacksonObjectMapperFactory))
.withSetup(BootstrapSetup(config))
withSystem(setup) { sys =>
val msg = SimpleCommand2("a", "b")
val json = serializeToJsonString(msg, sys)
// using the custom ObjectMapper with pretty printing enabled
val expected =
"""|{
| "name" : "a",
| "name2" : "b"
|}""".stripMargin
json should ===(expected)
}
}
}
}
abstract class JacksonSerializerSpec(serializerName: String)
extends TestKit(
ActorSystem(
"JacksonJsonSerializerSpec",
ConfigFactory.parseString(s"""
akka.serialization.jackson.migrations {
"akka.serialization.jackson.JavaTestMessages$$Event1" = "akka.serialization.jackson.JavaTestEventMigration"
"akka.serialization.jackson.JavaTestMessages$$Event2" = "akka.serialization.jackson.JavaTestEventMigration"
"akka.serialization.jackson.ScalaTestMessages$$Event1" = "akka.serialization.jackson.ScalaTestEventMigration"
"akka.serialization.jackson.ScalaTestMessages$$Event2" = "akka.serialization.jackson.ScalaTestEventMigration"
}
akka.actor {
allow-java-serialization = off
serialization-bindings {
"akka.serialization.jackson.ScalaTestMessages$$TestMessage" = $serializerName
"akka.serialization.jackson.JavaTestMessages$$TestMessage" = $serializerName
}
}
""")))
with WordSpecLike
with Matchers
with BeforeAndAfterAll {
def serialization(sys: ActorSystem = system): Serialization = SerializationExtension(sys)
override def afterAll(): Unit = {
shutdown()
}
def withSystem[T](config: String)(block: ActorSystem => T): T = {
val sys = ActorSystem(system.name, ConfigFactory.parseString(config).withFallback(system.settings.config))
try {
block(sys)
} finally shutdown(sys)
}
def withSystem[T](setup: ActorSystemSetup)(block: ActorSystem => T): T = {
val sys = ActorSystem(system.name, setup)
try {
block(sys)
} finally shutdown(sys)
}
def withTransportInformation[T](sys: ActorSystem = system)(block: () => T): T = {
Serialization.withTransportInformation(sys.asInstanceOf[ExtendedActorSystem]) { () =>
block()
}
}
def checkSerialization(obj: AnyRef, sys: ActorSystem = system): Unit = {
val serializer = serializerFor(obj, sys)
val manifest = serializer.manifest(obj)
val serializerId = serializer.identifier
val blob = serializeToBinary(obj)
val deserialized = deserializeFromBinary(blob, serializerId, manifest, sys)
deserialized should ===(obj)
}
/**
* @return tuple of (blob, serializerId, manifest)
*/
def serializeToBinary(obj: AnyRef, sys: ActorSystem = system): Array[Byte] = {
withTransportInformation(sys) { () =>
val serializer = serializerFor(obj, sys)
serializer.toBinary(obj)
}
}
def deserializeFromBinary(
blob: Array[Byte],
serializerId: Int,
manifest: String,
sys: ActorSystem = system): AnyRef = {
// TransportInformation added by serialization.deserialize
serialization(sys).deserialize(blob, serializerId, manifest).get
}
def serializerFor(obj: AnyRef, sys: ActorSystem = system): JacksonSerializer =
serialization(sys).findSerializerFor(obj) match {
case serializer: JacksonSerializer serializer
case s
throw new IllegalStateException(s"Wrong serializer ${s.getClass} for ${obj.getClass}")
}
"JacksonSerializer with Java message classes" must {
import JavaTestMessages._
"serialize simple message with one constructor parameter" in {
checkSerialization(new SimpleCommand("Bob"))
}
"serialize simple message with two constructor parameters" in {
checkSerialization(new SimpleCommand2("Bob", "Alice"))
checkSerialization(new SimpleCommand2("Bob", ""))
checkSerialization(new SimpleCommand2("Bob", null))
}
"serialize message with boolean property" in {
checkSerialization(new BooleanCommand(true))
checkSerialization(new BooleanCommand(false))
}
"serialize message with Optional property" in {
checkSerialization(new OptionalCommand(Optional.of("abc")))
checkSerialization(new OptionalCommand(Optional.empty()))
}
"serialize message with collections" in {
val strings = Arrays.asList("a", "b", "c")
val objects = Arrays.asList(new SimpleCommand("a"), new SimpleCommand("2"))
val msg = new CollectionsCommand(strings, objects)
checkSerialization(msg)
}
"serialize message with time" in {
val msg = new TimeCommand(LocalDateTime.now(), Duration.of(5, ChronoUnit.SECONDS))
checkSerialization(msg)
}
"serialize with ActorRef" in {
val echo = system.actorOf(TestActors.echoActorProps)
checkSerialization(new CommandWithActorRef("echo", echo))
}
"serialize with typed.ActorRef" in {
import akka.actor.typed.scaladsl.adapter._
val ref = system.spawnAnonymous(Behaviors.empty[String])
checkSerialization(new CommandWithTypedActorRef("echo", ref))
}
"serialize with Address" in {
val address = Address("akka", "sys", "localhost", 2552)
checkSerialization(new CommandWithAddress("echo", address))
}
"serialize with polymorphism" in {
checkSerialization(new Zoo(new Lion("Simba")))
checkSerialization(new Zoo(new Elephant("Elephant", 49)))
intercept[InvalidTypeIdException] {
// Cockroach not listed in JsonSubTypes
checkSerialization(new Zoo(new Cockroach("huh")))
}
}
"deserialize with migrations" in {
val event1 = new Event1("a")
val serializer = serializerFor(event1)
val blob = serializer.toBinary(event1)
val event2 = serializer.fromBinary(blob, classOf[Event1].getName).asInstanceOf[Event2]
event1.getField1 should ===(event2.getField1V2)
event2.getField2 should ===(17)
}
"deserialize with migrations from V2" in {
val event1 = new Event1("a")
val serializer = serializerFor(event1)
val blob = serializer.toBinary(event1)
val event2 = serializer.fromBinary(blob, classOf[Event1].getName + "#2").asInstanceOf[Event2]
event1.getField1 should ===(event2.getField1V2)
event2.getField2 should ===(17)
}
}
"JacksonSerializer with Scala message classes" must {
import ScalaTestMessages._
"serialize simple message with one constructor parameter" in {
checkSerialization(SimpleCommand("Bob"))
}
"serialize simple message with two constructor parameters" in {
checkSerialization(SimpleCommand2("Bob", "Alice"))
checkSerialization(SimpleCommand2("Bob", ""))
checkSerialization(SimpleCommand2("Bob", null))
}
"serialize message with boolean property" in {
checkSerialization(BooleanCommand(true))
checkSerialization(BooleanCommand(false))
}
"serialize message with Optional property" in {
checkSerialization(OptionCommand(Some("abc")))
checkSerialization(OptionCommand(None))
}
"serialize message with collections" in {
val strings = "a" :: "b" :: "c" :: Nil
val objects = Vector(SimpleCommand("a"), SimpleCommand("2"))
val msg = CollectionsCommand(strings, objects)
checkSerialization(msg)
}
"serialize message with time" in {
val msg = TimeCommand(LocalDateTime.now(), 5.seconds)
checkSerialization(msg)
}
"serialize FiniteDuration as java.time.Duration" in {
withTransportInformation() { () =>
val scalaMsg = TimeCommand(LocalDateTime.now(), 5.seconds)
val scalaSerializer = serializerFor(scalaMsg)
val blob = scalaSerializer.toBinary(scalaMsg)
val javaMsg = new JavaTestMessages.TimeCommand(scalaMsg.timestamp, Duration.ofSeconds(5))
val javaSerializer = serializerFor(javaMsg)
val deserialized = javaSerializer.fromBinary(blob, javaSerializer.manifest(javaMsg))
deserialized should ===(javaMsg)
}
}
"serialize with ActorRef" in {
val echo = system.actorOf(TestActors.echoActorProps)
checkSerialization(CommandWithActorRef("echo", echo))
}
"serialize with typed.ActorRef" in {
import akka.actor.typed.scaladsl.adapter._
val ref = system.spawnAnonymous(Behaviors.empty[String])
checkSerialization(CommandWithTypedActorRef("echo", ref))
}
"serialize with Address" in {
val address = Address("akka", "sys", "localhost", 2552)
checkSerialization(CommandWithAddress("echo", address))
}
"serialize with polymorphism" in {
checkSerialization(Zoo(Lion("Simba")))
checkSerialization(Zoo(Elephant("Elephant", 49)))
intercept[InvalidTypeIdException] {
// Cockroach not listed in JsonSubTypes
checkSerialization(Zoo(Cockroach("huh")))
}
}
"deserialize with migrations" in {
val event1 = Event1("a")
val serializer = serializerFor(event1)
val blob = serializer.toBinary(event1)
val event2 = serializer.fromBinary(blob, classOf[Event1].getName).asInstanceOf[Event2]
event1.field1 should ===(event2.field1V2)
event2.field2 should ===(17)
}
"deserialize with migrations from V2" in {
val event1 = Event1("a")
val serializer = serializerFor(event1)
val blob = serializer.toBinary(event1)
val event2 = serializer.fromBinary(blob, classOf[Event1].getName + "#2").asInstanceOf[Event2]
event1.field1 should ===(event2.field1V2)
event2.field2 should ===(17)
}
"not allow serialization of blacklisted class" in {
val serializer = serializerFor(SimpleCommand("ok"))
val fileHandler = new FileHandler(s"target/tmp-${this.getClass.getName}")
try {
intercept[IllegalArgumentException] {
serializer.manifest(fileHandler)
}.getMessage.toLowerCase should include("blacklist")
} finally fileHandler.close()
}
"not allow deserialization of blacklisted class" in {
withTransportInformation() { () =>
val msg = SimpleCommand("ok")
val serializer = serializerFor(msg)
val blob = serializer.toBinary(msg)
intercept[IllegalArgumentException] {
// maliciously changing manifest
serializer.fromBinary(blob, classOf[FileHandler].getName)
}.getMessage.toLowerCase should include("blacklist")
}
}
"not allow serialization of class that is not in serialization-bindings (whitelist)" in {
val serializer = serializerFor(SimpleCommand("ok"))
intercept[IllegalArgumentException] {
serializer.manifest(Status.Success("bad"))
}.getMessage.toLowerCase should include("whitelist")
}
"not allow deserialization of class that is not in serialization-bindings (whitelist)" in {
withTransportInformation() { () =>
val msg = SimpleCommand("ok")
val serializer = serializerFor(msg)
val blob = serializer.toBinary(msg)
intercept[IllegalArgumentException] {
// maliciously changing manifest
serializer.fromBinary(blob, classOf[Status.Success].getName)
}.getMessage.toLowerCase should include("whitelist")
}
}
"not allow serialization-bindings of open-ended types" in {
JacksonSerializer.disallowedSerializationBindings.foreach { clazz =>
val className = clazz.getName
withClue(className) {
intercept[IllegalArgumentException] {
val sys = ActorSystem(
system.name,
ConfigFactory.parseString(s"""
akka.actor.serialization-bindings {
"$className" = $serializerName
"akka.serialization.jackson.ScalaTestMessages$$TestMessage" = $serializerName
}
""").withFallback(system.settings.config))
try {
SerializationExtension(sys).serialize(SimpleCommand("hi")).get
} finally shutdown(sys)
}
}
}
}
// FIXME test configured modules with `*` and that the Akka modules are found
}
}