diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31cf20db25..fb2eae783d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ slf4j-version = "2.0.16" slf4j-v1x-version = "1.7.36" crt-kotlin-version = "0.8.10" micrometer-version = "1.13.6" +kotlinx-datetime-version = "0.6.1" # codegen smithy-version = "1.51.0" @@ -61,6 +62,7 @@ slf4j-api-v1x = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-v1x-versi slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j-version" } crt-kotlin = { module = "aws.sdk.kotlin.crt:aws-crt-kotlin", version.ref = "crt-kotlin-version" } micrometer-core = { module = "io.micrometer:micrometer-core", version.ref = "micrometer-version" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime-version" } smithy-codegen-core = { module = "software.amazon.smithy:smithy-codegen-core", version.ref = "smithy-version" } smithy-cli = { module = "software.amazon.smithy:smithy-cli", version.ref = "smithy-version" } diff --git a/runtime/runtime-core/build.gradle.kts b/runtime/runtime-core/build.gradle.kts index d902f42a7f..3765a1b33b 100644 --- a/runtime/runtime-core/build.gradle.kts +++ b/runtime/runtime-core/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { nativeMain { dependencies { api(libs.crt.kotlin) + implementation(libs.kotlinx.datetime) } } diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt index 18d005b660..025e1cd668 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/TimestampFormat.kt @@ -9,10 +9,10 @@ package aws.smithy.kotlin.runtime.time */ public enum class TimestampFormat { /** - * ISO-8601/RFC5399 timestamp including fractional seconds at microsecond precision (e.g., + * ISO-8601/RFC3339 timestamp including fractional seconds at microsecond precision (e.g., * "2022-04-25T16:44:13.667307Z") * - * Prefers RFC5399 when formatting + * Prefers RFC3339 when formatting */ ISO_8601, @@ -28,7 +28,7 @@ public enum class TimestampFormat { ISO_8601_CONDENSED_DATE, /** - * ISO-8601/RFC5399 timestamp including fractional seconds at arbitrary (i.e., untruncated) precision + * ISO-8601/RFC3339 timestamp including fractional seconds at arbitrary (i.e., untruncated) precision */ ISO_8601_FULL, diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt index 64d6d86286..9659754e4c 100644 --- a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt @@ -4,7 +4,6 @@ */ package aws.smithy.kotlin.runtime.time -import aws.smithy.kotlin.runtime.IgnoreNative import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -18,7 +17,6 @@ import kotlin.time.Duration.Companion.seconds // tests for conversion from a parsed representation into an Instant instance class InstantTest { - /** * Conversion from a string to epoch sec/ns */ @@ -58,11 +56,8 @@ class InstantTest { // leap second - dropped to: 2020-12-31T23:59:59 FromTest("2020-12-31T23:59:60Z", 1609459199, 0), - // midnight - should be 11/5 12AM - FromTest("2020-11-04T24:00:00Z", 1604534400, 0), ) - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testFromIso8601() { for ((idx, test) in iso8601Tests.withIndex()) { @@ -101,7 +96,6 @@ class InstantTest { TimestampFormat.ISO_8601_CONDENSED_DATE to Iso8601FmtTest::expectedIso8601CondDate, ) - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testFormatAsIso8601() { for ((idx, test) in iso8601FmtTests.withIndex()) { @@ -110,7 +104,7 @@ class InstantTest { .fromEpochSeconds(test.sec, test.ns) .format(format) val expected = getter(test) - assertEquals(expected, actual, "test[$idx]: failed to correctly format Instant as $format") + assertEquals(expected, actual, "test[$idx]: failed to correctly format Instant.fromEpochSeconds(${test.sec}, ${test.ns}) as $format") } } } @@ -125,7 +119,6 @@ class InstantTest { FromTest("Thu, 05 Nov 2020 19:22:37 -1245", 1604650057, 0), ) - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testFromRfc5322() { for ((idx, test) in rfc5322Tests.withIndex()) { @@ -143,7 +136,6 @@ class InstantTest { FmtTest(1604650057, 0, "Fri, 06 Nov 2020 08:07:37 GMT"), ) - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testFormatAsRfc5322() { for ((idx, test) in rfc5322FmtTests.withIndex()) { @@ -162,7 +154,6 @@ class InstantTest { FmtTest(1604604157, 345_006_000, "1604604157.345006"), ) - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testFormatAsEpochSeconds() { for ((idx, test) in epochFmtTests.withIndex()) { @@ -173,7 +164,6 @@ class InstantTest { } } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testToEpochDouble() { val sec = 1604604157L @@ -184,7 +174,6 @@ class InstantTest { assertTrue(kotlin.math.abs(0.012345 - fracSecs) < 0.00001) } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testGetCurrentTime() { val currentTime = Instant.now() @@ -194,7 +183,6 @@ class InstantTest { assertTrue(currentTime.epochSeconds > pastInstant) } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testGetEpochMilliseconds() { val instant = Instant.fromEpochSeconds(1602878160, 200_000) @@ -206,7 +194,6 @@ class InstantTest { assertEquals(expected2, instantWithMilli.epochMilliseconds) } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testFromEpochMilliseconds() { val ts1 = 1602878160000L @@ -218,54 +205,34 @@ class InstantTest { assertEquals(expected2, Instant.fromEpochMilliseconds(ts2)) } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testNegativeFromEpochSeconds() { val timestamp = Instant.fromEpochSeconds(-806976000L) assertEquals("1944-06-06T00:00:00Z", timestamp.toString()) } - // Select tests pulled from edge cases/tickets in the V2 Java SDK. - // Always good to learn from others... - class V2JavaSdkTests { - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation - @Test - fun v2JavaSdkTt0031561767() { - val input = "Fri, 16 May 2014 23:56:46 GMT" - val instant: Instant = Instant.fromRfc5322(input) - assertEquals(input, instant.format(TimestampFormat.RFC_5322)) - } + @Test + fun testUntil() { + val untilTests = mapOf( + ("2013-01-01T00:00:00+00:00" to "2014-01-01T00:00:00+00:00") to 365.days, + ("2020-01-01T00:00:00+00:00" to "2021-01-01T00:00:00+00:00") to 366.days, // leap year! + ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:00:00+00:00") to Duration.ZERO, + ("2023-10-06T00:00:00+00:00" to "2023-10-07T00:00:00+00:00") to 1.days, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T01:00:00+00:00") to 1.hours, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:01:00+00:00") to 1.minutes, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:00:01+00:00") to 1.seconds, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T12:12:12+00:00") to 12.hours + 12.minutes + 12.seconds, + ) - /** - * Tests the Date marshalling and unmarshalling. Asserts that the value is - * same before and after marshalling/unmarshalling - */ - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation - @Test - fun v2JavaSdkUnixTimestampRoundtrip() { - // v2 sdk used currentTimeMillis(), instead we just hard code a value here - // otherwise that would be a JVM specific test since since we do not (yet) have - // a Kotlin MPP way of getting current timestamp. Also obviously not using epoch mill - // but instead just epoch sec. Spirit of the test is the same though - longArrayOf(1595016457, 1L, 0L) - .map { Instant.fromEpochSeconds(0, 0) } - .forEach { instant -> - val serverSpecificDateFormat: String = instant.format(TimestampFormat.EPOCH_SECONDS) - val parsed: Instant = parseEpoch(serverSpecificDateFormat) - assertEquals(instant.epochSeconds, parsed.epochSeconds) - } - } + for ((times, expectedDuration) in untilTests) { + val start = Instant.fromIso8601(times.first) + val end = Instant.fromIso8601(times.second) - // NOTE: There is additional set of edge case tests related to a past issue - // in DateUtilsTest.java in the v2 sdk. Specifically around - // issue 223: https://github.com/aws/aws-sdk-java/issues/233 - // - // (1) - That issue is about round tripping values between SDK versions - // (2) - The input year in those tests is NOT valid and should never have - // been accepted by the parser. + assertEquals(expectedDuration, start.until(end)) + assertEquals(end.until(start), -expectedDuration) + } } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testPlusMinusDuration() { val start = Instant.fromEpochSeconds(1000, 1000) @@ -275,7 +242,6 @@ class InstantTest { assertEquals(Instant.fromEpochSeconds(990, 0), start - offset) } - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation @Test fun testRoundTripUtcOffset() { // sanity check we only ever emit UTC timestamps (e.g. round trip a response with UTC offset) @@ -293,27 +259,42 @@ class InstantTest { assertEquals(test.second, actual, "test[$idx]: failed to format offset timestamp in UTC") } } +} - @IgnoreNative // FIXME Re-enable after Kotlin/Native implementation +// Select tests pulled from edge cases/tickets in the V2 Java SDK. +// Always good to learn from others... +class V2JavaSdkTests { @Test - fun testUntil() { - val untilTests = mapOf( - ("2013-01-01T00:00:00+00:00" to "2014-01-01T00:00:00+00:00") to 365.days, - ("2020-01-01T00:00:00+00:00" to "2021-01-01T00:00:00+00:00") to 366.days, // leap year! - ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:00:00+00:00") to Duration.ZERO, - ("2023-10-06T00:00:00+00:00" to "2023-10-07T00:00:00+00:00") to 1.days, - ("2023-10-06T00:00:00+00:00" to "2023-10-06T01:00:00+00:00") to 1.hours, - ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:01:00+00:00") to 1.minutes, - ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:00:01+00:00") to 1.seconds, - ("2023-10-06T00:00:00+00:00" to "2023-10-06T12:12:12+00:00") to 12.hours + 12.minutes + 12.seconds, - ) - - for ((times, expectedDuration) in untilTests) { - val start = Instant.fromIso8601(times.first) - val end = Instant.fromIso8601(times.second) + fun v2JavaSdkTt0031561767() { + val input = "Fri, 16 May 2014 23:56:46 GMT" + val instant: Instant = Instant.fromRfc5322(input) + assertEquals(input, instant.format(TimestampFormat.RFC_5322)) + } - assertEquals(expectedDuration, start.until(end)) - assertEquals(end.until(start), -expectedDuration) - } + /** + * Tests the Date marshalling and unmarshalling. Asserts that the value is + * same before and after marshalling/unmarshalling + */ + @Test + fun v2JavaSdkUnixTimestampRoundtrip() { + // v2 sdk used currentTimeMillis(), instead we just hard code a value here + // otherwise that would be a JVM specific test since since we do not (yet) have + // a Kotlin MPP way of getting current timestamp. Also obviously not using epoch mill + // but instead just epoch sec. Spirit of the test is the same though + longArrayOf(1595016457, 1L, 0L) + .map { Instant.fromEpochSeconds(0, 0) } + .forEach { instant -> + val serverSpecificDateFormat: String = instant.format(TimestampFormat.EPOCH_SECONDS) + val parsed: Instant = parseEpoch(serverSpecificDateFormat) + assertEquals(instant.epochSeconds, parsed.epochSeconds) + } } + + // NOTE: There is additional set of edge case tests related to a past issue + // in DateUtilsTest.java in the v2 sdk. Specifically around + // issue 223: https://github.com/aws/aws-sdk-java/issues/233 + // + // (1) - That issue is about round tripping values between SDK versions + // (2) - The input year in those tests is NOT valid and should never have + // been accepted by the parser. } diff --git a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/DateTimeFormats.kt b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/DateTimeFormats.kt new file mode 100644 index 0000000000..6ca75c5f87 --- /dev/null +++ b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/DateTimeFormats.kt @@ -0,0 +1,127 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package aws.smithy.kotlin.runtime.time + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.format.DateTimeComponents +import kotlinx.datetime.format.DayOfWeekNames +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.alternativeParsing +import kotlinx.datetime.format.char +import kotlinx.datetime.format.optional + +/** + * [DateTimeFormat] for use with [kotlinx.datetime.Instant] + */ +internal object DateTimeFormats { + + /** + * ISO8601, full precision. Corresponds to [TimestampFormat.ISO_8601_FULL]. Truncate to microseconds for [TimestampFormat.ISO_8601]. + * e.g. "2020-11-05T19:22:37+00:00" + */ + val ISO_8601 = DateTimeComponents.Format { + // Two possible date formats: YYYY-MM-DD or YYYYMMDD + alternativeParsing({ + date( + LocalDate.Format { + year() + monthNumber() + dayOfMonth() + }, + ) + }) { + date( + LocalDate.Format { + year() + char('-') + monthNumber() + char('-') + dayOfMonth() + }, + ) + } + + char('T') + + // Two possible time formats: HH:MM:SS or HHMMSS + alternativeParsing({ + hour() + minute() + second() + }) { + hour() + char(':') + minute() + char(':') + second() + } + + // Fractional seconds + optional { + char('.') + secondFraction(1, 9) + } + + // Offsets + alternativeParsing({ + offsetHours() + }) { + offset(UtcOffset.Formats.ISO) + } + } + + /** + * ISO8601 condensed. Corresponds to [TimestampFormat.ISO_8601_CONDENSED]. + */ + val ISO_8601_CONDENSED = DateTimeComponents.Format { + year() + monthNumber() + dayOfMonth() + + char('T') + hour() + minute() + second() + char('Z') + } + + /** + * ISO8601 condensed, date only. Corresponds to [TimestampFormat.ISO_8601_CONDENSED_DATE] + */ + val ISO_8601_CONDENSED_DATE = DateTimeComponents.Format { + year() + monthNumber() + dayOfMonth() + } + + /** + * [RFC-5322/2822/822 IMF timestamp](https://tools.ietf.org/html/rfc5322). Corresponds to [TimestampFormat.RFC_5322]. + * e.g. "Thu, 05 Nov 2020 19:22:37 +0000" + */ + val RFC_5322 = DateTimeComponents.Format { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + chars(", ") + + dayOfMonth() + char(' ') + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + year() + char(' ') + + hour() + char(':') + minute() + char(':') + second() + char(' ') + + optional("GMT") { + offset(UtcOffset.Formats.FOUR_DIGITS) + } + } +} diff --git a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt index 56e4b8d3ba..899419245a 100644 --- a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt +++ b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt @@ -5,92 +5,106 @@ package aws.smithy.kotlin.runtime.time +import kotlinx.datetime.Clock +import kotlinx.datetime.format import kotlin.time.Duration +import kotlinx.datetime.Instant as KtInstant -// FIXME Consider making this multiplatform (`common`) using kotlinx.datetime -public actual class Instant : Comparable { - actual override fun compareTo(other: Instant): Int { - TODO("Not yet implemented") - } +private fun TimestampFormat.asDateTimeFormat() = when (this) { + TimestampFormat.RFC_5322 -> DateTimeFormats.RFC_5322 + TimestampFormat.ISO_8601_FULL -> DateTimeFormats.ISO_8601 + TimestampFormat.ISO_8601_CONDENSED -> DateTimeFormats.ISO_8601_CONDENSED + TimestampFormat.ISO_8601_CONDENSED_DATE -> DateTimeFormats.ISO_8601_CONDENSED_DATE + else -> throw IllegalArgumentException("TimestampFormat $this could not be converted to a DateTimeFormat") +} + +private fun KtInstant.truncateToMicros(): KtInstant = KtInstant.fromEpochSeconds(epochSeconds, nanosecondsOfSecond / 1_000 * 1_000) + +public actual class Instant(internal val delegate: KtInstant) : Comparable { - public actual val epochSeconds: Long - get() = TODO("Not yet implemented") - public actual val nanosecondsOfSecond: Int - get() = TODO("Not yet implemented") + actual override fun compareTo(other: Instant): Int = delegate.compareTo(other.delegate) + + public actual val epochSeconds: Long = delegate.epochSeconds + public actual val nanosecondsOfSecond: Int = delegate.nanosecondsOfSecond /** * Encode the [Instant] as a string into the format specified by [TimestampFormat] */ - public actual fun format(fmt: TimestampFormat): String { - TODO("Not yet implemented") + public actual fun format(fmt: TimestampFormat): String = when (fmt) { + TimestampFormat.ISO_8601 -> delegate.truncateToMicros().format(DateTimeFormats.ISO_8601) + TimestampFormat.EPOCH_SECONDS -> { + val s = delegate.epochSeconds.toString() + val ns = if (delegate.nanosecondsOfSecond != 0) { + ".${delegate.nanosecondsOfSecond.toString().padStart(9, '0').trimEnd('0')}" + } else { + "" + } + s + ns + } + else -> delegate.format(fmt.asDateTimeFormat()) } /** * Returns an instant that is the result of adding the specified [duration] to this instant. * NOTE: Duration may be negative in which case the returned Instant will be earlier than this Instant. */ - public actual operator fun plus(duration: Duration): Instant { - TODO("Not yet implemented") - } + public actual operator fun plus(duration: Duration): Instant = Instant(delegate + duration) /** * Returns an instant that is the result of subtracting the specified [duration] from this instant. * NOTE: Duration may be negative in which case the returned Instant will be later than this Instant. */ - public actual operator fun minus(duration: Duration): Instant { - TODO("Not yet implemented") - } + public actual operator fun minus(duration: Duration): Instant = Instant(delegate - duration) - public actual operator fun minus(other: Instant): Duration { - TODO("Not yet implemented") - } + public actual operator fun minus(other: Instant): Duration = delegate - other.delegate public actual companion object { /** * Parse an ISO-8601 formatted string into an [Instant] */ public actual fun fromIso8601(ts: String): Instant { - TODO("Not yet implemented") + var parsed = DateTimeFormats.ISO_8601.parse(ts).apply { + // Handle leap seconds (23:59:60 becomes 23:59:59) + if (second == 60) { + second = 59 + } + } + + return Instant(parsed.toInstantUsingOffset()) } /** * Parse an RFC5322/RFC-822 formatted string into an [Instant] */ - public actual fun fromRfc5322(ts: String): Instant { - TODO("Not yet implemented") - } + public actual fun fromRfc5322(ts: String): Instant = Instant(KtInstant.parse(ts, DateTimeFormats.RFC_5322)) /** * Create an [Instant] from its parts */ - public actual fun fromEpochSeconds(seconds: Long, ns: Int): Instant { - TODO("Not yet implemented") - } + public actual fun fromEpochSeconds(seconds: Long, ns: Int): Instant = Instant(KtInstant.fromEpochSeconds(seconds, ns)) /** * Parse a string formatted as epoch-seconds into an [Instant] */ - public actual fun fromEpochSeconds(ts: String): Instant { - TODO("Not yet implemented") - } + public actual fun fromEpochSeconds(ts: String): Instant = fromEpochSeconds(ts.toLong(), 0) /** * Create an [Instant] from the current system time */ - public actual fun now(): Instant { - TODO("Not yet implemented") - } + public actual fun now(): Instant = Instant(Clock.System.now()) /** * Create an [Instant] with the minimum possible value */ - public actual val MIN_VALUE: Instant - get() = TODO("Not yet implemented") + public actual val MIN_VALUE: Instant = Instant(KtInstant.DISTANT_PAST) /** * Create an [Instant] with the maximum possible value */ - public actual val MAX_VALUE: Instant - get() = TODO("Not yet implemented") + public actual val MAX_VALUE: Instant = Instant(KtInstant.DISTANT_FUTURE) } + + public override fun equals(other: Any?): Boolean = other is Instant && delegate == other.delegate + public override fun toString(): String = delegate.toString() + public override fun hashCode(): Int = delegate.hashCode() }