diff --git a/modules/core/src-js/smithy4s/Timestamp.scala b/modules/core/src-js/smithy4s/Timestamp.scala index 28772fcf2..c34dd2083 100644 --- a/modules/core/src-js/smithy4s/Timestamp.scala +++ b/modules/core/src-js/smithy4s/Timestamp.scala @@ -17,7 +17,6 @@ package smithy4s import smithy.api.TimestampFormat -import smithy4s.Timestamp._ import scalajs.js.Date import scala.util.control.{NoStackTrace, NonFatal} @@ -77,9 +76,10 @@ case class Timestamp private (epochSecond: Long, nano: Int) { marchDayOfYear - ((marchMonth * 1002762 - 16383) >> 15) // marchDayOfYear - (marchMonth * 306 + 5) / 10 + 1 internalFormat match { case 1 => - s.append(daysOfWeek(((epochDay + 700000003) % 7).toInt)).append(',') + s.append(Timestamp.daysOfWeek(((epochDay + 700000003) % 7).toInt)) + .append(',') append2Digits(day, s.append(' ')) - s.append(' ').append(months(month - 1)) + s.append(' ').append(Timestamp.months(month - 1)) append4Digits(year, s.append(' ')) appendTime(secsOfDay, s.append(' '), addSeparator = true) appendNano(nano, s) @@ -141,7 +141,7 @@ case class Timestamp private (epochSecond: Long, nano: Int) { append2Digits(q1, s) val q2 = r1 / 100000 val r2 = r1 - q2 * 100000 - val d = digits(q2) + val d = Timestamp.digits(q2) s.append(d.toByte.toChar) if (r2 != 0 || d > 0x3039) { // check if nano is divisible by 1000000 s.append((d >> 8).toByte.toChar) @@ -167,7 +167,7 @@ case class Timestamp private (epochSecond: Long, nano: Int) { } private[this] def append2Digits(x: Int, s: java.lang.StringBuilder): Unit = { - val d = digits(x) + val d = Timestamp.digits(x) val _ = s.append((d & 0xff).toChar).append((d >> 8).toChar) } @@ -352,6 +352,11 @@ object Timestamp { pos += 2 ch0 * 10 + ch1 - 528 // 528 == '0' * 11 } + var epochSecond = toEpochDay( + year, + month, + day + ) * 86400 + (hour * 3600 + minute * 60 + second) var nano = 0 var ch = (0: Char) if (pos < len) { @@ -372,15 +377,51 @@ object Timestamp { } } } - if (ch != 'Z' || pos != len) error() - new Timestamp( - toEpochDay( - year, - month, - day - ) * 86400 + (hour * 3600 + minute * 60 + second), - nano - ) + if (ch != 'Z') { + val isNeg = ch == '-' || (ch != '+' && { + error() + true + }) + if (pos + 2 > len) error() + var offsetTotal = { + val ch0 = s.charAt(pos) + val ch1 = s.charAt(pos + 1) + if (ch0 < '0' || ch0 > '1' || ch1 < '0' || ch1 > '9') error() + pos += 2 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } * 3600 + if ( + pos + 3 <= len && { + ch = s.charAt(pos) + pos += 1 + ch == ':' + } && { + offsetTotal += { + val ch0 = s.charAt(pos) + val ch1 = s.charAt(pos + 1) + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() + pos += 2 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } * 60 + pos + 3 <= len + } && { + ch = s.charAt(pos) + pos += 1 + ch == ':' + } + ) offsetTotal += { + val ch0 = s.charAt(pos) + val ch1 = s.charAt(pos + 1) + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() + pos += 2 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } + if (offsetTotal > 64800) error() // 64800 == 18 * 60 * 60 + if (isNeg) offsetTotal = -offsetTotal + epochSecond -= offsetTotal + } + if (pos != len) error() + new Timestamp(epochSecond, nano) } private[this] def parseEpochSeconds(s: String): Timestamp = { diff --git a/modules/core/src-jvm-native/smithy4s/Timestamp.scala b/modules/core/src-jvm-native/smithy4s/Timestamp.scala index 0fdc6af58..f5be6c212 100644 --- a/modules/core/src-jvm-native/smithy4s/Timestamp.scala +++ b/modules/core/src-jvm-native/smithy4s/Timestamp.scala @@ -329,6 +329,11 @@ object Timestamp extends TimestampCompanionPlatform { pos += 2 ch0 * 10 + ch1 - 528 // 528 == '0' * 11 } + var epochSecond = toEpochDay( + year, + month, + day + ) * 86400 + (hour * 3600 + minute * 60 + second) var nano = 0 var ch = (0: Char) if (pos < len) { @@ -349,15 +354,51 @@ object Timestamp extends TimestampCompanionPlatform { } } } - if (ch != 'Z' || pos != len) error() - new Timestamp( - toEpochDay( - year, - month, - day - ) * 86400 + (hour * 3600 + minute * 60 + second), - nano - ) + if (ch != 'Z') { + val isNeg = ch == '-' || (ch != '+' && { + error() + true + }) + if (pos + 2 > len) error() + var offsetTotal = { + val ch0 = s.charAt(pos) + val ch1 = s.charAt(pos + 1) + if (ch0 < '0' || ch0 > '1' || ch1 < '0' || ch1 > '9') error() + pos += 2 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } * 3600 + if ( + pos + 3 <= len && { + ch = s.charAt(pos) + pos += 1 + ch == ':' + } && { + offsetTotal += { + val ch0 = s.charAt(pos) + val ch1 = s.charAt(pos + 1) + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() + pos += 2 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } * 60 + pos + 3 <= len + } && { + ch = s.charAt(pos) + pos += 1 + ch == ':' + } + ) offsetTotal += { + val ch0 = s.charAt(pos) + val ch1 = s.charAt(pos + 1) + if (ch0 < '0' || ch0 > '5' || ch1 < '0' || ch1 > '9') error() + pos += 2 + ch0 * 10 + ch1 - 528 // 528 == '0' * 11 + } + if (offsetTotal > 64800) error() // 64800 == 18 * 60 * 60 + if (isNeg) offsetTotal = -offsetTotal + epochSecond -= offsetTotal + } + if (pos != len) error() + new Timestamp(epochSecond, nano) } private[this] def parseEpochSeconds(s: String): Timestamp = { diff --git a/modules/core/test/src-js/smithy4s/TimestampSpec.scala b/modules/core/test/src-js/smithy4s/TimestampSpec.scala index 64a832e29..170718d3f 100644 --- a/modules/core/test/src-js/smithy4s/TimestampSpec.scala +++ b/modules/core/test/src-js/smithy4s/TimestampSpec.scala @@ -78,6 +78,34 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { } } + property("Converts from DATE_TIME format with timezone offset") { + forAll { (i: Date, o: Int) => + val totalOffset = Math.abs(o % 64800) + val offsetHours = totalOffset / 3600 + val offsetMinutes = (totalOffset % 3600) / 60 + val offsetSeconds = totalOffset % 60 + val epochSecond = (i.valueOf() / 1000).toLong + val nano = (i.valueOf() % 1000).toInt * 1000000 + val formatted = Timestamp(epochSecond, nano) + .format(TimestampFormat.DATE_TIME) + .dropRight(1) + { + if (offsetSeconds == 0) + f"${if (o >= 0) "+" else "-"}$offsetHours%02d:$offsetMinutes%02d" + else + f"${if (o >= 0) "+" else "-"}$offsetHours%02d:$offsetMinutes%02d:$offsetSeconds%02d" + } + val ts = Timestamp( + epochSecond + { + if (o >= 0) -totalOffset + else totalOffset + }, + nano + ) + val parsed = Timestamp.parse(formatted, TimestampFormat.DATE_TIME) + expect.same(parsed, Some(ts)) + } + } + property("Converts to/from HTTP_DATE format") { forAll { (i: Date) => val epochSecond = (i.valueOf() / 1000).toLong @@ -110,13 +138,9 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { } property("Parse EPOCH_SECONDS format with invalid input") { - val EpochFormat = """^(\d+)(\.(\d+))?""".r - forAll { (str: String) => - val parsed = Timestamp.parse(str, TimestampFormat.EPOCH_SECONDS) - parsed match { - case Some(_) => expect(EpochFormat.pattern.matcher(str).matches) - case None => expect(!EpochFormat.pattern.matcher(str).matches) - } + forAll(Gen.alphaStr) { (str: String) => + val parsed = Timestamp.parse(str + "X", TimestampFormat.EPOCH_SECONDS) + expect.same(parsed, None) } } diff --git a/modules/core/test/src-jvm/smithy4s/TimestampSpec.scala b/modules/core/test/src-jvm/smithy4s/TimestampSpec.scala index 956d0fa1b..e9a1c41c6 100644 --- a/modules/core/test/src-jvm/smithy4s/TimestampSpec.scala +++ b/modules/core/test/src-jvm/smithy4s/TimestampSpec.scala @@ -87,6 +87,32 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { } } + property("Converts from DATE_TIME format with timezone offset") { + forAll { (i: Instant, o: Int) => + val totalOffset = Math.abs(o % 64800) + val offsetHours = totalOffset / 3600 + val offsetMinutes = (totalOffset % 3600) / 60 + val offsetSeconds = totalOffset % 60 + val formatted = Timestamp(i.getEpochSecond, i.getNano) + .format(TimestampFormat.DATE_TIME) + .dropRight(1) + { + if (offsetSeconds == 0) + f"${if (o >= 0) "+" else "-"}$offsetHours%02d:$offsetMinutes%02d" + else + f"${if (o >= 0) "+" else "-"}$offsetHours%02d:$offsetMinutes%02d:$offsetSeconds%02d" + } + val ts = Timestamp( + i.getEpochSecond + { + if (o >= 0) -totalOffset + else totalOffset + }, + i.getNano + ) + val parsed = Timestamp.parse(formatted, TimestampFormat.DATE_TIME) + expect.same(parsed, Some(ts)) + } + } + property("Converts to/from HTTP_DATE format") { forAll { (i: Instant) => val ts = Timestamp(i.getEpochSecond, i.getNano) @@ -122,8 +148,8 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite { property("Parse EPOCH_SECONDS format with invalid input") { forAll(Gen.alphaStr) { (str: String) => - val parsed = Timestamp.parse(str, TimestampFormat.EPOCH_SECONDS) - expect(parsed.isEmpty) + val parsed = Timestamp.parse(str + "X", TimestampFormat.EPOCH_SECONDS) + expect.same(parsed, None) } }