diff --git a/akka-serialization-jackson/src/main/resources/reference.conf b/akka-serialization-jackson/src/main/resources/reference.conf index 594c4a041b..5e4e2daf89 100644 --- a/akka-serialization-jackson/src/main/resources/reference.conf +++ b/akka-serialization-jackson/src/main/resources/reference.conf @@ -64,6 +64,36 @@ akka.serialization.jackson { FAIL_ON_UNKNOWN_PROPERTIES = off } + # Configuration of the ObjectMapper mapper features. + # See com.fasterxml.jackson.databind.MapperFeature + # Enum values corresponding to the MapperFeature and their + # boolean values, for example: + # + # mapper-features { + # SORT_PROPERTIES_ALPHABETICALLY = on + # } + mapper-features {} + + # Configuration of the ObjectMapper JsonParser features. + # See com.fasterxml.jackson.core.JsonParser.Feature + # Enum values corresponding to the JsonParser.Feature and their + # boolean value, for example: + # + # json-parser-features { + # ALLOW_SINGLE_QUOTES = on + # } + json-parser-features {} + + # Configuration of the ObjectMapper JsonParser features. + # See com.fasterxml.jackson.core.JsonGenerator.Feature + # Enum values corresponding to the JsonGenerator.Feature and + # their boolean value, for example: + # + # json-generator-features { + # WRITE_NUMBERS_AS_STRINGS = on + # } + json-generator-features {} + # Additional classes that are allowed even if they are not defined in `serialization-bindings`. # This is useful when a class is not used for serialization any more and therefore removed # from `serialization-bindings`, but should still be possible to deserialize. 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 index 158d95f3f6..f5844cdf37 100644 --- a/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonObjectMapperProvider.scala +++ b/akka-serialization-jackson/src/main/scala/akka/serialization/jackson/JacksonObjectMapperProvider.scala @@ -28,11 +28,14 @@ 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.MapperFeature 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 +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonGenerator object JacksonObjectMapperProvider extends ExtensionId[JacksonObjectMapperProvider] with ExtensionIdProvider { override def get(system: ActorSystem): JacksonObjectMapperProvider = super.get(system) @@ -94,6 +97,32 @@ object JacksonObjectMapperProvider extends ExtensionId[JacksonObjectMapperProvid case (feature, value) => mapper.configure(feature, value) } + val configuredMapperFeatures = features(config, "mapper-features").map { + case (enumName, value) => MapperFeature.valueOf(enumName) -> value + } + val mapperFeatures = objectMapperFactory.overrideConfiguredMapperFeatures(bindingName, configuredMapperFeatures) + mapperFeatures.foreach { + case (feature, value) => mapper.configure(feature, value) + } + + val configuredJsonParserFeatures = features(config, "json-parser-features").map { + case (enumName, value) => JsonParser.Feature.valueOf(enumName) -> value + } + val jsonParserFeatures = + objectMapperFactory.overrideConfiguredJsonParserFeatures(bindingName, configuredJsonParserFeatures) + jsonParserFeatures.foreach { + case (feature, value) => mapper.configure(feature, value) + } + + val configuredJsonGeneratorFeatures = features(config, "json-generator-features").map { + case (enumName, value) => JsonGenerator.Feature.valueOf(enumName) -> value + } + val jsonGeneratorFeatures = + objectMapperFactory.overrideConfiguredJsonGeneratorFeatures(bindingName, configuredJsonGeneratorFeatures) + jsonGeneratorFeatures.foreach { + case (feature, value) => mapper.configure(feature, value) + } + val configuredModules = config.getStringList("jackson-modules").asScala val modules1 = configuredModules.flatMap { fqcn => @@ -327,4 +356,43 @@ class JacksonObjectMapperFactory { configuredModules: immutable.Seq[Module]): immutable.Seq[Module] = configuredModules + /** + * After construction of the `ObjectMapper` the configured mapper features are applied to + * the mapper. These features can be amended programmatically by overriding this method and + * return the features that are to be applied to the `ObjectMapper`. + * + * @param bindingName bindingName name of this `ObjectMapper` + * @param configuredFeatures the list of `MapperFeatures` that were configured in `akka.serialization.jackson.mapper-features` + */ + def overrideConfiguredMapperFeatures( + @unused bindingName: String, + configuredFeatures: immutable.Seq[(MapperFeature, Boolean)]): immutable.Seq[(MapperFeature, Boolean)] = + configuredFeatures + + /** + * After construction of the `ObjectMapper` the configured JSON parser features are applied to + * the mapper. These features can be amended programmatically by overriding this method and + * return the features that are to be applied to the `ObjectMapper`. + * + * @param bindingName bindingName name of this `ObjectMapper` + * @param configuredFeatures the list of `JsonParser.Feature` that were configured in `akka.serialization.jackson.json-parser-features` + */ + def overrideConfiguredJsonParserFeatures( + @unused bindingName: String, + configuredFeatures: immutable.Seq[(JsonParser.Feature, Boolean)]): immutable.Seq[(JsonParser.Feature, Boolean)] = + configuredFeatures + + /** + * After construction of the `ObjectMapper` the configured JSON generator features are applied to + * the mapper. These features can be amended programmatically by overriding this method and + * return the features that are to be applied to the `ObjectMapper`. + * + * @param bindingName bindingName name of this `ObjectMapper` + * @param configuredFeatures the list of `JsonGenerator.Feature` that were configured in `akka.serialization.jackson.json-generator-features` + */ + def overrideConfiguredJsonGeneratorFeatures( + @unused bindingName: String, + configuredFeatures: immutable.Seq[(JsonGenerator.Feature, Boolean)]) + : immutable.Seq[(JsonGenerator.Feature, Boolean)] = + configuredFeatures } 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 index a84cc8836d..e78d294df1 100644 --- a/akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala +++ b/akka-serialization-jackson/src/test/scala/akka/serialization/jackson/JacksonSerializerSpec.scala @@ -32,6 +32,7 @@ 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.MapperFeature import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.Module @@ -45,6 +46,8 @@ import com.typesafe.config.ConfigFactory import org.scalatest.BeforeAndAfterAll import org.scalatest.Matchers import org.scalatest.WordSpecLike +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonGenerator object ScalaTestMessages { trait TestMessage @@ -137,23 +140,84 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { JacksonObjectMapperProvider(system).getOrCreate(anotherBindingName, None) shouldBe theSameInstanceAs(mapper2) } - "support several different configurations" in { + "JacksonSerializer configuration" must { + withSystem(""" akka.actor.serializers.jackson-json2 = "akka.serialization.jackson.JacksonJsonSerializer" akka.actor.serialization-identifiers.jackson-json2 = 999 akka.serialization.jackson.jackson-json2 { - deserialization-features.FAIL_ON_UNKNOWN_PROPERTIES = on - } - """) { sys => - val objMapper2 = serialization(sys).serializerByIdentity(999).asInstanceOf[JacksonJsonSerializer].objectMapper - objMapper2.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) should ===(true) - val objMapper3 = JacksonObjectMapperProvider(sys).getOrCreate("jackson-json2", None) - objMapper3.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) should ===(true) - // default has different config, different instance but same JacksonJsonSerializer class - val objMapper = + # on is Jackson's default + serialization-features.WRITE_DURATIONS_AS_TIMESTAMPS = off + + # on is Jackson's default + deserialization-features.EAGER_DESERIALIZER_FETCH = off + + # off is Jackson's default + mapper-features.SORT_PROPERTIES_ALPHABETICALLY = on + + # off is Jackson's default + json-parser-features.ALLOW_COMMENTS = on + + # on is Jackson's default + json-generator-features.AUTO_CLOSE_TARGET = off + } + """) { sys => + val identifiedObjectMapper = + serialization(sys).serializerByIdentity(999).asInstanceOf[JacksonJsonSerializer].objectMapper + val namedObjectMapper = JacksonObjectMapperProvider(sys).getOrCreate("jackson-json2", None) + val defaultObjectMapper = serializerFor(ScalaTestMessages.SimpleCommand("abc")).asInstanceOf[JacksonJsonSerializer].objectMapper - objMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) should ===(false) + + "support serialization features" in { + identifiedObjectMapper.isEnabled(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) should ===(false) + namedObjectMapper.isEnabled(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) should ===(false) + + // Default mapper follows Jackson and reference.conf default configuration + defaultObjectMapper.isEnabled(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) should ===(true) + } + + "support deserialization features" in { + identifiedObjectMapper.isEnabled(DeserializationFeature.EAGER_DESERIALIZER_FETCH) should ===(false) + namedObjectMapper.isEnabled(DeserializationFeature.EAGER_DESERIALIZER_FETCH) should ===(false) + + // Default mapper follows Jackson and reference.conf default configuration + defaultObjectMapper.isEnabled(DeserializationFeature.EAGER_DESERIALIZER_FETCH) should ===(true) + } + + "support mapper features" in { + identifiedObjectMapper.isEnabled(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) should ===(true) + namedObjectMapper.isEnabled(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) should ===(true) + + // Default mapper follows Jackson and reference.conf default configuration + defaultObjectMapper.isEnabled(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) should ===(false) + } + + "support json parser features" in { + identifiedObjectMapper.isEnabled(JsonParser.Feature.ALLOW_COMMENTS) should ===(true) + namedObjectMapper.isEnabled(JsonParser.Feature.ALLOW_COMMENTS) should ===(true) + + // Default mapper follows Jackson and reference.conf default configuration + defaultObjectMapper.isEnabled(JsonParser.Feature.ALLOW_COMMENTS) should ===(false) + } + + "support json generator features" in { + identifiedObjectMapper.isEnabled(JsonGenerator.Feature.AUTO_CLOSE_TARGET) should ===(false) + namedObjectMapper.isEnabled(JsonGenerator.Feature.AUTO_CLOSE_TARGET) should ===(false) + + // Default mapper follows Jackson and reference.conf default configuration + defaultObjectMapper.isEnabled(JsonGenerator.Feature.AUTO_CLOSE_TARGET) should ===(true) + } + + "fallback to defaults when object mapper is not configured" in { + val notConfigured = JacksonObjectMapperProvider(sys).getOrCreate("jackson-not-configured", None) + // Use Jacksons and Akka defaults + notConfigured.isEnabled(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) should ===(true) + notConfigured.isEnabled(DeserializationFeature.EAGER_DESERIALIZER_FETCH) should ===(true) + notConfigured.isEnabled(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) should ===(false) + notConfigured.isEnabled(JsonParser.Feature.ALLOW_COMMENTS) should ===(false) + notConfigured.isEnabled(JsonGenerator.Feature.AUTO_CLOSE_TARGET) should ===(true) + } } } } @@ -254,19 +318,45 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { bindingName: String, configuredFeatures: immutable.Seq[(SerializationFeature, Boolean)]) : immutable.Seq[(SerializationFeature, Boolean)] = { - if (bindingName == "jackson-json") { + if (bindingName == "jackson-json") configuredFeatures :+ (SerializationFeature.INDENT_OUTPUT -> true) - } else + else super.overrideConfiguredSerializationFeatures(bindingName, configuredFeatures) } override def overrideConfiguredModules( bindingName: String, configuredModules: immutable.Seq[Module]): immutable.Seq[Module] = - if (bindingName == "jackson-json") { + if (bindingName == "jackson-json") configuredModules.filterNot(_.isInstanceOf[JavaTimeModule]) - } else + else super.overrideConfiguredModules(bindingName, configuredModules) + + override def overrideConfiguredMapperFeatures( + bindingName: String, + configuredFeatures: immutable.Seq[(MapperFeature, Boolean)]): immutable.Seq[(MapperFeature, Boolean)] = + if (bindingName == "jackson-json") + configuredFeatures :+ (MapperFeature.SORT_PROPERTIES_ALPHABETICALLY -> true) + else + super.overrideConfiguredMapperFeatures(bindingName, configuredFeatures) + + override def overrideConfiguredJsonParserFeatures( + bindingName: String, + configuredFeatures: immutable.Seq[(JsonParser.Feature, Boolean)]) + : immutable.Seq[(JsonParser.Feature, Boolean)] = + if (bindingName == "jackson-json") + configuredFeatures :+ (JsonParser.Feature.ALLOW_SINGLE_QUOTES -> true) + else + super.overrideConfiguredJsonParserFeatures(bindingName, configuredFeatures) + + override def overrideConfiguredJsonGeneratorFeatures( + bindingName: String, + configuredFeatures: immutable.Seq[(JsonGenerator.Feature, Boolean)]) + : immutable.Seq[(JsonGenerator.Feature, Boolean)] = + if (bindingName == "jackson-json") + configuredFeatures :+ (JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS -> true) + else + super.overrideConfiguredJsonGeneratorFeatures(bindingName, configuredFeatures) } val config = system.settings.config @@ -275,12 +365,19 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { .withSetup(JacksonObjectMapperProviderSetup(customJacksonObjectMapperFactory)) .withSetup(BootstrapSetup(config)) withSystem(setup) { sys => + val mapper = JacksonObjectMapperProvider(sys).getOrCreate("jackson-json", None) + mapper.isEnabled(SerializationFeature.INDENT_OUTPUT) should ===(true) + mapper.isEnabled(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) should ===(true) + mapper.isEnabled(JsonParser.Feature.ALLOW_SINGLE_QUOTES) should ===(true) + mapper.isEnabled(SerializationFeature.INDENT_OUTPUT) should ===(true) + mapper.isEnabled(JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS) should ===(true) + val msg = InstantCommand(Instant.ofEpochMilli(1559907792075L)) val json = serializeToJsonString(msg, sys) // using the custom ObjectMapper with pretty printing enabled, and no JavaTimeModule json should include(""" "instant" : {""") - json should include(""" "seconds" : 1559907792,""") - json should include(""" "nanos" : 75000000,""") + json should include(""" "nanos" : "75000000",""") + json should include(""" "seconds" : "1559907792"""") } }