Jackson Integration

Finatra builds upon the excellent jackson-module-scala for JSON support. One of the biggest features Finatra provides is an improved case class deserializer which accumulates deserialization errors while parsing JSON into a case class instead of failing-fast, such that all errors can be reported at once.

Features

c.t.finatra.jackson.ScalaObjectMapper

The Finatra ScalaObjectMapper is a thin wrapper around a configured jackson-module-scala ScalaObjectMapper. However, the Finatra ScalaObjectMapper comes configured with several defaults when instantiated.

Defaults

The following integrations are provided by default when using the ScalaObjectMapper:

Instantiation & Configuration

There are apply functions available for creation of a ScalaObjectMapper configured with the defaults listed above:

val injector: com.google.inject.Injector = ???
val underlying: com.fasterxml.jackson.databind.ObjectMapper
    with com.fasterxml.jackson.module.scala.ScalaObjectMapper = ???

val objectMapper = ScalaObjectMapper()
val objectMapper = ScalaObjectMapper(injector)
val objectMapper = ScalaObjectMapper(underlying)
val objectMapper = ScalaObjectMapper(injector, underlying)

Important

All of the above apply methods which take an underlying Jackson ObjectMapper will always mutate the configuration of the underlying Jackson ObjectMapper to apply the current configuration of the ScalaObjectMapper#Builder to the provided Jackson ObjectMapper. That is, they should be considered builder functions to help produce a configured ScalaObjectMapper.

However, there may be times where you would like to only wrap an already configured Jackson ObjectMapper. To do, use ScalaObjectMapper.objectMapper(underlying) to create a ScalaObjectMapper which wraps but does not mutate the configuration of the given underlying Jackson ObjectMapper.

To apply more custom configuration to create a ScalaObjectMapper, there is a builder for constructing a customized mapper.

E.g., to set a PropertyNamingStrategy different than the default:

val objectMapper: ScalaObjectMapper =
  ScalaObjectMapper.builder
    .withPropertyNamingStrategy(PropertyNamingStrategy.KebabCaseStrategy)
    .objectMapper

Or to set additional modules or configuration:

val objectMapper: ScalaObjectMapper =
  ScalaObjectMapper.builder
    .withPropertyNamingStrategy(PropertyNamingStrategy.KebabCaseStrategy)
    .withAdditionalJacksonModules(Seq(MySimpleJacksonModule))
    .withAdditionalMapperConfigurationFn(
      _.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
    )
    .objectMapper

You can also get a camelCase or snake_case specifically configured mapper.

val camelCaseObjectMapper: ScalaObjectMapper =
  ScalaObjectMapper.builder
    .withAdditionalJacksonModules(Seq(MySimpleJacksonModule))
    .withAdditionalMapperConfigurationFn(
      _.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
    )
    .camelCaseObjectMapper

val snakeCaseObjectMapper: ScalaObjectMapper =
  ScalaObjectMapper.builder
    .withAdditionalJacksonModules(Seq(MySimpleJacksonModule))
    .withAdditionalMapperConfigurationFn(
      _.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
    )
    .snakeCaseObjectMapper

Or, if you already have an instance of an object mapper and want a copy that is configured to either a camelCase or snake_case property naming strategy, you can pass it to the appropriate ScalaObjectMapper utility method:

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.experimental.{ScalaObjectMapper => JacksonScalaObjectMapper}
import com.twitter.finatra.jackson.ScalaObjectMapper

// our default Jackson object mapper
val jacksonObjectMapper: ObjectMapper with JacksonScalaObjectMapper = ???

// a 'camelCase' copy
val camelCaseObjectMapper: ScalaObjectMapper =
  ScalaObjectMapper.camelCaseObjectMapper(jacksonObjectMapper)

// a 'snake_case' copy
val snakeCaseObjectMapper: ScalaObjectMapper =
  ScalaObjectMapper.snakeCaseObjectMapper(jacksonObjectMapper)

Note that these methods will copy the underlying Jackson mapper (not mutate it) to produce a new ScalaObjectMapper configured with the desired property naming strategy. That is, a new underlying mapper will be created which copies the original configuration and only the property naming strategy changed.

As mentioned above, you also wrap an already configured object mapper with the ScalaObjectMapper:

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.experimental.{ScalaObjectMapper => JacksonScalaObjectMapper}
import com.twitter.finatra.jackson.ScalaObjectMapper

// our default Jackson object mapper
val jacksonObjectMapper: ObjectMapper with JacksonScalaObjectMapper = ???

// a Finatra 'ScalaObjectMapper' copy
val objectMapper: ScalaObjectMapper = ScalaObjectMapper.objectMapper(jacksonObjectMapper)

This will copy the underlying Jackson mapper (not mutate it) to produce a new ScalaObjectMapper configured the same as the given Jackson object mapper.

Access to the underlying Jackson Object Mapper

As previously stated, the ScalaObjectMapper is a thin wrapper around a configured Jackson jackson-module-scala ScalaObjectMapper.

You can always access the underlying Jackson object mapper by calling underlying:

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.experimental.{ScalaObjectMapper => JacksonScalaObjectMapper}
import com.twitter.finatra.jackson.ScalaObjectMapper

val objectMapper: ScalaObjectMapper = ???

val jacksonObjectMapper: ObjectMapper with JacksonScalaObjectMapper = objectMapper.underlying

c.t.finatra.jackson.modules.ScalaObjectMapperModule

The framework also provides a c.t.inject.TwitterModule which can be used to bind a configured ScalaObjectMapper to the object graph. This is similar to the jackson-module-guice ObjectMapperModule but uses Finatra’s TwitterModule.

The ScalaObjectMapperModule provides bound instances of:

Tip

Generally, you are encouraged to obtain a reference to the Singleton instance provided by the object graph over instantiating a new mapper. This is to ensure usage of a consistently configured mapper across your application.

The ScalaObjectMapperModule provides overridable methods which mirror the ScalaObjectMapper#Builder for configuring the bound mappers.

For example, to create a c.t.inject.TwitterModule which sets the PropertyNamingStrategy different than the default:

import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.twitter.finatra.jackson.modules.ScalaObjectMapperModule

object MyCustomObjectMapperModule extends ScalaObjectMapperModule {

    override val propertyNamingStrategy: PropertyNamingStrategy =
      new PropertyNamingStrategy.KebabCaseStrategy
}

Or to set additional modules or configuration:

import com.fasterxml.jackson.databind.{
  DeserializationFeature,
  Module,
  ObjectMapper,
  PropertyNamingStrategy
}
import com.twitter.finatra.jackson.modules.ScalaObjectMapperModule

object MyCustomObjectMapperModule extends ScalaObjectMapperModule {

    override val propertyNamingStrategy: PropertyNamingStrategy =
      new PropertyNamingStrategy.KebabCaseStrategy

    override val additionalJacksonModules: Seq[Module] =
      Seq(MySimpleJacksonModule)

    override def additionalMapperConfiguration(mapper: ObjectMapper): Unit = {
      mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
    }
}

See the Modules Configuration in Servers or the HTTP Server Framework Modules for more information on how to make use of any custom ScalaObjectMapperModule.

Adding a Custom Serializer or Deserializer

To register a custom serializer or deserializer, you have a couple of options depending on if you are using injection to bind an instance of a ScalaObjectMapper to the object graph. When using injection, you should prefer to configure any custom serializer or deserializer via the methods provided by the ScalaObjectMapperModule, otherwise you can directly configure the underlying Jackson mapper of a ScalaObjectMapper instance.

Via Adding a Module to a ScalaObjectMapper instance

Follow the steps to create a Jackson Module for the custom serializer or deserializer then register the module to the underlying Jackson mapper from the ScalaObjectMapper instance:

import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.module.SimpleModule
import com.twitter.finatra.jackson.ScalaObjectMapper

// custom deserializer
class FooDeserializer extends JsonDeserializer[Foo] {
  override def deserialize(...)
}

// Jackson SimpleModule for custom deserializer
class FooDeserializerModule extends SimpleModule {
  addDeserializer(FooDeserializer)
}

...

val scalaObjectMapper: ScalaObjectMapper = ???
scalaObjectMapper.registerModule(new FooDeserializerModule)

Warning

Please note that this will mutate the underlying Jackson ObjectMapper and thus care should be taken with this approach. It is highly recommended to prefer setting configuration via a custom ScalaObjectMapperModule implementation.

Improved case class deserializer

Finatra provides a custom case class deserializer which overcomes some limitations in jackson-module-scala:

The case class deserializer is added by default when constructing a new ScalaObjectMapper.

Tip

Note: with the Finatra case class deserializer, non-option fields without default values are considered required.

If a required field is missing, a CaseClassMappingException is thrown.

@JsonCreator Support

The Finatra case class deserializer supports specification of a constructor or static factory method annotated with the Jackson Annotation, @JsonCreator (an annotation for indicating a specific constructor or static factory method to use for instantiation of the case class during deserialization).

For example, you can annotate a method on the companion object for the case class as a static factory for instantiation. Any static factory method to use for instantiation MUST be specified on the companion object for case class:

case class MySimpleCaseClass(int: Int)

object MySimpleCaseClass {
  @JsonCreator
  def apply(s: String): MySimpleCaseClass = MySimpleCaseClass(s.toInt)
}

Or to specify a secondary constructor to use for case class instantiation:

case class MyCaseClassWithMultipleConstructors(number1: Long, number2: Long, number3: Long) {
  @JsonCreator
  def this(numberAsString1: String, numberAsString2: String, numberAsString3: String) {
    this(numberAsString1.toLong, numberAsString2.toLong, numberAsString3.toLong)
  }
}

Note

If you define multiple constructors on a case class, it is required to annotate one of the constructors with @JsonCreator.

To annotate the primary constructor (as the syntax can seem non-intuitive because the () is required):

case class MyCaseClassWithMultipleConstructors @JsonCreator()(number1: Long, number2: Long, number3: Long) {
  def this(numberAsString1: String, numberAsString2: String, numberAsString3: String) {
    this(numberAsString1.toLong, numberAsString2.toLong, numberAsString3.toLong)
  }
}

The parens are needed because the Scala class constructor syntax requires constructor annotations to have exactly one parameter list, possibly empty.

If you define multiple case class constructors with no visible @JsonCreator constructor or static factory method via a companion, deserialization will error.

@JsonFormat Support

The Finatra case class deserializer supports @JsonFormat-annotated case class fields to properly contextualize deserialization based on the values in the annotation.

A common use case is to be able to support deserializing a JSON string into a “time” representation class based on a specific pattern independent of the time format configured on the ObjectMapper or even the default format for a given deserializer for the type.

For instance, Finatra provides a deserializer for the com.twitter.util.Time class. This deserializer is a Jackson ContextualDeserializer and will properly take into account a @JsonFormat-annotated field. However, the Finatra case class deserializer is invoked first and acts as a proxy for deserializing the time value. The case class deserializer properly contextualizes the field for correct deserialization by the TimeStringDeserializer.

Thus if you had a case class defined:

import com.fasterxml.jackson.annotation.JsonFormat
import com.twitter.util.Time

case class Event(
  id: Long,
  description: String,
  @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") when: Time
)

The following JSON:

{
  "id": 42,
  "description": "Something happened.",
  "when": "2018-09-14T23:20:08.000-07:00"
}

Will always deserialize properly into the case class regardless of the pattern configured on the ObjectMapper or as the default of a contextualized deserializer:

val scalaObjectMapper: ScalaObjectMapper = ???
val event: Event = scalaObjectMapper.parse[Event](json)

Jackson InjectableValues Support

By default, the framework provides a ScalaObjectMapper configured to resolve Jackson InjectableValues via a given Google Guice Injector.

The default is very similar to the jackson-module-guice: GuiceInjectableValues.

Note

Jackson “InjectableValues” is not related to Dependency Injection or Google Guice. It is meant to convey the filling in of a value in a deserialized object from somewhere other than the incoming JSON. In Jackson parlance, this is “injection” of a value.

The Finatra c.t.finatra.jackson.caseclass.DefaultInjectableValues allows users to denote fields in the case class to fill with values that come from a configured Google Guice Injector such that you can do this:

import javax.inject.Inject

case class Foo(name: String, description: String, @Inject bar: Bar)

That is, annotate the field to inject with either:

and the framework will attempt to get an instance of the field type from the Injector with which the mapper was configured. In this case, the framework would attempt to obtain an instance of Bar from the object graph.

Note

The framework also provides an @InjectableValue annotation which is used to mark other java.lang.annotation.Annotation interfaces as annotations that support case class field injection via Jackson InjectableValues.

Finatra’s HTTP integration defines such annotations to support injecting case class fields obtained from parts of an HTTP message.

See the HTTP Requests - Field Annotations documentation for more details.

Using the case class above, you could then parse incoming JSON with the ScalaObjectMapper:

import com.twitter.finatra.jackson.ScalaObjectMapper
import com.twitter.inject.Injector
import com.twitter.inject.app.TestInjector
import javax.inject.Inject

case class Foo(name: String, description: String, @Inject bar: Bar)

val json: String =
  """
    |{
    |  “name”: “FooItem”,
    |  “description”: “This is the description for FooItem”
    |}
  """.stripMargin

val injector: Injector = TestInjector(???).create
val mapper = ScalaObjectMapper.objectMapper(injector.underlying)
val foo = mapper.parse[Foo](json)

When deserializing the JSON string into an instance of Foo, the mapper will attempt to locate an instance of type Bar from the given injector and use it in place of the bar field in the Foo case class.

Caution

It is an error to specify multiple field injection annotations on a field, and it is also an error to use a field injection annotation in conjunction with any JacksonAnnotation.

Both of these cases will result in error during deserialization of JSON into the case class when using the Finatra case class deserializer.

As mentioned, the Finatra HTTP integration provides further Jackson InjectableValues support specifically for injecting values into a case class which are obtained from different parts of an HTTP message.

See the HTTP Requests - Field Annotations documentation for more details on HTTP Message “injectable values”.

Mix-in Annotations

The Jackson Mix-in Annotations provides a way to associate annotations to classes without needing to modify the target classes themselves. It is intended to help support 3rd party datatypes where the user cannot modify the sources to add annotations.

The Finatra case class deserializer supports Jackson Mix-in Annotations for specifying field annotations during deserialization with the case class deserializer.

For example, to deserialize JSON into the following classes that are not yours to annotate:

case class Point(x: Int, y: Int) {
  def area: Int = x * y
}

case class Points(points: Seq[Point])

However, you want to enforce field constraints with Finatra validations during deserialization. You can define a Mix-in,

trait PointMixIn {
  @Min(0) @Max(100) def x: Int
  @Min(0) @Max(100) def y: Int
  @JsonIgnore def area: Int
}

Then register this Mix-in for the Point class type. There are several ways to do this. Generally, it is recommended to always prefer applying configuration in a custom ScalaObjectMapperModule to ensure usage of a consistently configured mapper across your application.

Implement via Adding a Module to a ScalaObjectMapper instance

Follow the steps to create a Jackson Module for the Mix-in then register the module to the underlying Jackson mapper from the ScalaObjectMapper instance:

import com.fasterxml.jackson.databind.module.SimpleModule
import com.twitter.finatra.jackson.ScalaObjectMapper

object PointMixInModule extends SimpleModule {
    setMixInAnnotation(classOf[Point], classOf[PointMixIn]);
}

...

val scalaObjectMapper: ScalaObjectMapper = ???
scalaObjectMapper.registerModule(PointMixInModule)

Or register the Mix-in for the class type directly on the mapper (without a Jackson Module):

val objectMapper: ScalaObjectMapper = ???
objectMapper.underlying.addMixin[Point, PointMixIn]

Warning

Please note that this will mutate the underlying Jackson ObjectMapper and thus care should be taken with this approach. It is highly recommended to prefer setting configuration via a custom ScalaObjectMapperModule implementation to ensure consistency of the mapper configuration across your application.

Deserializing this JSON would then error with failed validations:

{
  "points": [
    {"x": -1, "y": 120},
    {"x": 4, "y": 99}
  ]
}

As the first Point instance has an x-value less than the minimum of 0 and a y-value greater than the maximum of 100.

Known CaseClassDeserializer Limitations

The Finatra case class deserializer provides a fair amount of utility but can not and does not support all Jackson Annotations. In a lot of cases the behavior of supporting a Jackson Annotation can at times be ambiguous (or even nonsensical), especially when it comes to combining Jackson Annotations and injectable field annotations.

Java Enums

We recommend the use of Java Enums for representing enumerations since they integrate well with Jackson’s ObjectMapper and have exhaustiveness checking as of Scala 2.10.

The following Jackson annotations may be useful when working with Enums:

  • @JsonValue: can be used for an overridden toString method.
  • @JsonEnumDefaultValue: can be used for defining a default value when deserializing unknown Enum values. Note that this requires READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE feature to be enabled.