From 13f96289c7b7e2f83004a42fc94a7f21947049a0 Mon Sep 17 00:00:00 2001 From: Lucas Satabin Date: Fri, 26 Jul 2024 09:36:57 +0200 Subject: [PATCH] Always render milliseconds for instant and time Checks that rendering date time and instant gives the same result, and that LocalTime also renders in the same way for both representations. --- .../main/scala/io/sphere/json/FromJSON.scala | 4 +- .../main/scala/io/sphere/json/ToJSON.scala | 14 +++- .../io/sphere/json/DateTimeParsingSpec.scala | 20 +++--- .../scala/io/sphere/json/JSONProperties.scala | 7 ++ .../io/sphere/json/JodaJavaTimeCompat.scala | 68 +++++++++++++++++++ 5 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 json/json-core/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala diff --git a/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala b/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala index 9fd0afd9..e109157b 100644 --- a/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala +++ b/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala @@ -391,7 +391,7 @@ object FromJSON extends FromJSONInstances { .appendPattern("'T'[HH[:mm[:ss]]]") .appendFraction(time.temporal.ChronoField.NANO_OF_SECOND, 0, 9, true) .optionalStart() - .appendZoneOrOffsetId() + .appendOffset("+HHmm", "Z") .optionalEnd() .optionalEnd() .parseDefaulting(time.temporal.ChronoField.MONTH_OF_YEAR, 1L) @@ -400,8 +400,8 @@ object FromJSON extends FromJSONInstances { .parseDefaulting(time.temporal.ChronoField.MINUTE_OF_HOUR, 0L) .parseDefaulting(time.temporal.ChronoField.SECOND_OF_MINUTE, 0L) .parseDefaulting(time.temporal.ChronoField.NANO_OF_SECOND, 0L) + .parseDefaulting(time.temporal.ChronoField.OFFSET_SECONDS, 0L) .toFormatter() - .withZone(time.ZoneOffset.UTC) implicit val javaInstantReader: FromJSON[time.Instant] = jsonStringReader("Failed to parse date/time: %s")(s => diff --git a/json/json-core/src/main/scala/io/sphere/json/ToJSON.scala b/json/json-core/src/main/scala/io/sphere/json/ToJSON.scala index 6bd24370..8cf778ea 100644 --- a/json/json-core/src/main/scala/io/sphere/json/ToJSON.scala +++ b/json/json-core/src/main/scala/io/sphere/json/ToJSON.scala @@ -185,14 +185,22 @@ object ToJSON extends ToJSONInstances { } // java.time + + // always format the milliseconds + private val javaInstantFormatter = new time.format.DateTimeFormatterBuilder() + .appendInstant(3) + .toFormatter() implicit val javaInstantWriter: ToJSON[time.Instant] = new ToJSON[time.Instant] { def write(value: time.Instant): JValue = JString( - time.format.DateTimeFormatter.ISO_INSTANT.format(value)) + javaInstantFormatter.format(time.OffsetDateTime.ofInstant(value, time.ZoneOffset.UTC))) } + // always format the milliseconds + private val javaLocalTimeFormatter = new time.format.DateTimeFormatterBuilder() + .appendPattern("HH:mm:ss.SSS") + .toFormatter() implicit val javaTimeWriter: ToJSON[time.LocalTime] = new ToJSON[time.LocalTime] { - def write(value: time.LocalTime): JValue = JString( - time.format.DateTimeFormatter.ISO_LOCAL_TIME.format(value)) + def write(value: time.LocalTime): JValue = JString(javaLocalTimeFormatter.format(value)) } implicit val javaDateWriter: ToJSON[time.LocalDate] = new ToJSON[time.LocalDate] { diff --git a/json/json-core/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala b/json/json-core/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala index aec11c62..562e9c3b 100644 --- a/json/json-core/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala +++ b/json/json-core/src/test/scala/io/sphere/json/DateTimeParsingSpec.scala @@ -114,52 +114,52 @@ class DateTimeParsingSpec extends AnyWordSpec with Matchers { // ensures that we accept similar patterns as joda when parsing instants "parsing a Java instant" should { "accept a full instant with milliseconds and offset" in { - javaInstantReader.read(JString("2004-06-09T12:24:48.501+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12:24:48.501+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:24:48.501Z")) } "accept a year with offset" in { - javaInstantReader.read(JString("2004T+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004T+0800")) shouldBe Valid( Instant.parse("2004-01-01T00:00:00+08:00")) } "accept a year month with offset" in { - javaInstantReader.read(JString("2004-06T+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06T+0800")) shouldBe Valid( Instant.parse("2004-06-01T00:00:00+08:00")) } "accept a year month day with offset" in { - javaInstantReader.read(JString("2004-06-09T+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T+0800")) shouldBe Valid( Instant.parse("2004-06-09T00:00:00+08:00")) } "accept a year month day with hour and offset" in { - javaInstantReader.read(JString("2004-06-09T12+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:00:00Z")) } "accept a year month day with hour, minute, and offset" in { - javaInstantReader.read(JString("2004-06-09T12:24+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12:24+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:24:00Z")) } "accept a year month day with hour, minute, second, and offset" in { - javaInstantReader.read(JString("2004-06-09T12:24:48+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12:24:48+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:24:48Z")) } "accept a year month day with hour, fraction, and offset" in { - javaInstantReader.read(JString("2004-06-09T12.5+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12.5+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:00:00.5Z")) } "accept a year month day with hour, minute, fraction, and offset" in { - javaInstantReader.read(JString("2004-06-09T12:24.5+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12:24.5+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:24:00.5Z")) } "accept a year month day with hour, minute, second, fraction, and offset" in { - javaInstantReader.read(JString("2004-06-09T12:24:48.5+08:00")) shouldBe Valid( + javaInstantReader.read(JString("2004-06-09T12:24:48.5+0800")) shouldBe Valid( Instant.parse("2004-06-09T04:24:48.5Z")) } diff --git a/json/json-core/src/test/scala/io/sphere/json/JSONProperties.scala b/json/json-core/src/test/scala/io/sphere/json/JSONProperties.scala index 00a10902..40ba1b5b 100644 --- a/json/json-core/src/test/scala/io/sphere/json/JSONProperties.scala +++ b/json/json-core/src/test/scala/io/sphere/json/JSONProperties.scala @@ -54,6 +54,13 @@ object JSONProperties extends Properties("JSON") { ms <- Gen.choose(0, 999) } yield new DateTime(y, m, d, h, min, s, ms, DateTimeZone.UTC)) + // generate dates between years -4000 and +4000 + implicit val javaInstant: Arbitrary[time.Instant] = + Arbitrary(Gen.choose(-188395027761000L, 64092207599999L).map(time.Instant.ofEpochMilli(_))) + + implicit val javaLocalTime: Arbitrary[time.LocalTime] = Arbitrary( + Gen.choose(0, 3600 * 24).map(time.LocalTime.ofSecondOfDay(_))) + implicit def arbitraryDate: Arbitrary[LocalDate] = Arbitrary(Arbitrary.arbitrary[DateTime].map(_.toLocalDate)) diff --git a/json/json-core/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala b/json/json-core/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala new file mode 100644 index 00000000..21437cfb --- /dev/null +++ b/json/json-core/src/test/scala/io/sphere/json/JodaJavaTimeCompat.scala @@ -0,0 +1,68 @@ +package io.sphere.json + +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import org.joda.time.YearMonth +import org.scalacheck.Arbitrary +import org.scalacheck.Gen +import org.scalacheck.Prop +import org.scalacheck.Properties +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.time.{Instant => JInstant} +import java.time.{LocalDate => JLocalDate} +import java.time.{LocalTime => JLocalTime} +import java.time.{YearMonth => JYearMonth} +import cats.data.Validated + +class JodaJavaTimeCompat extends Properties("Joda - java.time compat") { + val epochMillis = Gen.choose(-188395027761000L, 64092207599999L) + + implicit def arbitraryDateTime: Arbitrary[DateTime] = + Arbitrary(epochMillis.map(new DateTime(_, DateTimeZone.UTC))) + + // generate dates between years -4000 and +4000 + implicit val javaInstant: Arbitrary[JInstant] = + Arbitrary(epochMillis.map(JInstant.ofEpochMilli(_))) + + implicit val javaLocalTime: Arbitrary[JLocalTime] = Arbitrary( + Gen.choose(0, 3600 * 24).map(JLocalTime.ofSecondOfDay(_))) + + property("compatibility between serialized Instant and DateTime") = Prop.forAll { + (instant: JInstant) => + val dateTime = new DateTime(instant.toEpochMilli(), DateTimeZone.UTC) + val serializedInstant = ToJSON[JInstant].write(instant) + val serializedDateTime = ToJSON[DateTime].write(dateTime) + serializedInstant == serializedDateTime + } + + property("compatibility between serialized java.time.LocalTime and org.joda.time.LocalTime") = + Prop.forAll { (javaTime: JLocalTime) => + val jodaTime = LocalTime.fromMillisOfDay(javaTime.toNanoOfDay() / 1000000) + val serializedJavaTime = ToJSON[JLocalTime].write(javaTime) + val serializedJodaTime = ToJSON[LocalTime].write(jodaTime) + serializedJavaTime == serializedJodaTime + } + + property("roundtrip from java.time.Instant") = Prop.forAll { (instant: JInstant) => + FromJSON[DateTime] + .read(ToJSON[JInstant].write(instant)) + .andThen { dateTime => + FromJSON[JInstant].read(ToJSON[DateTime].write(dateTime)) + } + .fold(_ => false, _ == instant) + } + + property("roundtrip from org.joda.time.DateTime") = Prop.forAll { (dateTime: DateTime) => + FromJSON[JInstant] + .read(ToJSON[DateTime].write(dateTime)) + .andThen { instant => + FromJSON[DateTime].read(ToJSON[JInstant].write(instant)) + } + .fold(_ => false, _ == dateTime) + } + +}