Skip to content

Commit

Permalink
[finatra-jackson] Add c.t.u.Time deserializer with JsonFormat support
Browse files Browse the repository at this point in the history
**Problem**

Currently `FinatraJacksonModule` doesn't support deserializing `com.twitter.util.Time`

**Solution**

Added `TimeStringDeserializer` class which adds the functionality to deserialize a time string (e.g.
`2019-06-19T15:27:00.000-07:00`) into a `com.twitter.util.Time` object. It also supports Jackson's
`JsonFormat` annotation to specify the pattern of the input string (e.g `yyyy-MM-dd`). The pattern
follows the `java.text.SimpleDateFormat` style. Defaults the timezone to `UTC` if not specified.

**Result**

Time strings can be successfully deserialized into `com.twitter.util.Time` objects.

Differential Revision: https://phabricator.twitter.biz/D330682
  • Loading branch information
Rahul Iyer authored and jenkins committed Jun 21, 2019
1 parent ea0b7a6 commit ed3d666
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 1 deletion.
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Expand Up @@ -7,6 +7,12 @@ Note that ``RB_ID=#`` and ``PHAB_ID=#`` correspond to associated message in comm
Unreleased
----------

Added
~~~~~

* finatra-jackson: Add `com.twitter.util.Time` deserializer with `JsonFormat` support.
``PHAB_ID=D330682``

19.6.0
------

Expand Down
Expand Up @@ -894,6 +894,10 @@ class DoEverythingController @Inject()(
post("/seqCaseClass") { r: Seq[TestUser] =>
r
}

post("/ctu-time") { r: DomainTwitterTimeRequest =>
r
}
}

case class MultipleInjectableValueParams(@RouteParam @QueryParam id: String)
Expand Down
@@ -0,0 +1,5 @@
package com.twitter.finatra.http.tests.integration.doeverything.main.domain

import com.twitter.util.Time

case class DomainTwitterTimeRequest(time: Time)
Expand Up @@ -18,7 +18,7 @@ import com.twitter.finatra.json.JsonDiff._
import com.twitter.inject.Mockito
import com.twitter.inject.server.FeatureTest
import com.twitter.io.{Buf, StreamIO}
import com.twitter.util.Future
import com.twitter.util.{Future, Time}
import com.twitter.{logging => ctl}
import java.net.{ConnectException, InetSocketAddress, SocketAddress}
import org.scalatest.exceptions.TestFailedException
Expand Down Expand Up @@ -2467,5 +2467,27 @@ class DoEverythingServerFeatureTest extends FeatureTest with Mockito {

com.twitter.finagle.stats.logOnShutdown() should equal(false) // verify default value unchanged
}

test("POST /ctu-time") {
server.httpPost("/ctu-time",
postBody =
s"""
|{
| "time": "${Time.now.format("yyyy-MM-dd'T'HH:mm:ss.SSSZ")}"
|}
""".stripMargin,
andExpect = Status.Ok
)

server.httpPost("/ctu-time",
postBody =
s"""
|{
| "time": "${Time.now.format("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")}"
|}
""".stripMargin,
andExpect = Status.InternalServerError
)
}
}

Expand Up @@ -15,4 +15,5 @@ private[finatra] object SerDeSimpleModule extends SimpleModule {
classOf[DateTime],
new JodaDatetimeDeserializer(FormatConfig.DEFAULT_DATETIME_PARSER))
addDeserializer(classOf[ctu.Duration], DurationStringDeserializer)
addDeserializer(classOf[ctu.Time], TimeStringDeserializer())
}
@@ -0,0 +1,63 @@
package com.twitter.finatra.json.internal.serde

import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.deser.ContextualDeserializer
import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer
import com.fasterxml.jackson.databind.{BeanProperty, DeserializationContext, JsonDeserializer}
import com.twitter.util.{Time, TimeFormat}
import java.util.{Locale, TimeZone}

private[finatra] object TimeStringDeserializer {
private[this] val DefaultTimeFormat: String = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"

def apply(): TimeStringDeserializer = this(DefaultTimeFormat)

def apply(pattern: String): TimeStringDeserializer =
this(pattern, None, TimeZone.getTimeZone("UTC"))

def apply(pattern: String, locale: Option[Locale], timezone: TimeZone): TimeStringDeserializer =
new TimeStringDeserializer(new TimeFormat(pattern, locale, timezone))
}

private[finatra] class TimeStringDeserializer(
private[this] val timeFormat: TimeFormat
) extends StdScalarDeserializer[Time](classOf[Time]) with ContextualDeserializer {

override def deserialize(parser: JsonParser, context: DeserializationContext): Time = {
timeFormat.parse(parser.getValueAsString)
}

/**
* This method allows extracting the [[com.fasterxml.jackson.annotation.JsonFormat JsonFormat]]
* annotation and create a [[com.twitter.util.TimeFormat TimeFormat]] based on the specifications
* provided in the annotation. The implementation follows the Jackson's java8 & joda-time versions
*
* @param context Deserialization context to access configuration, additional deserializers
* that may be needed by this deserializer
* @param property Method, field or constructor parameter that represents the property (and is
* used to assign deserialized value). Should be available; but there may be
* cases where caller can not provide it and null is passed instead
* (in which case impls usually pass 'this' deserializer as is)
*
* @return Deserializer to use for deserializing values of specified property; may be this
* instance or a new instance.
*
* @see https://github.com/FasterXML/jackson-modules-java8/blob/master/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DateTimeDeserializerBase.java#L29
*/
override def createContextual(
context: DeserializationContext,
property: BeanProperty
): JsonDeserializer[_] = {
val deserializerOption: Option[TimeStringDeserializer] = for {
jsonFormat <- Option(findFormatOverrides(context, property, handledType()))
deserializer <- Option(TimeStringDeserializer(
jsonFormat.getPattern,
Option(jsonFormat.getLocale),
Option(jsonFormat.getTimeZone).getOrElse(TimeZone.getTimeZone("UTC"))
)) if jsonFormat.hasPattern
} yield {
deserializer
}
deserializerOption.getOrElse(this)
}
}
@@ -0,0 +1,67 @@
package com.twitter.finatra.json.tests.internal

import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import com.twitter.finatra.json.internal.serde.SerDeSimpleModule
import com.twitter.inject.Test
import com.twitter.util.{Time, TimeFormat}

case class WithoutJsonFormat(time: Time)

case class WithJsonFormat(@JsonFormat(pattern = "yyyy-MM-dd") time: Time)

case class WithJsonFormatAndTimezone(
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "America/Los_Angeles") time: Time
)

class TwitterTimeStringDeserializerTest extends Test {

private[this] final val Input1 =
"""
|{
| "time": "2019-06-17T15:45:00.000+0000"
|}
""".stripMargin

private[this] final val Input2 =
"""
|{
| "time": "2019-06-17"
|}
""".stripMargin

private[this] final val Input3 =
"""
|{
| "time": "2019-06-17 16:30:00"
|}
""".stripMargin

private[this] val objectMapper = new ObjectMapper()

override def beforeAll(): Unit = {
val modules = Seq(DefaultScalaModule, SerDeSimpleModule)
modules.foreach(objectMapper.registerModule)
}

test("should deserialize date without JsonFormat") {
val expected = 1560786300000L
val actual: WithoutJsonFormat = objectMapper.readValue(Input1, classOf[WithoutJsonFormat])
actual.time.inMillis shouldEqual expected
}

test("should deserialize date with JsonFormat") {
val expected: Time = new TimeFormat("yyyy-MM-dd").parse("2019-06-17")
val actual: WithJsonFormat = objectMapper.readValue(Input2, classOf[WithJsonFormat])
actual.time shouldEqual expected
}

test("should deserialize date with JsonFormat and timezone") {
val expected = 1560814200000L
val actual: WithJsonFormatAndTimezone = objectMapper.readValue(
Input3, classOf[WithJsonFormatAndTimezone])
actual.time.inMillis shouldEqual expected
}

}

0 comments on commit ed3d666

Please sign in to comment.