diff --git a/CHANGELOG.md b/CHANGELOG.md index 755af7288..2b30ed437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ When adding entries, please treat them as if they could end up in a release any Thank you! +# 0.18.28 + +* Better support for timestamps before Linux Epoch and trimming the Timestamp nanosecond part (see [#1623](https://github.com/disneystreaming/smithy4s/pull/1623)) + # 0.18.27 * Fix for how `NaN` is handled for `Float` and `Double` inside of the `MetadataDecoder` and `Range` constraint `RefinementProvider` diff --git a/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala b/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala index 820b88944..04c171f6c 100644 --- a/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala +++ b/modules/bootstrapped/test/src/smithy4s/DocumentSpec.scala @@ -629,6 +629,38 @@ class DocumentSpec() extends FunSuite { ) } + test( + "Document encoder - timestamp epoch seconds uses correct BigDecimal scale" + ) { + def check(ts: Timestamp, expectedScale: Int) = { + val result = Document.Encoder + .fromSchema(TimestampOperationInput.schema) + .encode(TimestampOperationInput(ts, ts, ts)) + inside(result) { case Document.DObject(fields) => + inside(fields.get("epochSeconds")) { + case Some(Document.DNumber(bigDecimal)) => + expect.same(bigDecimal.scale, expectedScale) + } + } + } + check(Timestamp(1L, 0), 0) + check(Timestamp(1L, 500 * 1000 * 1000), 1) + check(Timestamp(1L, 123 * 1000 * 1000), 3) + } + + test("Document decoder - timestamps before linux epoch") { + val doc = + Document.obj("epochSeconds" -> Document.fromBigDecimal(-0.999999877)) + val result = Document.Decoder + .fromSchema(TimestampOperationInput.schema) + .decode(doc) + expect.same( + result, + Right(TimestampOperationInput(epochSeconds = Timestamp(-1, 123))) + ) + + } + test("Document decoder - timestamp defaults") { val doc = Document.obj() val result = Document.Decoder @@ -921,4 +953,12 @@ class DocumentSpec() extends FunSuite { assertEquals(doc, expected) } + private def inside[A, B]( + a: A + )(assertPF: PartialFunction[A, Unit])(implicit loc: munit.Location) = { + assertPF.lift + .apply(a) + .getOrElse(Assertions.fail("Value did not match the expected pattern")) + } + } diff --git a/modules/core/src/smithy4s/internals/DocumentDecoderSchemaVisitor.scala b/modules/core/src/smithy4s/internals/DocumentDecoderSchemaVisitor.scala index e959a169b..8547703ea 100644 --- a/modules/core/src/smithy4s/internals/DocumentDecoderSchemaVisitor.scala +++ b/modules/core/src/smithy4s/internals/DocumentDecoderSchemaVisitor.scala @@ -186,11 +186,9 @@ class DocumentDecoderSchemaVisitor( case EPOCH_SECONDS => DocumentDecoder.instance("Timestamp", "Number") { case (_, DNumber(value)) => - val epochSeconds = value.toLong - Timestamp( - epochSeconds, - ((value - epochSeconds) * 1000000000).toInt - ) + val epochSeconds = + value.setScale(0, BigDecimal.RoundingMode.FLOOR).toLong + Timestamp(epochSeconds, ((value - epochSeconds) * 1000000000).toInt) } } } diff --git a/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala b/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala index bc329006c..07c5b2fe4 100644 --- a/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala +++ b/modules/core/src/smithy4s/internals/DocumentEncoderSchemaVisitor.scala @@ -104,10 +104,18 @@ class DocumentEncoderSchemaVisitor( case HTTP_DATE => ts => DString(ts.format(HTTP_DATE)) case EPOCH_SECONDS => ts => - val epochSecondsWithNanos = - BigDecimal(ts.epochSecond) + (BigDecimal(ts.nano) * BigDecimal(10) - .pow(-9)) - DNumber(epochSecondsWithNanos) + DNumber( + BigDecimal({ + val es = java.math.BigDecimal.valueOf(ts.epochSecond) + if (ts.nano == 0) es + else + es.add( + java.math.BigDecimal + .valueOf(ts.nano.toLong, 9) + .stripTrailingZeros + ) + }) + ) } case PDocument => from(identity) case PFloat => from(float => DNumber(BigDecimal(float.toDouble))) diff --git a/modules/json/src/smithy4s/json/internals/SchemaVisitorJCodec.scala b/modules/json/src/smithy4s/json/internals/SchemaVisitorJCodec.scala index 8102667a2..8d0990ec5 100644 --- a/modules/json/src/smithy4s/json/internals/SchemaVisitorJCodec.scala +++ b/modules/json/src/smithy4s/json/internals/SchemaVisitorJCodec.scala @@ -389,16 +389,21 @@ private[smithy4s] class SchemaVisitorJCodec( def decodeValue(cursor: Cursor, in: JsonReader): Timestamp = { val timestamp = in.readBigDecimal(null) - val epochSecond = timestamp.toLong - Timestamp(epochSecond, ((timestamp - epochSecond) * 1000000000).toInt) + val epochSeconds = + timestamp.setScale(0, BigDecimal.RoundingMode.FLOOR).toLong + Timestamp(epochSeconds, ((timestamp - epochSeconds) * 1000000000).toInt) } def encodeValue(x: Timestamp, out: JsonWriter): Unit = { - if (x.nano == 0) { - out.writeVal(x.epochSecond) - } else { - out.writeVal(BigDecimal(x.epochSecond) + x.nano / 1000000000.0) - } + // TODO: can be improved with out.writeTimestampVal(x.epochSecond, x.nano) when https://github.com/plokhotnyuk/jsoniter-scala/releases/tag/v2.32.0 is used + out.writeVal(BigDecimal({ + val es = java.math.BigDecimal.valueOf(x.epochSecond) + if (x.nano == 0) es + else + es.add( + java.math.BigDecimal.valueOf(x.nano.toLong, 9).stripTrailingZeros + ) + })) } def decodeKey(in: JsonReader): Timestamp = { diff --git a/modules/json/test/src/smithy4s/json/JsonSpec.scala b/modules/json/test/src/smithy4s/json/JsonSpec.scala index 3c6a4170c..a7a0accde 100644 --- a/modules/json/test/src/smithy4s/json/JsonSpec.scala +++ b/modules/json/test/src/smithy4s/json/JsonSpec.scala @@ -71,4 +71,21 @@ class JsonSpec() extends FunSuite { assertEquals(roundTripped, Right(foo)) } + test("Json document respects BigDecimal resolution on read/write") { + val foo = + Document.obj( + "a" -> Document.fromBigDecimal(BigDecimal(1)), + "b" -> Document.fromBigDecimal(BigDecimal(1.1)) + ) + val result = Json.writeDocumentAsPrettyString(foo) + val roundTripped = Json.readDocument(Blob(result)) + val expectedJson = """|{ + | "a": 1, + | "b": 1.1 + |}""".stripMargin + + assertEquals(result, expectedJson) + assertEquals(roundTripped, Right(foo)) + } + } diff --git a/modules/json/test/src/smithy4s/json/SchemaVisitorJCodecTests.scala b/modules/json/test/src/smithy4s/json/SchemaVisitorJCodecTests.scala index c599c54d4..702d3366d 100644 --- a/modules/json/test/src/smithy4s/json/SchemaVisitorJCodecTests.scala +++ b/modules/json/test/src/smithy4s/json/SchemaVisitorJCodecTests.scala @@ -23,6 +23,7 @@ import com.github.plokhotnyuk.jsoniter_scala.core.{readFromString => _, _} import munit.FunSuite import smithy.api.Default import smithy.api.JsonName +import smithy.api.TimestampFormat import smithy4s.codecs.PayloadError import smithy4s.codecs.PayloadPath import smithy4s.example.CheckedOrUnchecked @@ -174,6 +175,30 @@ class SchemaVisitorJCodecTests() extends FunSuite { } } + case class Timestamps( + epochSeconds: Timestamp, + httpDate: Timestamp, + dateTime: Timestamp + ) + + object Timestamps { + def apply(timestamps: Timestamp): Timestamps = + Timestamps(timestamps, timestamps, timestamps) + implicit val schema: Schema[Timestamps] = { + struct( + timestamp + .required[Timestamps]("epochSeconds", _.epochSeconds) + .addHints(TimestampFormat.EPOCH_SECONDS.widen), + timestamp + .required[Timestamps]("httpDate", _.httpDate) + .addHints(TimestampFormat.HTTP_DATE.widen), + timestamp + .required[Timestamps]("dateTime", _.dateTime) + .addHints(TimestampFormat.DATE_TIME.widen) + )(Timestamps.apply) + } + } + test( "Compiling a codec for a recursive type should not blow up the stack" ) { @@ -185,6 +210,74 @@ class SchemaVisitorJCodecTests() extends FunSuite { expect.same(roundTripped, foo) } + test("Timestamps before linux epoch are encoded/decoded correctly") { + def roundTripCheck( + timestamps: Timestamps, + expectedEpochSeconds: String, + expectedHttpDate: String, + expectedDateTime: String + ) = { + val result = writeToString(timestamps) + expect.same( + result, + s"""{"epochSeconds":$expectedEpochSeconds,"httpDate":"$expectedHttpDate","dateTime":"$expectedDateTime"}""" + ) + val decoded = readFromString[Timestamps](result) + expect.same(decoded, timestamps) + } + roundTripCheck( + timestamps = Timestamps(Timestamp(1969, 12, 31, 23, 59, 59, 123)), + expectedEpochSeconds = "-0.999999877", + expectedHttpDate = "Wed, 31 Dec 1969 23:59:59.000000123 GMT", + expectedDateTime = "1969-12-31T23:59:59.000000123Z" + ) + } + + test("Timestamp nanoseconds are encoded/decoded correctly") { + def roundTripCheck( + nanos: Int, + expectedEpochSeconds: String, + expectedHttpDate: String, + expectedDateTime: String + ) = { + val timestamps = Timestamps(Timestamp(1970, 1, 1, 10, 11, 12, nanos)) + val result = writeToString(timestamps) + expect.same( + result, + f"""{"epochSeconds":$expectedEpochSeconds,"httpDate":"$expectedHttpDate","dateTime":"$expectedDateTime"}""" + ) + val decoded = readFromString[Timestamps](result) + expect.same(decoded, timestamps) + } + roundTripCheck( + nanos = 123, + expectedEpochSeconds = "36672.000000123", + expectedHttpDate = "Thu, 01 Jan 1970 10:11:12.000000123 GMT", + expectedDateTime = "1970-01-01T10:11:12.000000123Z" + ) + roundTripCheck( + nanos = 1230, + expectedEpochSeconds = "36672.00000123", + expectedHttpDate = "Thu, 01 Jan 1970 10:11:12.000001230 GMT", + expectedDateTime = "1970-01-01T10:11:12.000001230Z" + ) + + roundTripCheck( + nanos = 123000000, + expectedEpochSeconds = "36672.123", + expectedHttpDate = "Thu, 01 Jan 1970 10:11:12.123 GMT", + expectedDateTime = "1970-01-01T10:11:12.123Z" + ) + + roundTripCheck( + nanos = 0, + expectedEpochSeconds = "36672", + expectedHttpDate = "Thu, 01 Jan 1970 10:11:12 GMT", + expectedDateTime = "1970-01-01T10:11:12Z" + ) + + } + test("Optional encode from present value") { val foo = Foo(1, Some(2)) val json = """{"a":1,"_b":2}"""