diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b11f09e5ec..5f3784b225 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 ------ diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/controllers/DoEverythingController.scala b/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/controllers/DoEverythingController.scala index 8f70a0bc20..787fc34374 100644 --- a/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/controllers/DoEverythingController.scala +++ b/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/controllers/DoEverythingController.scala @@ -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) diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/domain/DomainTwitterTimeRequest.scala b/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/domain/DomainTwitterTimeRequest.scala new file mode 100644 index 0000000000..e8191c1574 --- /dev/null +++ b/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/main/domain/DomainTwitterTimeRequest.scala @@ -0,0 +1,5 @@ +package com.twitter.finatra.http.tests.integration.doeverything.main.domain + +import com.twitter.util.Time + +case class DomainTwitterTimeRequest(time: Time) diff --git a/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/test/DoEverythingServerFeatureTest.scala b/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/test/DoEverythingServerFeatureTest.scala index ad605d2a6f..4335a13cad 100644 --- a/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/test/DoEverythingServerFeatureTest.scala +++ b/http/src/test/scala/com/twitter/finatra/http/tests/integration/doeverything/test/DoEverythingServerFeatureTest.scala @@ -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 @@ -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 + ) + } } diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/SerDeSimpleModule.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/SerDeSimpleModule.scala index 98764b3b3b..d6b5312ce5 100644 --- a/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/SerDeSimpleModule.scala +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/SerDeSimpleModule.scala @@ -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()) } diff --git a/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/TimeStringDeserializer.scala b/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/TimeStringDeserializer.scala new file mode 100644 index 0000000000..5a1ebc2a29 --- /dev/null +++ b/jackson/src/main/scala/com/twitter/finatra/json/internal/serde/TimeStringDeserializer.scala @@ -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) + } +} diff --git a/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/TwitterTimeStringDeserializerTest.scala b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/TwitterTimeStringDeserializerTest.scala new file mode 100644 index 0000000000..7a8500928e --- /dev/null +++ b/jackson/src/test/scala/com/twitter/finatra/json/tests/internal/TwitterTimeStringDeserializerTest.scala @@ -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 + } + +}