From 6122966fca33f9b2ef1ef51edcb583146e480d45 Mon Sep 17 00:00:00 2001 From: Patrik Nordwall Date: Sun, 11 Feb 2018 19:56:52 +0100 Subject: [PATCH] Jackson serializer as replacement for Java serialization, #24155 * Copied from Lagom, with the following differences * Jsonable and CompressedJsonable not included * pcollection and guava modules not enabled by default * added scala and afterburner modules * JSON, CBOR and Smile options (different serializers) * JMH benchmark * jackson version 2.9.9 * test polymorphism * serializer for ActorRef * Address serializer * FiniteDuration serializer, same as java.time.Duration * use blacklist from Jackson databind against gadgets * disallow binding to open ended types, such as java.io.Serializable * Configurable ObjectMapper ser/deser features * testing date formats with WRITE_DATES_AS_TIMESTAMPS on/off * ActorSystemSetup for ObjectMapper creation * and possibility to lookup created ObjectMapper via ObjectMapperProvider extension * createObjectMapper without ActorSystem, needed by Lagom test * add basic docs * skip Scala 2.13 for akka-serialization-jackson for now, until the Jackson Scala module has been released --- .../jackson/JacksonSerializationBench.scala | 130 +++++ akka-docs/src/main/paradox/index-cluster.md | 1 + .../src/main/paradox/serialization-jackson.md | 223 +++++++ .../src/main/resources/reference.conf | 82 +++ .../jackson/ActorRefModule.scala | 68 +++ .../jackson/ActorSystemAccess.scala | 28 + .../serialization/jackson/AddressModule.scala | 60 ++ .../jackson/AkkaJacksonModule.scala | 14 + .../jackson/FiniteDurationModule.scala | 63 ++ .../jackson/JacksonMigration.scala | 46 ++ .../serialization/jackson/JacksonModule.scala | 101 ++++ .../jackson/JacksonObjectMapperProvider.scala | 279 +++++++++ .../jackson/JacksonSerializer.scala | 394 +++++++++++++ .../jackson/JavaTestEventMigration.java | 32 + .../jackson/JavaTestMessages.java | 428 ++++++++++++++ .../serialization/jackson/MySerializable.java | 20 + .../serialization/jackson/v1/Customer.java | 25 + .../serialization/jackson/v1/ItemAdded.java | 21 + .../serialization/jackson/v1/OrderAdded.java | 17 + .../serialization/jackson/v2a/Address.java | 21 + .../serialization/jackson/v2a/Customer.java | 23 + .../jackson/v2a/CustomerMigration.java | 37 ++ .../serialization/jackson/v2a/ItemAdded.java | 37 ++ .../jackson/v2a/OrderPlaced.java | 17 + .../jackson/v2a/OrderPlacedMigration.java | 28 + .../serialization/jackson/v2b/ItemAdded.java | 23 + .../jackson/v2b/ItemAddedMigration.java | 29 + .../serialization/jackson/v2c/ItemAdded.java | 23 + .../jackson/v2c/ItemAddedMigration.java | 30 + .../jackson/JacksonSerializerSpec.scala | 550 ++++++++++++++++++ .../jackson/SerializationDocSpec.scala | 43 ++ build.sbt | 84 +-- project/Dependencies.scala | 34 +- project/OSGi.scala | 141 ++--- 34 files changed, 3049 insertions(+), 103 deletions(-) create mode 100644 akka-bench-jmh/src/main/scala/akka/serialization/jackson/JacksonSerializationBench.scala create mode 100644 akka-docs/src/main/paradox/serialization-jackson.md create mode 100644 akka-serialization-jackson/src/main/resources/reference.conf create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorRefModule.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorSystemAccess.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AddressModule.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AkkaJacksonModule.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/FiniteDurationModule.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonMigration.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonModule.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonObjectMapperProvider.scala create mode 100644 akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonSerializer.scala create mode 100644 akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestEventMigration.java create mode 100644 akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestMessages.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/MySerializable.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/Customer.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/ItemAdded.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/OrderAdded.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Address.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Customer.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/CustomerMigration.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/ItemAdded.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlaced.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlacedMigration.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAdded.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAddedMigration.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAdded.java create mode 100644 akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAddedMigration.java create mode 100644 akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala create mode 100644 akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala diff --git a/akka-bench-jmh/src/main/scala/akka/serialization/jackson/JacksonSerializationBench.scala b/akka-bench-jmh/src/main/scala/akka/serialization/jackson/JacksonSerializationBench.scala new file mode 100644 index 0000000000..83b0e632b3 --- /dev/null +++ b/akka-bench-jmh/src/main/scala/akka/serialization/jackson/JacksonSerializationBench.scala @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2018-2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import java.util.concurrent.TimeUnit + +import scala.concurrent.Await +import scala.concurrent.duration._ + +import akka.actor._ +import akka.serialization.Serialization +import akka.serialization.SerializationExtension +import akka.serialization.SerializerWithStringManifest +import com.typesafe.config.ConfigFactory +import org.openjdk.jmh.annotations._ + +object JacksonSerializationBench { + trait TestMessage + + final case class Small(name: String, num: Int) extends TestMessage + + final case class Medium( + field1: String, + field2: String, + field3: String, + num1: Int, + num2: Int, + num3: Int, + nested1: Small, + nested2: Small, + nested3: Small) + extends TestMessage + + final case class Large( + nested1: Medium, + nested2: Medium, + nested3: Medium, + vector: Vector[Medium], + map: Map[String, Medium]) + extends TestMessage + + // FIXME try with plain java classes (not case class) +} + +@State(Scope.Benchmark) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@BenchmarkMode(Array(Mode.Throughput)) +@Fork(2) +@Warmup(iterations = 4) +@Measurement(iterations = 5) +class JacksonSerializationBench { + import JacksonSerializationBench._ + + val smallMsg1 = Small("abc", 17) + val smallMsg2 = Small("def", 18) + val smallMsg3 = Small("ghi", 19) + val mediumMsg1 = Medium("abc", "def", "ghi", 1, 2, 3, smallMsg1, smallMsg2, smallMsg3) + val mediumMsg2 = Medium("ABC", "DEF", "GHI", 10, 20, 30, smallMsg1, smallMsg2, smallMsg3) + val mediumMsg3 = Medium("abcABC", "defDEF", "ghiGHI", 100, 200, 300, smallMsg1, smallMsg2, smallMsg3) + val largeMsg = Large( + mediumMsg1, + mediumMsg2, + mediumMsg3, + Vector(mediumMsg1, mediumMsg2, mediumMsg3), + Map("a" -> mediumMsg1, "b" -> mediumMsg2, "c" -> mediumMsg3)) + + var system: ActorSystem = _ + var serialization: Serialization = _ + + @Param(Array("jackson-json", "jackson-smile", "jackson-cbor", "java")) + private var serializerName: String = _ + + @Setup(Level.Trial) + def setupTrial(): Unit = { + val config = ConfigFactory.parseString(s""" + akka { + loglevel = WARNING + actor { + serialization-bindings { + "akka.serialization.jackson.JacksonSerializationBench$$TestMessage" = $serializerName + } + } + serialization.jackson { + #compress-larger-than = 100 b + } + } + """) + + system = ActorSystem("JacksonSerializationBench", config) + serialization = SerializationExtension(system) + } + + @TearDown(Level.Trial) + def tearDownTrial(): Unit = { + Await.result(system.terminate(), 5.seconds) + } + + private def serializeDeserialize[T <: AnyRef](msg: T): T = { + serialization.findSerializerFor(msg) match { + case serializer: SerializerWithStringManifest ⇒ + val blob = serializer.toBinary(msg) + serializer.fromBinary(blob, serializer.manifest(msg)).asInstanceOf[T] + case serializer ⇒ + val blob = serializer.toBinary(msg) + if (serializer.includeManifest) + serializer.fromBinary(blob, Some(msg.getClass)).asInstanceOf[T] + else + serializer.fromBinary(blob, None).asInstanceOf[T] + } + + } + + @Benchmark + def small(): Small = { + serializeDeserialize(smallMsg1) + } + + @Benchmark + def medium(): Medium = { + serializeDeserialize(mediumMsg1) + } + + @Benchmark + def large(): Large = { + serializeDeserialize(largeMsg) + } + +} diff --git a/akka-docs/src/main/paradox/index-cluster.md b/akka-docs/src/main/paradox/index-cluster.md index 17a6f42193..d9fba36698 100644 --- a/akka-docs/src/main/paradox/index-cluster.md +++ b/akka-docs/src/main/paradox/index-cluster.md @@ -15,6 +15,7 @@ * [distributed-data](distributed-data.md) * [cluster-dc](cluster-dc.md) * [serialization](serialization.md) +* [serialization-jackson](serialization-jackson.md) * [multi-jvm-testing](multi-jvm-testing.md) * [multi-node-testing](multi-node-testing.md) * [remoting-artery](remoting-artery.md) diff --git a/akka-docs/src/main/paradox/serialization-jackson.md b/akka-docs/src/main/paradox/serialization-jackson.md new file mode 100644 index 0000000000..bef9d76e19 --- /dev/null +++ b/akka-docs/src/main/paradox/serialization-jackson.md @@ -0,0 +1,223 @@ +# Serialization with Jackson + +## Dependency + +To use Serialization, you must add the following dependency in your project: + +@@dependency[sbt,Maven,Gradle] { + group="com.typesafe.akka" + artifact="akka-serialization-jackson_$scala.binary_version$" + version="$akka.version$" +} + +## Introduction + +You find general concepts for for Akka serialization in the @ref:[Serialization](serialization.md) section. +This section describes how to use the Jackson serializer for application specific messages and persistent +event and snapshots. + +[Jackson](https://github.com/FasterXML/jackson) has support for both text based JSON and +binary formats. + +In many cases ordinary classes can be serialized by Jackson without any additional hints, but sometimes +annotations are needed to specify how to convert the objects to JSON/bytes. + +## Usage + +To enable Jackson serialization for a class you need to configure it or one of its super classes +in serialization-bindings configuration. Typically you will create a marker @scala[trait]@java[interface] +for that purpose and let the messages @scala[extend]@java[implement] that. + +Scala +: @@snip [SerializationDocSpec.scala](/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala) { #marker-interface } + +Java +: @@snip [MySerializable.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/MySerializable.java) { #marker-interface } + +Then you configure the class name of marker @scala[trait]@java[interface] in `serialization-bindings` to +one of the supported Jackson formats: `jackson-json`, `jackson-cbor` or `jackson-smile` + +@@snip [config](/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala) { #serialization-bindings } + +That is all that is needed for basic classes where Jackson understands the structure. A few cases that requires +annotations are described below. + +Note that it's only the top level class or its marker @scala[trait]@java[interface] that must be defined in +`serialization-bindings`, not nested classes that it references in member fields. + +@@@ note + +Add the `-parameters` Java compiler option for usage by the [ParameterNamesModule](https://github.com/FasterXML/jackson-modules-java8/tree/master/parameter-names). +It reduces the need for some annotations. + +@@@ + +## Security + +For security reasons it is disallowed to bind the Jackson serializers to +open ended types that might be target for [serialization gadgets](https://medium.com/@cowtowncoder/on-jackson-cves-dont-panic-here-is-what-you-need-to-know-54cd0d6e8062), +such as: + +* `java.lang.Object` +* `java.io.Serializable` +* `java.util.Comparable`. + +The blacklist of possible serialization gadget classes defined by Jackson databind are checked +and disallowed for deserialization. + +### Formats + +The following formats are supported, and you select which one to use in the `serialization-bindings` +configuration as described above. + +* `jackson-json` - ordinary text based JSON +* `jackson-cbor` - binary [CBOR data format](https://github.com/FasterXML/jackson-dataformats-binary/tree/master/cbor) +* `jackson-smile` - binary [Smile data format](https://github.com/FasterXML/jackson-dataformats-binary/tree/master/smile) + +The binary formats are more compact and have slightly better better performance than the JSON format. + +TODO: It's undecided if we will support both CBOR or and Smile since the difference is small + +## Annotations + +TODO examples when annotations are needed + +## Schema Evolution + +When using Event Sourcing, but also for rolling updates, schema evolution becomes an important aspect of +developing your application. The requirements as well as our own understanding of the business domain may +(and will) change over time. + +The Jackson serializer provides a way to perform transformations of the JSON tree model during deserialization. +This is working in the same way for the textual and binary formats. + +We will look at a few scenarios of how the classes may be evolved. + +### Remove Field + +Removing a field can be done without any migration code. The Jackson serializer will ignore properties that does +not exist in the class. + +### Add Field + +Adding an optional field can be done without any migration code. The default value will be @scala[None]@java[`Optional.empty`]. + +Old class: + +Java +: @@snip [ItemAdded.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/ItemAdded.java) { #add-optional } + +TODO: Scala examples + +New class with a new optional `discount` property and a new `note` field with default value: + +Java +: @@snip [ItemAdded.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/ItemAdded.java) { #add-optional } + +Let's say we want to have a mandatory `discount` property without default value instead: + +Java +: @@snip [ItemAdded.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAdded.java) { #add-mandatory } + +To add a new mandatory field we have to use a `JacksonMigration` class and set the default value in the migration code. + +This is how a migration class would look like for adding a `discount` field: + +Java +: @@snip [ItemAddedMigration.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAddedMigration.java) { #add-mandatory } + +Override the `currentVersion` method to define the version number of the current (latest) version. The first version, +when no migration was used, is always 1. Increase this version number whenever you perform a change that is not +backwards compatible without migration code. + +Implement the transformation of the old JSON structure to the new JSON structure in the `transform` method. +The [JsonNode](https://fasterxml.github.io/jackson-databind/javadoc/2.9/com/fasterxml/jackson/databind/JsonNode.html) +is mutable so you can add and remove fields, or change values. Note that you have to cast to specific sub-classes +such as [ObjectNode](https://fasterxml.github.io/jackson-databind/javadoc/2.9/com/fasterxml/jackson/databind/node/ObjectNode.html) +and [ArrayNode](https://fasterxml.github.io/jackson-databind/javadoc/2.9/com/fasterxml/jackson/databind/node/ArrayNode.html) +to get access to mutators. + +The migration class must be defined in configuration file: + +@@snip [config](/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala) { #migrations-conf } + +### Rename Field + +Let's say that we want to rename the `productId` field to `itemId` in the previous example. + +Java +: @@snip [ItemAdded.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAdded.java) { #rename } + +The migration code would look like: + +Java +: @@snip [ItemAddedMigration.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAddedMigration.java) { #rename } + +### Structural Changes + +In a similar way we can do arbitrary structural changes. + +Old class: + +Java +: @@snip [Customer.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/Customer.java) { #structural } + +New class: + +Java +: @@snip [Customer.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Customer.java) { #structural } + +with the `Address` class: + +Java +: @@snip [Address.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Address.java) { #structural } + +The migration code would look like: + +Java +: @@snip [CustomerMigration.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/CustomerMigration.java) { #structural } + +### Rename Class + +It is also possible to rename the class. For example, let's rename `OrderAdded` to `OrderPlaced`. + +Old class: + +Java +: @@snip [OrderAdded.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/OrderAdded.java) { #rename-class } + +New class: + +Java +: @@snip [OrderPlaced.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlaced.java) { #rename-class } + +The migration code would look like: + +Java +: @@snip [OrderPlacedMigration.java](/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlacedMigration.java) { #rename-class } + +Note the override of the `transformClassName` method to define the new class name. + +That type of migration must be configured with the old class name as key. The actual class can be removed. + +@@snip [config](/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala) { #migrations-conf-rename } + +## Jackson Modules + +The following Jackson modules are enabled by default: + +@@snip [reference.conf](/akka-serialization-jackson/src/main/resources/reference.conf) { #jackson-modules } + +You can amend the configuration `akka.serialization.jackson.jackson-modules` to enable other modules. + +The [ParameterNamesModule](https://github.com/FasterXML/jackson-modules-java8/tree/master/parameter-names) requires that the `-parameters` +Java compiler option is enabled. + +### Compression + +JSON can be rather verbose and for large messages it can be beneficial compress large payloads. Messages larger +than the following configuration are compressed with GZIP. + +@@snip [reference.conf](/akka-serialization-jackson/src/main/resources/reference.conf) { #compression } + +TODO: The binary formats are currently also compressed. That may change since it might not be needed for those. diff --git a/akka-serialization-jackson/src/main/resources/reference.conf b/akka-serialization-jackson/src/main/resources/reference.conf new file mode 100644 index 0000000000..153fc1c8ad --- /dev/null +++ b/akka-serialization-jackson/src/main/resources/reference.conf @@ -0,0 +1,82 @@ +########################################## +# Akka Serialization Jackson Config File # +########################################## + +# This is the reference config file that contains all the default settings. +# Make your edits/overrides in your application.conf. + +#//#jackson-modules +akka.serialization.jackson { + + # The Jackson JSON serializer will register these modules. + # It is also possible to use jackson-modules = ["*"] to dynamically + # find and register all modules in the classpath. + jackson-modules += "akka.serialization.jackson.AkkaJacksonModule" + jackson-modules += "com.fasterxml.jackson.module.paramnames.ParameterNamesModule" + jackson-modules += "com.fasterxml.jackson.datatype.jdk8.Jdk8Module" + jackson-modules += "com.fasterxml.jackson.datatype.jsr310.JavaTimeModule" + jackson-modules += "com.fasterxml.jackson.module.scala.DefaultScalaModule" + jackson-modules += "com.fasterxml.jackson.module.afterburner.AfterburnerModule" + #jackson-modules += "com.fasterxml.jackson.datatype.pcollections.PCollectionsModule" + #jackson-modules += "com.fasterxml.jackson.datatype.guava.GuavaModule" +} +#//#jackson-modules + +#//#compression +akka.serialization.jackson { + # The serializer will compress the payload when it's larger than this value. + compress-larger-than = 10 KiB +} +#//#compression + +akka.serialization.jackson { + # When enabled and akka.loglevel=DEBUG serialization time and payload size + # is logged for each messages. + verbose-debug-logging = off + + # Define data migration transformations of old formats to current + # format here as a mapping between the (old) class name to be + # transformed to the JacksonJsonMigration class that implements + # the transformation. + migrations { + } + + # Configuration of the ObjectMapper serialization features. + # See com.fasterxml.jackson.databind.SerializationFeature + # Enum values corresponding to the SerializationFeature and their boolean value. + serialization-features { + + } + + # Configuration of the ObjectMapper deserialization features. + # See com.fasterxml.jackson.databind.SeserializationFeature + # Enum values corresponding to the DeserializationFeature and their boolean value. + deserialization-features { + FAIL_ON_UNKNOWN_PROPERTIES = off + } + + +} + +akka.actor { + serializers { + jackson-json = "akka.serialization.jackson.JacksonJsonSerializer" + jackson-cbor = "akka.serialization.jackson.JacksonCborSerializer" + jackson-smile = "akka.serialization.jackson.JacksonSmileSerializer" + } + serialization-bindings { + # Define bindings for classes or interfaces use Jackson serializer, e.g. + # "com.example.Jsonable" = jackson-json + # "com.example.MyMessage" = jackson-cbor + # + # For security reasons it is disallowed to bind the Jackson serializers to + # open ended types that might be target to be deserialization gadgets, such as + # java.lang.Object, java.io.Serializable, java.util.Comparable + + } + serialization-identifiers { + "akka.serialization.jackson.JacksonJsonSerializer" = 31 + "akka.serialization.jackson.JacksonCborSerializer" = 32 + "akka.serialization.jackson.JacksonSmileSerializer" = 33 + } +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorRefModule.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorRefModule.scala new file mode 100644 index 0000000000..86e848dfd9 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorRefModule.scala @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +// FIXME maybe move many things to `akka.serialization.jackson.internal` package? + +import akka.actor.ActorRef +import akka.annotation.InternalApi +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonTokenId +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer + +// FIXME add serializer for Typed ActorRef also (probably have to be in akka-cluster-typed module) + +/** + * INTERNAL API: Adds support for serializing and deserializing [[ActorRef]]. + */ +@InternalApi private[akka] trait ActorRefModule extends JacksonModule { + addSerializer(classOf[ActorRef], () => ActorRefSerializer.instance, () => ActorRefDeserializer.instance) +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object ActorRefSerializer { + val instance: ActorRefSerializer = new ActorRefSerializer +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] class ActorRefSerializer + extends StdScalarSerializer[ActorRef](classOf[ActorRef]) + with ActorSystemAccess { + override def serialize(value: ActorRef, jgen: JsonGenerator, provider: SerializerProvider): Unit = { + val serializedActorRef = value.path.toSerializationFormatWithAddress(currentSystem().provider.getDefaultAddress) + jgen.writeString(serializedActorRef) + } +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object ActorRefDeserializer { + val instance: ActorRefDeserializer = new ActorRefDeserializer +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] class ActorRefDeserializer + extends StdScalarDeserializer[ActorRef](classOf[ActorRef]) + with ActorSystemAccess { + + def deserialize(jp: JsonParser, ctxt: DeserializationContext): ActorRef = { + if (jp.currentTokenId() == JsonTokenId.ID_STRING) { + val serializedActorRef = jp.getText() + currentSystem().provider.resolveActorRef(serializedActorRef) + } else + ctxt.handleUnexpectedToken(handledType(), jp).asInstanceOf[ActorRef] + } +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorSystemAccess.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorSystemAccess.scala new file mode 100644 index 0000000000..adddb01ea5 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/ActorSystemAccess.scala @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import akka.actor.ExtendedActorSystem +import akka.annotation.InternalApi +import akka.serialization.Serialization + +/** + * INTERNAL API + */ +@InternalApi private[akka] trait ActorSystemAccess { + def currentSystem(): ExtendedActorSystem = { + Serialization.currentTransportInformation.value match { + case null => + throw new IllegalStateException( + "Can't access current ActorSystem, Serialization.currentTransportInformation was not set.") + case Serialization.Information(_, system) => system.asInstanceOf[ExtendedActorSystem] + } + } +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object ActorSystemAccess extends ActorSystemAccess diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AddressModule.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AddressModule.scala new file mode 100644 index 0000000000..dde076e4a8 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AddressModule.scala @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import akka.actor.Address +import akka.actor.AddressFromURIString +import akka.annotation.InternalApi +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonTokenId +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer + +/** + * INTERNAL API: Adds support for serializing and deserializing [[Address]]. + */ +@InternalApi private[akka] trait AddressModule extends JacksonModule { + addSerializer(classOf[Address], () => AddressSerializer.instance, () => AddressDeserializer.instance) +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object AddressSerializer { + val instance: AddressSerializer = new AddressSerializer +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] class AddressSerializer extends StdScalarSerializer[Address](classOf[Address]) { + override def serialize(value: Address, jgen: JsonGenerator, provider: SerializerProvider): Unit = { + jgen.writeString(value.toString) + } +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object AddressDeserializer { + val instance: AddressDeserializer = new AddressDeserializer +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] class AddressDeserializer extends StdScalarDeserializer[Address](classOf[Address]) { + + def deserialize(jp: JsonParser, ctxt: DeserializationContext): Address = { + if (jp.currentTokenId() == JsonTokenId.ID_STRING) { + val serializedAddress = jp.getText() + AddressFromURIString(serializedAddress) + } else + ctxt.handleUnexpectedToken(handledType(), jp).asInstanceOf[Address] + } +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AkkaJacksonModule.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AkkaJacksonModule.scala new file mode 100644 index 0000000000..9a84109146 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/AkkaJacksonModule.scala @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +/** + * Complete module with support for all custom serializers. + */ +class AkkaJacksonModule extends JacksonModule with ActorRefModule with AddressModule with FiniteDurationModule { + override def getModuleName = "AkkaJacksonModule" +} + +object AkkaJacksonModule extends AkkaJacksonModule diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/FiniteDurationModule.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/FiniteDurationModule.scala new file mode 100644 index 0000000000..9b6f575561 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/FiniteDurationModule.scala @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import scala.concurrent.duration.FiniteDuration + +import akka.annotation.InternalApi +import akka.util.JavaDurationConverters._ +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer +import com.fasterxml.jackson.datatype.jsr310.deser.DurationDeserializer +import com.fasterxml.jackson.datatype.jsr310.ser.DurationSerializer + +/** + * INTERNAL API: Adds support for serializing and deserializing [[FiniteDuration]]. + */ +@InternalApi private[akka] trait FiniteDurationModule extends JacksonModule { + addSerializer( + classOf[FiniteDuration], + () => FiniteDurationSerializer.instance, + () => FiniteDurationDeserializer.instance) +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object FiniteDurationSerializer { + val instance: FiniteDurationSerializer = new FiniteDurationSerializer +} + +/** + * INTERNAL API: Delegates to DurationSerializer in `jackson-modules-java8` + */ +@InternalApi private[akka] class FiniteDurationSerializer + extends StdScalarSerializer[FiniteDuration](classOf[FiniteDuration]) { + override def serialize(value: FiniteDuration, jgen: JsonGenerator, provider: SerializerProvider): Unit = { + DurationSerializer.INSTANCE.serialize(value.asJava, jgen, provider) + } +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object FiniteDurationDeserializer { + val instance: FiniteDurationDeserializer = new FiniteDurationDeserializer +} + +/** + * INTERNAL API: Delegates to DurationDeserializer in `jackson-modules-java8` + */ +@InternalApi private[akka] class FiniteDurationDeserializer + extends StdScalarDeserializer[FiniteDuration](classOf[FiniteDuration]) { + + def deserialize(jp: JsonParser, ctxt: DeserializationContext): FiniteDuration = { + DurationDeserializer.INSTANCE.deserialize(jp, ctxt).asScala + } +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonMigration.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonMigration.scala new file mode 100644 index 0000000000..18faeb37a4 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonMigration.scala @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016-2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import com.fasterxml.jackson.databind.JsonNode +import akka.util.unused + +/** + * Data migration of old formats to current format can + * be implemented in a concrete subclass and configured to + * be used by the `JacksonSerializer` for a changed class. + * + * It is used when deserializing data of older version than the + * [[JacksonMigration#currentVersion]]. You implement the transformation of the + * JSON structure in the [[JacksonMigration#transform]] method. If you have changed the + * class name you should override [[JacksonMigration#transformClassName]] and return + * current class name. + */ +abstract class JacksonMigration { + + /** + * Define current version. The first version, when no migration was used, + * is always 1. + */ + def currentVersion: Int + + /** + * Override this method if you have changed the class name. Return + * current class name. + */ + def transformClassName(@unused fromVersion: Int, className: String): String = + className + + /** + * Implement the transformation of the old JSON structure to the new + * JSON structure. The `JsonNode` is mutable so you can add and remove fields, + * or change values. Note that you have to cast to specific sub-classes such + * as `ObjectNode` and `ArrayNode` to get access to mutators. + * + * @param fromVersion the version of the old data + * @param json the old JSON data + */ + def transform(fromVersion: Int, json: JsonNode): JsonNode +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonModule.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonModule.scala new file mode 100644 index 0000000000..0423a09ed2 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonModule.scala @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import akka.annotation.InternalApi +import com.fasterxml.jackson.core.Version +import com.fasterxml.jackson.core.util.VersionUtil +import com.fasterxml.jackson.databind.BeanDescription +import com.fasterxml.jackson.databind.DeserializationConfig +import com.fasterxml.jackson.databind.JavaType +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.Module +import com.fasterxml.jackson.databind.Module.SetupContext +import com.fasterxml.jackson.databind.SerializationConfig +import com.fasterxml.jackson.databind.`type`.TypeModifier +import com.fasterxml.jackson.databind.deser.Deserializers +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier +import com.fasterxml.jackson.databind.ser.Serializers + +/** + * INTERNAL API + */ +@InternalApi private[akka] object JacksonModule { + + lazy val version: Version = { + val groupId = "com.typesafe.akka" + val artifactId = "akka-serialization-jackson" + val version = akka.Version.current + VersionUtil.parseVersion(version, groupId, artifactId) + } + + class SerializerResolverByClass(clazz: Class[_], deserializer: () => JsonSerializer[_]) extends Serializers.Base { + + override def findSerializer( + config: SerializationConfig, + javaType: JavaType, + beanDesc: BeanDescription): JsonSerializer[_] = { + if (clazz.isAssignableFrom(javaType.getRawClass)) + deserializer() + else + super.findSerializer(config, javaType, beanDesc) + } + + } + + class DeserializerResolverByClass(clazz: Class[_], serializer: () => JsonDeserializer[_]) extends Deserializers.Base { + + override def findBeanDeserializer( + javaType: JavaType, + config: DeserializationConfig, + beanDesc: BeanDescription): JsonDeserializer[_] = { + if (clazz.isAssignableFrom(javaType.getRawClass)) + serializer() + else + super.findBeanDeserializer(javaType, config, beanDesc) + } + + } +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] object VersionExtractor { + def unapply(v: Version) = Some((v.getMajorVersion, v.getMinorVersion)) +} + +/** + * INTERNAL API + */ +@InternalApi private[akka] trait JacksonModule extends Module { + import JacksonModule._ + + private val initializers = Seq.newBuilder[SetupContext => Unit] + + def version: Version = JacksonModule.version + + def setupModule(context: SetupContext): Unit = { + initializers.result().foreach(_.apply(context)) + } + + def addSerializer( + clazz: Class[_], + serializer: () => JsonSerializer[_], + deserializer: () => JsonDeserializer[_]): this.type = { + this += { ctx => + ctx.addSerializers(new SerializerResolverByClass(clazz, serializer)) + ctx.addDeserializers(new DeserializerResolverByClass(clazz, deserializer)) + } + } + + protected def +=(init: SetupContext => Unit): this.type = { initializers += init; this } + protected def +=(ser: Serializers): this.type = this += (_.addSerializers(ser)) + protected def +=(deser: Deserializers): this.type = this += (_.addDeserializers(deser)) + protected def +=(typeMod: TypeModifier): this.type = this += (_.addTypeModifier(typeMod)) + protected def +=(beanSerMod: BeanSerializerModifier): this.type = this += (_.addBeanSerializerModifier(beanSerMod)) + +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonObjectMapperProvider.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonObjectMapperProvider.scala new file mode 100644 index 0000000000..836a9865cd --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonObjectMapperProvider.scala @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2016-2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import java.util.concurrent.ConcurrentHashMap + +import scala.collection.immutable +import scala.util.Failure +import scala.util.Success + +import akka.actor.ActorSystem +import akka.actor.DynamicAccess +import akka.actor.ExtendedActorSystem +import akka.actor.Extension +import akka.actor.ExtensionId +import akka.actor.ExtensionIdProvider +import akka.actor.setup.Setup +import akka.annotation.InternalStableApi +import akka.event.Logging +import akka.event.LoggingAdapter +import akka.util.unused +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.Module +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule +import com.typesafe.config.Config + +object JacksonObjectMapperProvider extends ExtensionId[JacksonObjectMapperProvider] with ExtensionIdProvider { + override def get(system: ActorSystem): JacksonObjectMapperProvider = super.get(system) + + override def lookup = JacksonObjectMapperProvider + + override def createExtension(system: ExtendedActorSystem): JacksonObjectMapperProvider = + new JacksonObjectMapperProvider(system) + + /** + * INTERNAL API: Use [[JacksonObjectMapperProvider#create]] + * + * This is needed by one test in Lagom where the ObjectMapper is created without starting and ActorSystem. + */ + @InternalStableApi + def createObjectMapper( + serializerIdentifier: Int, + jsonFactory: Option[JsonFactory], + objectMapperFactory: JacksonObjectMapperFactory, + config: Config, + dynamicAccess: DynamicAccess, + log: Option[LoggingAdapter]) = { + import scala.collection.JavaConverters._ + + val mapper = objectMapperFactory.newObjectMapper(serializerIdentifier, jsonFactory) + + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + + val configuredSerializationFeatures = + features(config, "akka.serialization.jackson.serialization-features").map { + case (enumName, value) => SerializationFeature.valueOf(enumName) -> value + } + val serializationFeatures = + objectMapperFactory.overrideConfiguredSerializationFeatures(serializerIdentifier, configuredSerializationFeatures) + serializationFeatures.foreach { + case (feature, value) => mapper.configure(feature, value) + } + + val configuredDeserializationFeatures = + features(config, "akka.serialization.jackson.deserialization-features").map { + case (enumName, value) => DeserializationFeature.valueOf(enumName) -> value + } + val deserializationFeatures = + objectMapperFactory.overrideConfiguredDeserializationFeatures( + serializerIdentifier, + configuredDeserializationFeatures) + deserializationFeatures.foreach { + case (feature, value) => mapper.configure(feature, value) + } + + val configuredModules = config.getStringList("akka.serialization.jackson.jackson-modules").asScala + val modules1 = + if (configuredModules.contains("*")) + ObjectMapper.findModules(dynamicAccess.classLoader).asScala + else + configuredModules.flatMap { fqcn ⇒ + dynamicAccess.createInstanceFor[Module](fqcn, Nil) match { + case Success(m) ⇒ Some(m) + case Failure(e) ⇒ + log.foreach( + _.error( + e, + s"Could not load configured Jackson module [$fqcn], " + + "please verify classpath dependencies or amend the configuration " + + "[akka.serialization.jackson-modules]. Continuing without this module.")) + None + } + } + + val modules2 = modules1.map { module ⇒ + if (module.isInstanceOf[ParameterNamesModule]) + // ParameterNamesModule needs a special case for the constructor to ensure that single-parameter + // constructors are handled the same way as constructors with multiple parameters. + // See https://github.com/FasterXML/jackson-module-parameter-names#delegating-creator + new ParameterNamesModule(JsonCreator.Mode.PROPERTIES) + else module + }.toList + + val modules3 = objectMapperFactory.overrideConfiguredModules(serializerIdentifier, modules2) + + modules3.foreach { module => + mapper.registerModule(module) + log.foreach(_.debug("Registered Jackson module [{}]", module.getClass.getName)) + } + + mapper + } + + private def features(config: Config, section: String): immutable.Seq[(String, Boolean)] = { + import scala.collection.JavaConverters._ + val cfg = config.getConfig(section) + cfg.root.keySet().asScala.map(key => key -> cfg.getBoolean(key)).toList + } +} + +// FIXME docs +final class JacksonObjectMapperProvider(system: ExtendedActorSystem) extends Extension { + private val objectMappers = new ConcurrentHashMap[Int, ObjectMapper] + + /** + * Returns an existing Jackson `ObjectMapper` that was created previously with this method, or + * creates a new instance. + * + * The `ObjectMapper` is created with sensible defaults and modules configured + * in `akka.serialization.jackson.jackson-modules`. It's using [[JacksonObjectMapperProviderSetup]] + * if the `ActorSystem` is started with such [[akka.actor.setup.ActorSystemSetup]]. + * + * The returned `ObjecctMapper` must not be modified, because it may already be in use and such + * modifications are not thread-safe. + * + * @param serializerIdentifier the identifier of the serializer that is using this `ObjectMapper`, + * there will be one `ObjectInstance` per serializer + * @param jsonFactory optional `JsonFactory` such as `SmileFactory`, for plain JSON `None` (defaults) + * can be used + */ + def getOrCreate(serializerIdentifier: Int, jsonFactory: Option[JsonFactory]): ObjectMapper = { + objectMappers.computeIfAbsent(serializerIdentifier, _ => create(serializerIdentifier, jsonFactory)) + } + + // FIXME Java API, Optional vs Option + + /** + * Creates a new instance of a Jackson `ObjectMapper` with sensible defaults and modules configured + * in `akka.serialization.jackson.jackson-modules`. It's using [[JacksonObjectMapperProviderSetup]] + * if the `ActorSystem` is started with such [[akka.actor.setup.ActorSystemSetup]]. + * + * @param serializerIdentifier the identifier of the serializer that is using this `ObjectMapper`, + * there will be one `ObjectInstance` per serializer + * @param jsonFactory optional `JsonFactory` such as `SmileFactory`, for plain JSON `None` (defaults) + * can be used + * @see [[JacksonObjectMapperProvider#getOrCreate]] + */ + def create(serializerIdentifier: Int, jsonFactory: Option[JsonFactory]): ObjectMapper = { + val log = Logging.getLogger(system, JacksonObjectMapperProvider.getClass) + val config = system.settings.config + val dynamicAccess = system.dynamicAccess + + val factory = system.settings.setup.get[JacksonObjectMapperProviderSetup] match { + case Some(setup) => setup.factory + case None => new JacksonObjectMapperFactory // default + } + + JacksonObjectMapperProvider.createObjectMapper( + serializerIdentifier, + jsonFactory, + factory, + config, + dynamicAccess, + Some(log)) + } + +} + +object JacksonObjectMapperProviderSetup { + + /** + * Scala API: factory for defining a `JacksonObjectMapperProvider` that is passed in when ActorSystem + * is created rather than creating one from configured class name. + */ + def apply(factory: JacksonObjectMapperFactory): JacksonObjectMapperProviderSetup = + new JacksonObjectMapperProviderSetup(factory) + + /** + * Java API: factory for defining a `JacksonObjectMapperProvider` that is passed in when ActorSystem + * is created rather than creating one from configured class name. + */ + def create(factory: JacksonObjectMapperFactory): JacksonObjectMapperProviderSetup = + apply(factory) + +} + +/** + * Setup for defining a `JacksonObjectMapperProvider` that can be passed in when ActorSystem + * is created rather than creating one from configured class name. Create a subclass of + * [[JacksonObjectMapperFactory]] and override the methods to amend the defaults. + */ +final class JacksonObjectMapperProviderSetup(val factory: JacksonObjectMapperFactory) extends Setup + +/** + * Used with [[JacksonObjectMapperProviderSetup]] for defining a `JacksonObjectMapperProvider` that can be + * passed in when ActorSystem is created rather than creating one from configured class name. + * Create a subclass and override the methods to amend the defaults. + */ +class JacksonObjectMapperFactory { + + /** + * Override this method to create a new custom instance of `ObjectMapper` for the given `serializerIdentifier`. + * + * @param serializerIdentifier the identifier of the serializer that is using this `ObjectMapper`, + * there will be one `ObjectInstance` per serializer + * @param jsonFactory optional `JsonFactory` such as `SmileFactory`, for plain JSON `None` (defaults) + * can be used + */ + def newObjectMapper(@unused serializerIdentifier: Int, jsonFactory: Option[JsonFactory]): ObjectMapper = + new ObjectMapper(jsonFactory.orNull) + + // FIXME Java API + + /** + * After construction of the `ObjectMapper` the configured serialization features are applied to + * the mapper. These features can be amended programatically by overriding this method and + * return the features that are to be applied to the `ObjectMapper`. + * + * @param serializerIdentifier the identifier of the serializer that is using this `ObjectMapper`, + * there will be one `ObjectInstance` per serializer + * @param configuredFeatures the list of `SerializationFeature` that were configured in + * `akka.serialization.jackson.serialization-features` + */ + def overrideConfiguredSerializationFeatures( + @unused serializerIdentifier: Int, + configuredFeatures: immutable.Seq[(SerializationFeature, Boolean)]) + : immutable.Seq[(SerializationFeature, Boolean)] = + configuredFeatures + + /** + * After construction of the `ObjectMapper` the configured deserialization features are applied to + * the mapper. These features can be amended programatically by overriding this method and + * return the features that are to be applied to the `ObjectMapper`. + * + * @param serializerIdentifier the identifier of the serializer that is using this `ObjectMapper`, + * there will be one `ObjectInstance` per serializer + * @param configuredFeatures the list of `DeserializationFeature` that were configured in + * `akka.serialization.jackson.deserialization-features` + */ + def overrideConfiguredDeserializationFeatures( + @unused serializerIdentifier: Int, + configuredFeatures: immutable.Seq[(DeserializationFeature, Boolean)]) + : immutable.Seq[(DeserializationFeature, Boolean)] = + configuredFeatures + + /** + * After construction of the `ObjectMapper` the configured modules are added to + * the mapper. These modules can be amended programatically by overriding this method and + * return the modules that are to be applied to the `ObjectMapper`. + * + * @param serializerIdentifier the identifier of the serializer that is using this `ObjectMapper`, + * there will be one `ObjectInstance` per serializer + * @param configuredModules the list of `Modules` that were configured in + * `akka.serialization.jackson.deserialization-features` + */ + def overrideConfiguredModules( + @unused serializerIdentifier: Int, + configuredModules: immutable.Seq[Module]): immutable.Seq[Module] = + configuredModules + +} diff --git a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonSerializer.scala b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonSerializer.scala new file mode 100644 index 0000000000..56dcf8ee23 --- /dev/null +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonSerializer.scala @@ -0,0 +1,394 @@ +/* + * Copyright (C) 2016-2019 Lightbend Inc. + */ + +package akka.serialization.jackson + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.NotSerializableException +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +import scala.annotation.tailrec +import scala.util.Failure +import scala.util.Success +import scala.util.control.NonFatal + +import akka.actor.ExtendedActorSystem +import akka.annotation.InternalApi +import akka.event.LogMarker +import akka.event.Logging +import akka.serialization.BaseSerializer +import akka.serialization.SerializationExtension +import akka.serialization.SerializerWithStringManifest +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.impl.SubTypeValidator +import com.fasterxml.jackson.dataformat.cbor.CBORFactory +import com.fasterxml.jackson.dataformat.smile.SmileFactory + +/** + * INTERNAL API + */ +@InternalApi private[akka] object JacksonSerializer { + + /** + * Using the blacklist from Jackson databind of class names that shouldn't be allowed. + * Not nice to depend on implementation details of Jackson, but good to use the same + * list to automatically have the list updated when new classes are added in Jackson. + */ + class GadgetClassBlacklist extends SubTypeValidator { + + private def defaultNoDeserClassNames: java.util.Set[String] = + SubTypeValidator.DEFAULT_NO_DESER_CLASS_NAMES // it's has protected visibility + + private val prefixSpring: String = "org.springframework." + private val prefixC3P0: String = "com.mchange.v2.c3p0." + + def isAllowedClassName(className: String): Boolean = { + if (defaultNoDeserClassNames.contains(className)) + false + else if (className.startsWith(prefixC3P0) && className.endsWith("DataSource")) + false + else + true + } + + def isAllowedClass(clazz: Class[_]): Boolean = { + if (clazz.getName.startsWith(prefixSpring)) { + isAllowedSpringClass(clazz) + } else + true + } + + @tailrec private def isAllowedSpringClass(clazz: Class[_]): Boolean = { + if (clazz == null || clazz.equals(classOf[java.lang.Object])) + true + else { + val name = clazz.getSimpleName + // looking for "AbstractBeanFactoryPointcutAdvisor" but no point to allow any is there? + if ("AbstractPointcutAdvisor".equals(name) + // ditto for "FileSystemXmlApplicationContext": block all ApplicationContexts + || "AbstractApplicationContext".equals(name)) + false + else + isAllowedSpringClass(clazz.getSuperclass) + } + } + } + + val disallowedSerializationBindings: Set[Class[_]] = + Set(classOf[java.io.Serializable], classOf[java.io.Serializable], classOf[java.lang.Comparable[_]]) + +} + +object JacksonJsonSerializer { + val Identifier = 31 +} + +/** + * INTERNAL API: only public by configuration + * + * Akka serializer for Jackson with JSON. + */ +@InternalApi private[akka] final class JacksonJsonSerializer(system: ExtendedActorSystem) + extends JacksonSerializer( + system, + JacksonObjectMapperProvider(system).getOrCreate(JacksonJsonSerializer.Identifier, None)) + +object JacksonSmileSerializer { + val Identifier = 33 +} + +/** + * INTERNAL API: only public by configuration + * + * Akka serializer for Jackson with Smile. + */ +@InternalApi private[akka] final class JacksonSmileSerializer(system: ExtendedActorSystem) + extends JacksonSerializer( + system, + JacksonObjectMapperProvider(system).getOrCreate(JacksonSmileSerializer.Identifier, Some(new SmileFactory))) + +object JacksonCborSerializer { + val Identifier = 32 +} + +/** + * INTERNAL API: only public by configuration + * + * Akka serializer for Jackson with CBOR. + */ +@InternalApi private[akka] final class JacksonCborSerializer(system: ExtendedActorSystem) + extends JacksonSerializer( + system, + JacksonObjectMapperProvider(system).getOrCreate(JacksonCborSerializer.Identifier, Some(new CBORFactory))) + +// FIXME Look into if we should support both Smile and CBOR, and what we should recommend if there is a choice. +// Make dependencies optional/provided. + +/** + * INTERNAL API: Base class for Jackson serializers. + * + * Configuration in `akka.serialization.jackson` section. + * It will load Jackson modules defined in configuration `jackson-modules`. + * + * It will compress the payload if the the payload is larger than the configured + * `compress-larger-than` value. + */ +@InternalApi private[akka] abstract class JacksonSerializer( + val system: ExtendedActorSystem, + val objectMapper: ObjectMapper) + extends SerializerWithStringManifest + with BaseSerializer { + import JacksonSerializer.GadgetClassBlacklist + + // FIXME it should be possible to implement ByteBufferSerializer as well, using Jackson's + // ByteBufferBackedOutputStream/ByteBufferBackedInputStream + + private val log = Logging.withMarker(system, getClass) + private val conf = system.settings.config.getConfig("akka.serialization.jackson") + private val isDebugEnabled = conf.getBoolean("verbose-debug-logging") && log.isDebugEnabled + private final val BufferSize = 1024 * 4 + private val compressLargerThan: Long = conf.getBytes("compress-larger-than") + private val migrations: Map[String, JacksonMigration] = { + import scala.collection.JavaConverters._ + conf.getConfig("migrations").root.unwrapped.asScala.toMap.map { + case (k, v) ⇒ + val transformer = system.dynamicAccess.createInstanceFor[JacksonMigration](v.toString, Nil).get + k -> transformer + } + } + private val blacklist: GadgetClassBlacklist = new GadgetClassBlacklist + + // This must lazy otherwise it will deadlock the ActorSystem creation + private lazy val serialization = SerializationExtension(system) + + // doesn't have to be volatile, doesn't matter if check is run more than once + private var serializationBindingsCheckedOk = false + + override def manifest(obj: AnyRef): String = { + checkAllowedSerializationBindings() + val className = obj.getClass.getName + checkAllowedClassName(className) + checkAllowedClass(obj.getClass) + migrations.get(className) match { + case Some(transformer) ⇒ className + "#" + transformer.currentVersion + case None ⇒ className + } + } + + override def toBinary(obj: AnyRef): Array[Byte] = { + checkAllowedSerializationBindings() + val startTime = if (isDebugEnabled) System.nanoTime else 0L + val bytes = objectMapper.writeValueAsBytes(obj) + // FIXME investigate if compression should be used for the binary formats + val result = + if (bytes.length > compressLargerThan) compress(bytes) + else bytes + + if (isDebugEnabled) { + val durationMicros = (System.nanoTime - startTime) / 1000 + if (bytes.length == result.length) + log.debug( + "Serialization of [{}] took [{}] µs, size [{}] bytes", + obj.getClass.getName, + durationMicros, + result.length) + else + log.debug( + "Serialization of [{}] took [{}] µs, compressed size [{}] bytes, uncompressed size [{}] bytes", + obj.getClass.getName, + durationMicros, + result.length, + bytes.length) + } + + result + } + + override def fromBinary(bytes: Array[Byte], manifest: String): AnyRef = { + checkAllowedSerializationBindings() + val startTime = if (isDebugEnabled) System.nanoTime else 0L + val compressed = isGZipped(bytes) + + val (fromVersion, manifestClassName) = parseManifest(manifest) + checkAllowedClassName(manifestClassName) + + val migration = migrations.get(manifestClassName) + + val className = migration match { + case Some(transformer) if fromVersion < transformer.currentVersion ⇒ + transformer.transformClassName(fromVersion, manifestClassName) + case Some(transformer) if fromVersion > transformer.currentVersion ⇒ + throw new IllegalStateException( + s"Migration version ${transformer.currentVersion} is " + + s"behind version $fromVersion of deserialized type [$manifestClassName]") + case _ ⇒ manifestClassName + } + if (className ne manifestClassName) + checkAllowedClassName(className) + + val clazz = system.dynamicAccess.getClassFor[AnyRef](className) match { + case Success(c) ⇒ c + case Failure(_) ⇒ + throw new NotSerializableException( + s"Cannot find manifest class [$className] for serializer [${getClass.getName}].") + } + checkAllowedClass(clazz) + + val decompressBytes = if (compressed) decompress(bytes) else bytes + + val result = migration match { + case Some(transformer) if fromVersion < transformer.currentVersion ⇒ + val jsonTree = objectMapper.readTree(decompressBytes) + val newJsonTree = transformer.transform(fromVersion, jsonTree) + objectMapper.treeToValue(newJsonTree, clazz) + case _ ⇒ + objectMapper.readValue(decompressBytes, clazz) + } + + if (isDebugEnabled) { + val durationMicros = (System.nanoTime - startTime) / 1000 + if (bytes.length == decompressBytes.length) + log.debug( + "Deserialization of [{}] took [{}] µs, size [{}] bytes", + clazz.getName, + durationMicros, + decompressBytes.length) + else + log.debug( + "Deserialization of [{}] took [{}] µs, compressed size [{}] bytes, uncompressed size [{}] bytes", + clazz.getName, + durationMicros, + decompressBytes.length, + bytes.length) + } + + result + } + + private def checkAllowedClassName(className: String): Unit = { + if (!blacklist.isAllowedClassName(className)) { + val warnMsg = s"Can't serialize/deserialize object of type [$className] in [${getClass.getName}]. " + + s"Blacklisted for security reasons." + log.warning(LogMarker.Security, warnMsg) + throw new IllegalArgumentException(warnMsg) + } + } + + private def checkAllowedClass(clazz: Class[_]): Unit = { + if (!blacklist.isAllowedClass(clazz)) { + val warnMsg = s"Can't serialize/deserialize object of type [${clazz.getName}] in [${getClass.getName}]. " + + s"Blacklisted for security reasons." + log.warning(LogMarker.Security, warnMsg) + throw new IllegalArgumentException(warnMsg) + } else if (!isInWhitelist(clazz)) { + val warnMsg = s"Can't serialize/deserialize object of type [${clazz.getName}] in [${getClass.getName}]. " + + "Only classes that are whitelisted are allowed for security reasons. " + + "Configure whitelist with akka.actor.serialization-bindings or " + + "akka.serialization.jackson.whitelist-packages." + log.warning(LogMarker.Security, warnMsg) + throw new IllegalArgumentException(warnMsg) + } + } + + /** + * Using the `serialization-bindings` as source for the whitelist. + * Note that the intended usage of serialization-bindings is for lookup of + * serializer when serializing (`toBinary`). For deserialization (`fromBinary`) the serializer-id is + * used for selecting serializer. + * Here we use `serialization-bindings` also and more importantly when deserializing (fromBinary) + * to check that the manifest class is of a known (registered) type. + * The drawback of using `serialization-bindings` for this is that an old class can't be removed + * from `serialization-bindings` when it's not used for serialization but still used for + * deserialization (e.g. rolling update with serialization changes). It's also + * not possible to change a binding from a JacksonSerializer to another serializer (e.g. protobuf) + * and still bind with the same class (interface). + * If this is too limiting we can add another config property as an additional way to + * whitelist classes that are not bound to this serializer with serialization-bindings. + */ + private def isInWhitelist(clazz: Class[_]): Boolean = { + try { + // The reason for using isInstanceOf rather than `eq this` is to allow change of + // serializizer within the Jackson family, but we don't trust other serializers + // because they might be bound to open-ended interfaces like java.io.Serializable. + val boundSerializer = serialization.serializerFor(clazz) + boundSerializer.isInstanceOf[JacksonSerializer] || + // to support rolling updates in Lagom we also trust the binding to the Lagom 1.5.x JacksonJsonSerializer, + // which is named OldJacksonJsonSerializer in Lagom 1.6.x + // FIXME maybe make this configurable, but I don't see any other usages than for Lagom? + boundSerializer.getClass.getName == "com.lightbend.lagom.internal.jackson.OldJacksonJsonSerializer" + } catch { + case NonFatal(_) => false // not bound + } + } + + /** + * Check that serialization-bindings are not configured with open-ended interfaces, + * like java.lang.Object, bound to this serializer. + * + * This check is run on first access since it can't be run from constructor because SerializationExtension + * can't be accessed from there. + */ + private def checkAllowedSerializationBindings(): Unit = { + if (!serializationBindingsCheckedOk) { + def isBindingOk(clazz: Class[_]): Boolean = + try { + serialization.serializerFor(clazz) ne this + } catch { + case NonFatal(_) => true // not bound + } + + JacksonSerializer.disallowedSerializationBindings.foreach { clazz => + if (!isBindingOk(clazz)) { + val warnMsg = "For security reasons it's not allowed to bind open-ended interfaces like " + + s"[${clazz.getName}] to [${getClass.getName}]. " + + "Change your akka.actor.serialization-bindings configuration." + log.warning(LogMarker.Security, warnMsg) + throw new IllegalArgumentException(warnMsg) + } + } + serializationBindingsCheckedOk = true + } + } + + private def parseManifest(manifest: String) = { + val i = manifest.lastIndexOf('#') + val fromVersion = if (i == -1) 1 else manifest.substring(i + 1).toInt + val manifestClassName = if (i == -1) manifest else manifest.substring(0, i) + (fromVersion, manifestClassName) + } + + def compress(bytes: Array[Byte]): Array[Byte] = { + val bos = new ByteArrayOutputStream(BufferSize) + val zip = new GZIPOutputStream(bos) + try zip.write(bytes) + finally zip.close() + bos.toByteArray + } + + def decompress(bytes: Array[Byte]): Array[Byte] = { + val in = new GZIPInputStream(new ByteArrayInputStream(bytes)) + val out = new ByteArrayOutputStream() + // FIXME pool of recycled buffers? + val buffer = new Array[Byte](BufferSize) + + @tailrec def readChunk(): Unit = in.read(buffer) match { + case -1 ⇒ () + case n ⇒ + out.write(buffer, 0, n) + readChunk() + } + + try readChunk() + finally in.close() + out.toByteArray + } + + def isGZipped(bytes: Array[Byte]): Boolean = { + (bytes != null) && (bytes.length >= 2) && + (bytes(0) == GZIPInputStream.GZIP_MAGIC.toByte) && + (bytes(1) == (GZIPInputStream.GZIP_MAGIC >> 8).toByte) + } +} diff --git a/akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestEventMigration.java b/akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestEventMigration.java new file mode 100644 index 0000000000..6901178e44 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestEventMigration.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2016-2019 Lightbend Inc. + */ + +package akka.serialization.jackson; + +import com.fasterxml.jackson.databind.node.IntNode; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.JsonNode; + +public class JavaTestEventMigration extends JacksonMigration { + + @Override + public int currentVersion() { + return 3; + } + + @Override + public String transformClassName(int fromVersion, String className) { + return JavaTestMessages.Event2.class.getName(); + } + + @Override + public JsonNode transform(int fromVersion, JsonNode json) { + ObjectNode root = (ObjectNode) json; + root.set("field1V2", root.get("field1")); + root.remove("field1"); + root.set("field2", IntNode.valueOf(17)); + return root; + } +} diff --git a/akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestMessages.java b/akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestMessages.java new file mode 100644 index 0000000000..723686dfad --- /dev/null +++ b/akka-serialization-jackson/src/test/java/akka/serialization/jackson/JavaTestMessages.java @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2016-2019 Lightbend Inc. + */ + +package akka.serialization.jackson; + +import akka.actor.ActorRef; +import akka.actor.Address; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface JavaTestMessages { + + public interface TestMessage {} + + public class SimpleCommand implements TestMessage { + private final String name; + + // FIXME document gotchas like this (or is there a better way?) + // @JsonProperty needed due to single argument constructor, see + // https://github.com/FasterXML/jackson-modules-java8/tree/master/parameter-names + public SimpleCommand(@JsonProperty("name") String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SimpleCommand that = (SimpleCommand) o; + return name != null ? name.equals(that.name) : that.name == null; + } + + @Override + public int hashCode() { + return name != null ? name.hashCode() : 0; + } + } + + public class SimpleCommand2 implements TestMessage { + public final String name; + public final String name2; + + // note that no annotation needed here, `javac -parameters` and not single param constructor + public SimpleCommand2(String name, String name2) { + this.name = name; + this.name2 = name2; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SimpleCommand2 that = (SimpleCommand2) o; + + if (name != null ? !name.equals(that.name) : that.name != null) return false; + return name2 != null ? name2.equals(that.name2) : that.name2 == null; + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + (name2 != null ? name2.hashCode() : 0); + return result; + } + } + + public class OptionalCommand implements TestMessage { + private final Optional maybe; + + public OptionalCommand(@JsonProperty("maybe") Optional maybe) { + this.maybe = maybe; + } + + public Optional getMaybe() { + return maybe; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + OptionalCommand that = (OptionalCommand) o; + + return maybe != null ? maybe.equals(that.maybe) : that.maybe == null; + } + + @Override + public int hashCode() { + return maybe != null ? maybe.hashCode() : 0; + } + } + + public class BooleanCommand implements TestMessage { + private final boolean published; + + public BooleanCommand(@JsonProperty("published") boolean published) { + this.published = published; + } + + public boolean isPublished() { + return published; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + BooleanCommand that = (BooleanCommand) o; + + return published == that.published; + } + + @Override + public int hashCode() { + return (published ? 1 : 0); + } + } + + public class CollectionsCommand implements TestMessage { + private final List strings; + // if this was List it would not automatically work, + // which is good, otherwise arbitrary classes could be loaded + private final List objects; + + public CollectionsCommand(List strings, List objects) { + this.strings = strings; + this.objects = objects; + } + + public List getStrings() { + return strings; + } + + public List getObjects() { + return objects; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CollectionsCommand that = (CollectionsCommand) o; + + if (strings != null ? !strings.equals(that.strings) : that.strings != null) return false; + return objects != null ? objects.equals(that.objects) : that.objects == null; + } + + @Override + public int hashCode() { + int result = strings != null ? strings.hashCode() : 0; + result = 31 * result + (objects != null ? objects.hashCode() : 0); + return result; + } + } + + public class TimeCommand implements TestMessage { + public final LocalDateTime timestamp; + public final Duration duration; + + public TimeCommand(LocalDateTime timestamp, Duration duration) { + this.timestamp = timestamp; + this.duration = duration; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TimeCommand that = (TimeCommand) o; + + if (timestamp != null ? !timestamp.equals(that.timestamp) : that.timestamp != null) + return false; + return duration != null ? duration.equals(that.duration) : that.duration == null; + } + + @Override + public int hashCode() { + int result = timestamp != null ? timestamp.hashCode() : 0; + result = 31 * result + (duration != null ? duration.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "TimeCommand{" + "timestamp=" + timestamp + ", duration=" + duration + '}'; + } + } + + public class CommandWithActorRef implements TestMessage { + public final String name; + public final ActorRef replyTo; + + public CommandWithActorRef(String name, ActorRef replyTo) { + this.name = name; + this.replyTo = replyTo; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CommandWithActorRef that = (CommandWithActorRef) o; + + if (name != null ? !name.equals(that.name) : that.name != null) return false; + return replyTo != null ? replyTo.equals(that.replyTo) : that.replyTo == null; + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + (replyTo != null ? replyTo.hashCode() : 0); + return result; + } + } + + public class CommandWithAddress implements TestMessage { + public final String name; + public final Address address; + + public CommandWithAddress(String name, Address address) { + this.name = name; + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CommandWithAddress that = (CommandWithAddress) o; + + if (name != null ? !name.equals(that.name) : that.name != null) return false; + return address != null ? address.equals(that.address) : that.address == null; + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + (address != null ? address.hashCode() : 0); + return result; + } + } + + public class Event1 implements TestMessage { + + private final String field1; + + public Event1(String field1) { + this.field1 = field1; + } + + public String getField1() { + return field1; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Event1 event1 = (Event1) o; + + return field1 != null ? field1.equals(event1.field1) : event1.field1 == null; + } + + @Override + public int hashCode() { + return field1 != null ? field1.hashCode() : 0; + } + } + + public class Event2 implements TestMessage { + private final String field1V2; // renamed from field1 + private final int field2; // new mandatory field + + public Event2(String field1V2, int field2) { + this.field1V2 = field1V2; + this.field2 = field2; + } + + public String getField1V2() { + return field1V2; + } + + public int getField2() { + return field2; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Event2 event2 = (Event2) o; + + if (field2 != event2.field2) return false; + return field1V2 != null ? field1V2.equals(event2.field1V2) : event2.field1V2 == null; + } + + @Override + public int hashCode() { + int result = field1V2 != null ? field1V2.hashCode() : 0; + result = 31 * result + field2; + return result; + } + } + + public class Zoo implements TestMessage { + public final Animal first; + + public Zoo(@JsonProperty("first") Animal first) { + this.first = first; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Zoo zoo = (Zoo) o; + + return first != null ? first.equals(zoo.first) : zoo.first == null; + } + + @Override + public int hashCode() { + return first != null ? first.hashCode() : 0; + } + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Lion.class, name = "lion"), + @JsonSubTypes.Type(value = Elephant.class, name = "elephant") + }) + interface Animal {} + + public final class Lion implements Animal { + public final String name; + + public Lion(@JsonProperty("name") String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Lion lion = (Lion) o; + + return name != null ? name.equals(lion.name) : lion.name == null; + } + + @Override + public int hashCode() { + return name != null ? name.hashCode() : 0; + } + } + + public final class Elephant implements Animal { + public final String name; + public final int age; + + public Elephant(String name, int age) { + this.name = name; + this.age = age; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Elephant elephant = (Elephant) o; + + if (age != elephant.age) return false; + return name != null ? name.equals(elephant.name) : elephant.name == null; + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + age; + return result; + } + } + // not defined in JsonSubTypes + final class Cockroach implements Animal { + public final String name; + + public Cockroach(@JsonProperty("name") String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Cockroach cockroach = (Cockroach) o; + + return name != null ? name.equals(cockroach.name) : cockroach.name == null; + } + + @Override + public int hashCode() { + return name != null ? name.hashCode() : 0; + } + } +} diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/MySerializable.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/MySerializable.java new file mode 100644 index 0000000000..da7337df3d --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/MySerializable.java @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson; + +// #marker-interface +/** Marker interface for messages, events and snapshots that are serialized with Jackson. */ +public interface MySerializable {} + +class MyMessage implements MySerializable { + public final String name; + public final int nr; + + public MyMessage(String name, int nr) { + this.name = name; + this.nr = nr; + } +} +// #marker-interface diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/Customer.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/Customer.java new file mode 100644 index 0000000000..086fba6137 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/Customer.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v1; + +import jdoc.akka.serialization.jackson.MySerializable; + +// #structural +public class Customer implements MySerializable { + public final String name; + public final String street; + public final String city; + public final String zipCode; + public final String country; + + public Customer(String name, String street, String city, String zipCode, String country) { + this.name = name; + this.street = street; + this.city = city; + this.zipCode = zipCode; + this.country = country; + } +} +// #structural diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/ItemAdded.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/ItemAdded.java new file mode 100644 index 0000000000..9723c7df98 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/ItemAdded.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v1; + +import jdoc.akka.serialization.jackson.MySerializable; + +// #add-optional +public class ItemAdded implements MySerializable { + public final String shoppingCartId; + public final String productId; + public final int quantity; + + public ItemAdded(String shoppingCartId, String productId, int quantity) { + this.shoppingCartId = shoppingCartId; + this.productId = productId; + this.quantity = quantity; + } +} +// #add-optional diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/OrderAdded.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/OrderAdded.java new file mode 100644 index 0000000000..110afa3ade --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v1/OrderAdded.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v1; + +import jdoc.akka.serialization.jackson.MySerializable; + +// #rename-class +public class OrderAdded implements MySerializable { + public final String shoppingCartId; + + public OrderAdded(String shoppingCartId) { + this.shoppingCartId = shoppingCartId; + } +} +// #rename-class diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Address.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Address.java new file mode 100644 index 0000000000..1c7e10000c --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Address.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2a; + +// #structural +public class Address { + public final String street; + public final String city; + public final String zipCode; + public final String country; + + public Address(String street, String city, String zipCode, String country) { + this.street = street; + this.city = city; + this.zipCode = zipCode; + this.country = country; + } +} +// #structural diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Customer.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Customer.java new file mode 100644 index 0000000000..4e7096b7c8 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/Customer.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2a; + +import jdoc.akka.serialization.jackson.MySerializable; + +import java.util.Optional; + +// #structural +public class Customer implements MySerializable { + public final String name; + public final Address shippingAddress; + public final Optional
billingAddress; + + public Customer(String name, Address shippingAddress, Optional
billingAddress) { + this.name = name; + this.shippingAddress = shippingAddress; + this.billingAddress = billingAddress; + } +} +// #structural diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/CustomerMigration.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/CustomerMigration.java new file mode 100644 index 0000000000..f98ae23cc1 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/CustomerMigration.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2a; + +// #structural +import akka.serialization.jackson.JacksonMigration; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class CustomerMigration extends JacksonMigration { + + @Override + public int currentVersion() { + return 2; + } + + @Override + public JsonNode transform(int fromVersion, JsonNode json) { + ObjectNode root = (ObjectNode) json; + if (fromVersion <= 1) { + ObjectNode shippingAddress = root.with("shippingAddress"); + shippingAddress.set("street", root.get("street")); + shippingAddress.set("city", root.get("city")); + shippingAddress.set("zipCode", root.get("zipCode")); + shippingAddress.set("country", root.get("country")); + root.remove("street"); + root.remove("city"); + root.remove("zipCode"); + root.remove("country"); + } + return root; + } +} +// #structural diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/ItemAdded.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/ItemAdded.java new file mode 100644 index 0000000000..9bd6751582 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/ItemAdded.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2a; + +import jdoc.akka.serialization.jackson.MySerializable; + +import java.util.Optional; + +// #add-optional +public class ItemAdded implements MySerializable { + public final String shoppingCartId; + public final String productId; + public final int quantity; + public final Optional discount; + public final String note; + + public ItemAdded( + String shoppingCartId, + String productId, + int quantity, + Optional discount, + String note) { + this.shoppingCartId = shoppingCartId; + this.productId = productId; + this.quantity = quantity; + this.discount = discount; + this.note = note; + } + + public ItemAdded( + String shoppingCartId, String productId, int quantity, Optional discount) { + this(shoppingCartId, productId, quantity, discount, ""); + } +} +// #add-optional diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlaced.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlaced.java new file mode 100644 index 0000000000..07967eb908 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlaced.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2a; + +import jdoc.akka.serialization.jackson.MySerializable; + +// #rename-class +public class OrderPlaced implements MySerializable { + public final String shoppingCartId; + + public OrderPlaced(String shoppingCartId) { + this.shoppingCartId = shoppingCartId; + } +} +// #rename-class diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlacedMigration.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlacedMigration.java new file mode 100644 index 0000000000..a21b8ea9a3 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2a/OrderPlacedMigration.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2a; + +import akka.serialization.jackson.JacksonMigration; +import com.fasterxml.jackson.databind.JsonNode; + +// #rename-class +public class OrderPlacedMigration extends JacksonMigration { + + @Override + public int currentVersion() { + return 2; + } + + @Override + public String transformClassName(int fromVersion, String className) { + return OrderPlaced.class.getName(); + } + + @Override + public JsonNode transform(int fromVersion, JsonNode json) { + return json; + } +} +// #rename-class diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAdded.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAdded.java new file mode 100644 index 0000000000..2a7b5b6aa7 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAdded.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2b; + +import jdoc.akka.serialization.jackson.MySerializable; + +// #add-mandatory +public class ItemAdded implements MySerializable { + public final String shoppingCartId; + public final String productId; + public final int quantity; + public final double discount; + + public ItemAdded(String shoppingCartId, String productId, int quantity, double discount) { + this.shoppingCartId = shoppingCartId; + this.productId = productId; + this.quantity = quantity; + this.discount = discount; + } +} +// #add-mandatory diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAddedMigration.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAddedMigration.java new file mode 100644 index 0000000000..6febb70b6c --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2b/ItemAddedMigration.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2b; + +// #add-mandatory +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import akka.serialization.jackson.JacksonMigration; + +public class ItemAddedMigration extends JacksonMigration { + + @Override + public int currentVersion() { + return 2; + } + + @Override + public JsonNode transform(int fromVersion, JsonNode json) { + ObjectNode root = (ObjectNode) json; + if (fromVersion <= 1) { + root.set("discount", DoubleNode.valueOf(0.0)); + } + return root; + } +} +// #add-mandatory diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAdded.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAdded.java new file mode 100644 index 0000000000..0a2e39f946 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAdded.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2c; + +import jdoc.akka.serialization.jackson.MySerializable; + +// #rename +public class ItemAdded implements MySerializable { + public final String shoppingCartId; + + public final String itemId; + + public final int quantity; + + public ItemAdded(String shoppingCartId, String itemId, int quantity) { + this.shoppingCartId = shoppingCartId; + this.itemId = itemId; + this.quantity = quantity; + } +} +// #rename diff --git a/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAddedMigration.java b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAddedMigration.java new file mode 100644 index 0000000000..4676a0c4d2 --- /dev/null +++ b/akka-serialization-jackson/src/test/java/jdoc/akka/serialization/jackson/v2c/ItemAddedMigration.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package jdoc.akka.serialization.jackson.v2c; + +// #rename + +import akka.serialization.jackson.JacksonMigration; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class ItemAddedMigration extends JacksonMigration { + + @Override + public int currentVersion() { + return 2; + } + + @Override + public JsonNode transform(int fromVersion, JsonNode json) { + ObjectNode root = (ObjectNode) json; + if (fromVersion <= 1) { + root.set("itemId", root.get("productId")); + root.remove("productId"); + } + return root; + } +} +// #rename diff --git a/akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala b/akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala new file mode 100644 index 0000000000..2bfe46a796 --- /dev/null +++ b/akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala @@ -0,0 +1,550 @@ +/* + * Copyright (C) 2016-2019 Lightbend Inc. + */ + +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.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 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 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 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 + + } +} diff --git a/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala b/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala new file mode 100644 index 0000000000..fd69e176ab --- /dev/null +++ b/akka-serialization-jackson/src/test/scala/doc/akka/serialization/jackson/SerializationDocSpec.scala @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2019 Lightbend Inc. + */ + +package doc.akka.serialization.jackson + +//#marker-interface +/** + * Marker interface for messages, events and snapshots that are serialized with Jackson. + */ +trait MySerializable + +final case class Message(name: String, nr: Int) extends MySerializable +//#marker-interface + +object SerializationDocSpec { + val config = """ + #//#serialization-bindings + akka.actor { + serialization-bindings { + "com.myservice.MySerializable" = jackson-json + } + } + #//#serialization-bindings + """ + + val configMigration = """ + #//#migrations-conf + akka.serialization.jackson.migrations { + "com.myservice.event.ItemAdded" = "com.myservice.event.ItemAddedMigration" + } + #//#migrations-conf + """ + + val configMigrationRenamClass = """ + #//#migrations-conf-rename + akka.serialization.jackson.migrations { + "com.myservice.event.OrederAdded" = "com.myservice.event.OrderPlacedMigration" + } + #//#migrations-conf-rename + """ +} +// FIXME add real tests for the migrations, see EventMigrationTest.java in Lagom diff --git a/build.sbt b/build.sbt index c6e093e959..d23712c838 100644 --- a/build.sbt +++ b/build.sbt @@ -32,42 +32,45 @@ shellPrompt := { s => } resolverSettings +def isScala213: Boolean = System.getProperty("akka.build.scalaVersion", "").startsWith("2.13") + // When this is updated the set of modules in ActorSystem.allModules should also be updated lazy val aggregatedProjects: Seq[ProjectReference] = List[ProjectReference]( - actor, - actorTests, - actorTestkitTyped, - actorTyped, - actorTypedTests, - benchJmh, - benchJmhTyped, - cluster, - clusterMetrics, - clusterSharding, - clusterShardingTyped, - clusterTools, - clusterTyped, - coordination, - discovery, - distributedData, - docs, - multiNodeTestkit, - osgi, - persistence, - persistenceQuery, - persistenceShared, - persistenceTck, - persistenceTyped, - protobuf, - remote, - remoteTests, - slf4j, - stream, - streamTestkit, - streamTests, - streamTestsTck, - streamTyped, - testkit) + actor, + actorTests, + actorTestkitTyped, + actorTyped, + actorTypedTests, + cluster, + clusterMetrics, + clusterSharding, + clusterShardingTyped, + clusterTools, + clusterTyped, + coordination, + discovery, + distributedData, + docs, + multiNodeTestkit, + osgi, + persistence, + persistenceQuery, + persistenceShared, + persistenceTck, + persistenceTyped, + protobuf, + remote, + remoteTests, + slf4j, + stream, + streamTestkit, + streamTests, + streamTestsTck, + streamTyped, + testkit) ++ + (if (isScala213) List.empty[ProjectReference] + else + List[ProjectReference](jackson, benchJmh, benchJmhTyped)) // FIXME move 2.13 condition when Jackson ScalaModule has been released for Scala 2.13.0 lazy val root = Project(id = "akka", base = file(".")) .aggregate(aggregatedProjects: _*) @@ -99,7 +102,7 @@ lazy val akkaScalaNightly = akkaModule("akka-scala-nightly") .disablePlugins(ValidatePullRequest, MimaPlugin, CopyrightHeaderInPr) lazy val benchJmh = akkaModule("akka-bench-jmh") - .dependsOn(Seq(actor, stream, streamTests, persistence, distributedData, testkit).map( + .dependsOn(Seq(actor, stream, streamTests, persistence, distributedData, jackson, testkit).map( _ % "compile->compile;compile->test"): _*) .settings(Dependencies.benchJmh) .enablePlugins(JmhPlugin, ScaladocNoVerificationOfDiagrams, NoPublish, CopyrightHeader) @@ -235,6 +238,17 @@ lazy val docs = akkaModule("akka-docs") .disablePlugins(MimaPlugin, WhiteSourcePlugin) .disablePlugins(ScalafixPlugin) +lazy val jackson = akkaModule("akka-serialization-jackson") + .dependsOn(actor, actorTests % "test->test", testkit % "test->test") + .settings(Dependencies.jackson) + .settings(AutomaticModuleName.settings("akka.serialization.jackson")) + .settings(OSGi.jackson) + .settings(javacOptions += "-parameters") + // FIXME remove when Jackson ScalaModule has been released for Scala 2.13.0 + .settings(crossScalaVersions -= Dependencies.scala213Version) + .enablePlugins(ScaladocNoVerificationOfDiagrams) + .disablePlugins(MimaPlugin) + lazy val multiNodeTestkit = akkaModule("akka-multi-node-testkit") .dependsOn(remote, testkit) .settings(Protobuf.settings) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ccf88d35fc..ee16f48300 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -18,9 +18,13 @@ object Dependencies { val slf4jVersion = "1.7.25" val scalaXmlVersion = "1.0.6" val aeronVersion = "1.15.1" + val jacksonVersion = "2.9.9" + + val scala212Version = "2.12.8" + val scala213Version = "2.13.0-RC2" val Versions = Seq( - crossScalaVersions := Seq("2.12.8", "2.13.0-RC2"), + crossScalaVersions := Seq(scala212Version, scala213Version), scalaVersion := System.getProperty("akka.build.scalaVersion", crossScalaVersions.value.head), scalaCheckVersion := sys.props.get("akka.build.scalaCheckVersion").getOrElse("1.14.0"), scalaTestVersion := { @@ -41,7 +45,8 @@ object Dependencies { CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, n)) if n >= 13 => "0.4.0" case _ => "0.3.7" - }}) + } + }) object Compile { // Compile @@ -78,6 +83,17 @@ object Dependencies { val aeronDriver = "io.aeron" % "aeron-driver" % aeronVersion // ApacheV2 val aeronClient = "io.aeron" % "aeron-client" % aeronVersion // ApacheV2 + val jacksonCore = "com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion // ApacheV2 + val jacksonAnnotations = "com.fasterxml.jackson.core" % "jackson-annotations" % jacksonVersion // ApacheV2 + val jacksonDatabind = "com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion // ApacheV2 + val jacksonJdk8 = "com.fasterxml.jackson.datatype" % "jackson-datatype-jdk8" % jacksonVersion // ApacheV2 + val jacksonJsr310 = "com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % jacksonVersion // ApacheV2 + val jacksonScala = "com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion // ApacheV2 + val jacksonParameterNames = "com.fasterxml.jackson.module" % "jackson-module-parameter-names" % jacksonVersion // ApacheV2 + val jacksonAfterburner = "com.fasterxml.jackson.module" % "jackson-module-afterburner" % jacksonVersion // ApacheV2 + val jacksonCbor = "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % jacksonVersion // ApacheV2 + val jacksonSmile = "com.fasterxml.jackson.dataformat" % "jackson-dataformat-smile" % jacksonVersion // ApacheV2 + object Docs { val sprayJson = "io.spray" %% "spray-json" % "1.3.5" % "test" val gson = "com.google.code.gson" % "gson" % "2.8.5" % "test" @@ -209,6 +225,20 @@ object Dependencies { val persistenceShared = l ++= Seq(Provided.levelDB, Provided.levelDBNative) + val jackson = l ++= Seq( + jacksonCore, + jacksonAnnotations, + jacksonDatabind, + jacksonScala, + jacksonJdk8, + jacksonJsr310, + jacksonParameterNames, + jacksonAfterburner, + jacksonSmile, + jacksonCbor, + Test.junit, + Test.scalatest.value) + val osgi = l ++= Seq( osgiCore, osgiCompendium, diff --git a/project/OSGi.scala b/project/OSGi.scala index 319886d671..6a22056193 100644 --- a/project/OSGi.scala +++ b/project/OSGi.scala @@ -16,33 +16,38 @@ object OSGi { // The included osgiSettings that creates bundles also publish the jar files // in the .../bundles directory which makes testing locally published artifacts // a pain. Create bundles but publish them to the normal .../jars directory. - def osgiSettings = defaultOsgiSettings ++ Seq( - Compile / packageBin := { - val bundle = OsgiKeys.bundle.value - // This normally happens automatically when loading the - // sbt-reproducible-builds plugin, but because we replace - // `packageBin` wholesale here we need to invoke the post-processing - // manually. See also - // https://github.com/raboof/sbt-reproducible-builds#sbt-osgi - ReproducibleBuildsPlugin.postProcessJar(bundle) - }, - // This will fail the build instead of accidentally removing classes from the resulting artifact. - // Each package contained in a project MUST be known to be private or exported, if it's undecided we MUST resolve this - OsgiKeys.failOnUndecidedPackage := true, - // By default an entry is generated from module group-id, but our modules do not adhere to such package naming - OsgiKeys.privatePackage := Seq(), - // Explicitly specify the version of JavaSE required #23795 (rather depend on - // figuring that out from the JDK it was built with) - OsgiKeys.requireCapability := "osgi.ee;filter:=\"(&(osgi.ee=JavaSE)(version>=1.8))\"" - ) + def osgiSettings = + defaultOsgiSettings ++ Seq( + Compile / packageBin := { + val bundle = OsgiKeys.bundle.value + // This normally happens automatically when loading the + // sbt-reproducible-builds plugin, but because we replace + // `packageBin` wholesale here we need to invoke the post-processing + // manually. See also + // https://github.com/raboof/sbt-reproducible-builds#sbt-osgi + ReproducibleBuildsPlugin.postProcessJar(bundle) + }, + // This will fail the build instead of accidentally removing classes from the resulting artifact. + // Each package contained in a project MUST be known to be private or exported, if it's undecided we MUST resolve this + OsgiKeys.failOnUndecidedPackage := true, + // By default an entry is generated from module group-id, but our modules do not adhere to such package naming + OsgiKeys.privatePackage := Seq(), + // Explicitly specify the version of JavaSE required #23795 (rather depend on + // figuring that out from the JDK it was built with) + OsgiKeys.requireCapability := "osgi.ee;filter:=\"(&(osgi.ee=JavaSE)(version>=1.8))\"") val actor = osgiSettings ++ Seq( - OsgiKeys.exportPackage := Seq("akka*"), - OsgiKeys.privatePackage := Seq("akka.osgi.impl"), - //akka-actor packages are not imported, as contained in the CP - OsgiKeys.importPackage := (osgiOptionalImports map optionalResolution) ++ Seq("!sun.misc", scalaJava8CompatImport(), scalaVersion(scalaImport).value, configImport(), "*"), - // dynamicImportPackage needed for loading classes defined in configuration - OsgiKeys.dynamicImportPackage := Seq("*")) + OsgiKeys.exportPackage := Seq("akka*"), + OsgiKeys.privatePackage := Seq("akka.osgi.impl"), + //akka-actor packages are not imported, as contained in the CP + OsgiKeys.importPackage := (osgiOptionalImports.map(optionalResolution)) ++ Seq( + "!sun.misc", + scalaJava8CompatImport(), + scalaVersion(scalaImport).value, + configImport(), + "*"), + // dynamicImportPackage needed for loading classes defined in configuration + OsgiKeys.dynamicImportPackage := Seq("*")) val actorTyped = exports(Seq("akka.actor.typed.*")) @@ -60,29 +65,27 @@ object OSGi { val protobuf = exports(Seq("akka.protobuf.*")) + val jackson = exports(Seq("akka.serialization.jackson.*")) + val remote = exports(Seq("akka.remote.*")) - val parsing = exports( - Seq("akka.parboiled2.*", "akka.shapeless.*"), - imports = Seq(optionalResolution("scala.quasiquotes"))) + val parsing = + exports(Seq("akka.parboiled2.*", "akka.shapeless.*"), imports = Seq(optionalResolution("scala.quasiquotes"))) val httpCore = exports(Seq("akka.http.*"), imports = Seq(scalaJava8CompatImport())) val http = exports( - Seq("akka.http.impl.server") ++ - Seq( - "akka.http.$DSL$.server.*", - "akka.http.$DSL$.client.*", - "akka.http.$DSL$.coding.*", - "akka.http.$DSL$.common.*", - "akka.http.$DSL$.marshalling.*", - "akka.http.$DSL$.unmarshalling.*") flatMap { p => - Seq(p.replace("$DSL$", "scaladsl"), p.replace("$DSL$", "javadsl")) - }, - imports = Seq( - scalaJava8CompatImport(), - akkaImport("akka.stream.*"), - akkaImport("akka.parboiled2.*"))) + (Seq("akka.http.impl.server") ++ + Seq( + "akka.http.$DSL$.server.*", + "akka.http.$DSL$.client.*", + "akka.http.$DSL$.coding.*", + "akka.http.$DSL$.common.*", + "akka.http.$DSL$.marshalling.*", + "akka.http.$DSL$.unmarshalling.*")).flatMap { p => + Seq(p.replace("$DSL$", "scaladsl"), p.replace("$DSL$", "javadsl")) + }, + imports = Seq(scalaJava8CompatImport(), akkaImport("akka.stream.*"), akkaImport("akka.parboiled2.*"))) val httpTestkit = exports(Seq("akka.http.scaladsl.testkit.*", "akka.http.javadsl.testkit.*")) @@ -94,18 +97,13 @@ object OSGi { val stream = exports( - packages = Seq( - "akka.stream.*", - "com.typesafe.sslconfig.akka.*" - ), + packages = Seq("akka.stream.*", "com.typesafe.sslconfig.akka.*"), imports = Seq( scalaJava8CompatImport(), scalaParsingCombinatorImport(), sslConfigCoreImport("com.typesafe.sslconfig.ssl.*"), sslConfigCoreImport("com.typesafe.sslconfig.util.*"), - "!com.typesafe.sslconfig.akka.*" - ) - ) + "!com.typesafe.sslconfig.akka.*")) val streamTestkit = exports(Seq("akka.stream.testkit.*")) @@ -113,11 +111,7 @@ object OSGi { val persistence = exports( Seq("akka.persistence.*"), - imports = Seq( - optionalResolution("org.fusesource.leveldbjni.*"), - optionalResolution("org.iq80.leveldb.*") - ) - ) + imports = Seq(optionalResolution("org.fusesource.leveldbjni.*"), optionalResolution("org.iq80.leveldb.*"))) val persistenceTyped = exports(Seq("akka.persistence.typed.*")) @@ -135,11 +129,19 @@ object OSGi { // to be able to find reference.conf "akka.testkit") - def exports(packages: Seq[String] = Seq(), imports: Seq[String] = Nil) = osgiSettings ++ Seq( - OsgiKeys.importPackage := imports ++ scalaVersion(defaultImports).value, - OsgiKeys.exportPackage := packages) - def defaultImports(scalaVersion: String) = Seq("!sun.misc", akkaImport(), configImport(), "!scala.compat.java8.*", - "!scala.util.parsing.*", scalaImport(scalaVersion), "*") + def exports(packages: Seq[String] = Seq(), imports: Seq[String] = Nil) = + osgiSettings ++ Seq( + OsgiKeys.importPackage := imports ++ scalaVersion(defaultImports).value, + OsgiKeys.exportPackage := packages) + def defaultImports(scalaVersion: String) = + Seq( + "!sun.misc", + akkaImport(), + configImport(), + "!scala.compat.java8.*", + "!scala.util.parsing.*", + scalaImport(scalaVersion), + "*") def akkaImport(packageName: String = "akka.*") = versionedImport(packageName, "2.5", "2.6") def configImport(packageName: String = "com.typesafe.config.*") = versionedImport(packageName, "1.3.0", "1.4.0") def scalaImport(version: String) = { @@ -148,13 +150,20 @@ object OSGi { val ScalaVersion(epoch, major) = version versionedImport(packageName, s"$epoch.$major", s"$epoch.${major.toInt + 1}") } - def scalaJava8CompatImport(packageName: String = "scala.compat.java8.*") = versionedImport(packageName, "0.7.0", "1.0.0") - def scalaParsingCombinatorImport(packageName: String = "scala.util.parsing.combinator.*") = versionedImport(packageName, "1.1.0", "1.2.0") - def sslConfigCoreImport(packageName: String = "com.typesafe.sslconfig") = versionedImport(packageName, "0.2.3", "1.0.0") - def sslConfigCoreSslImport(packageName: String = "com.typesafe.sslconfig.ssl.*") = versionedImport(packageName, "0.2.3", "1.0.0") - def sslConfigCoreUtilImport(packageName: String = "com.typesafe.sslconfig.util.*") = versionedImport(packageName, "0.2.3", "1.0.0") - def kamonImport(packageName: String = "kamon.sigar.*") = optionalResolution(versionedImport(packageName, "1.6.5", "1.6.6")) - def sigarImport(packageName: String = "org.hyperic.*") = optionalResolution(versionedImport(packageName, "1.6.5", "1.6.6")) + def scalaJava8CompatImport(packageName: String = "scala.compat.java8.*") = + versionedImport(packageName, "0.7.0", "1.0.0") + def scalaParsingCombinatorImport(packageName: String = "scala.util.parsing.combinator.*") = + versionedImport(packageName, "1.1.0", "1.2.0") + def sslConfigCoreImport(packageName: String = "com.typesafe.sslconfig") = + versionedImport(packageName, "0.2.3", "1.0.0") + def sslConfigCoreSslImport(packageName: String = "com.typesafe.sslconfig.ssl.*") = + versionedImport(packageName, "0.2.3", "1.0.0") + def sslConfigCoreUtilImport(packageName: String = "com.typesafe.sslconfig.util.*") = + versionedImport(packageName, "0.2.3", "1.0.0") + def kamonImport(packageName: String = "kamon.sigar.*") = + optionalResolution(versionedImport(packageName, "1.6.5", "1.6.6")) + def sigarImport(packageName: String = "org.hyperic.*") = + optionalResolution(versionedImport(packageName, "1.6.5", "1.6.6")) def optionalResolution(packageName: String) = "%s;resolution:=optional".format(packageName) def versionedImport(packageName: String, lower: String, upper: String) = s"""$packageName;version="[$lower,$upper)"""" }