diff --git a/Src/Support/Google.Apis.Core/Util/Utilities.cs b/Src/Support/Google.Apis.Core/Util/Utilities.cs index 4ebc373cc9..bec4f60204 100644 --- a/Src/Support/Google.Apis.Core/Util/Utilities.cs +++ b/Src/Support/Google.Apis.Core/Util/Utilities.cs @@ -183,6 +183,7 @@ internal static string ConvertToRFC3339(DateTime date) { date = date.ToUniversalTime(); } + // TODO: Handle finer precision than milliseconds? return date.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", DateTimeFormatInfo.InvariantInfo); } @@ -211,28 +212,89 @@ public static string GetStringFromDateTime(DateTime? date) } /// - /// Parses the input string and returns if the input is - /// of the format "yyyy-MM-ddTHH:mm:ss.FFFZ" or "yyyy-MM-ddTHH:mm:ssZ". If the input is null, - /// this method returns null. Otherwise, is thrown. + /// Parses the input string and returns if the input has 0, 3, 6 or 9 + /// subsecond digits. If the input is null, this method returns null. Otherwise, is thrown. + /// If 9 subsecond digits are present, the result is truncated as if the final two digits are 0 (as the "tick" + /// precision of DateTimeOffset is 100 nanoseconds). /// - public static DateTimeOffset? GetDateTimeOffsetFromString(string raw) => - raw is null - ? (DateTimeOffset?) null - : DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss.FFF'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + public static DateTimeOffset? GetDateTimeOffsetFromString(string raw) + { + // Date part is 10 characters; time to seconds is 8. + // We need to take account of the "T" separator, the decimal separator for subseconds, and the trailing 'Z'. + const int SecondsOnlyLength = 20; + const int MillisecondsLength = SecondsOnlyLength + 4; + const int MicrosecondsLength = MillisecondsLength + 3; + const int NanosecondsLength = MicrosecondsLength + 3; + const int NanosecondsBeyondTicksIndex = NanosecondsLength - 3; + if (raw is null) + { + return null; + } + return raw.Length switch + { + SecondsOnlyLength => DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), + MillisecondsLength => DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), + MicrosecondsLength => DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), + // Note: deliberately no Z here. + NanosecondsLength => DateTimeOffset.ParseExact(AdjustForNanoseconds(raw), "yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), + _ => ThrowFormatException() + }; + + // If we receive "2023-10-2713:45:00.123456789Z" we need to parse it + // as "2023-10-2713:45:00.1234567Z" (i.e. to tick precision) as .NET doesn't support + // parsing and automatically truncating nanoseconds. + static string AdjustForNanoseconds(string raw) + { + ValidateDigit(raw[NanosecondsBeyondTicksIndex]); + ValidateDigit(raw[NanosecondsBeyondTicksIndex + 1]); + // This avoids us having to concatenate the substring with "Z". + if (raw[NanosecondsBeyondTicksIndex + 2] != 'Z') + { + ThrowFormatException(); + } + return raw.Substring(0, NanosecondsBeyondTicksIndex); + } + + static void ValidateDigit(char c) + { + if (c < '0' || c > '9') + { + ThrowFormatException(); + } + } + + // Note: this returns DateTimeOffset so we can use it in the switch... + static DateTimeOffset ThrowFormatException() => + throw new FormatException("String was not recognized as a valid DateTime"); + } /// /// Returns a string from the input instance, or null if - /// is null. The string is always in the format "yyyy-MM-ddTHH:mm:ss.fffZ" or - /// "yyyy-MM-ddTHH:mm:ssZ" - always UTC, always either second or millisecond precision, and always using the - /// invariant culture. + /// is null. The string is always in the format "yyyy-MM-ddTHH:mm:ssZ" or + /// "yyyy-MM-ddTHH:mm:ss.Z" or + /// "yyyy-MM-ddTHH:mm:ssZ". It is always UTC, always either second, millisecond, microsecond or nanosecond precision, + /// and always using the invariant culture. When using nanosecond precision, the last two digits will always be 0 + /// as /// - public static string GetStringFromDateTimeOffset(DateTimeOffset? date) => - date is null - ? null - // While FFF sounds like it should work, we really want to produce no subsecond parts or 3 digits. - : date.Value.Millisecond == 0 ? date.Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") - : date.Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); - + public static string GetStringFromDateTimeOffset(DateTimeOffset? date) + { + const int TicksPerMicrosecond = 10; + if (date is not DateTimeOffset dto) + { + return null; + } + dto = dto.ToUniversalTime(); + // Note: DateTimeOffset.Ticks is always non-negative, which makes things simpler. + long tickOfSecond = dto.Ticks % TimeSpan.TicksPerSecond; + string pattern = tickOfSecond switch + { + 0 => "yyyy-MM-dd'T'HH:mm:ss'Z'", + _ when tickOfSecond % TimeSpan.TicksPerMillisecond == 0 => "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", + _ when tickOfSecond % TicksPerMicrosecond == 0 => "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'", + _ => "yyyy-MM-dd'T'HH:mm:ss.fffffff'00Z'" + }; + return date.Value.ToUniversalTime().ToString(pattern, CultureInfo.InvariantCulture); + } /// /// Deserializes the given raw value to an object using , /// as if it were a JSON string value. diff --git a/Src/Support/Google.Apis.Tests/Apis/Utils/UtilitiesTest.cs b/Src/Support/Google.Apis.Tests/Apis/Utils/UtilitiesTest.cs index fcdf7cda86..df0da66239 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Utils/UtilitiesTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Utils/UtilitiesTest.cs @@ -15,7 +15,6 @@ limitations under the License. */ using Google.Apis.Util; -using Newtonsoft.Json.Linq; using System; using System.Globalization; using System.Threading; @@ -89,38 +88,51 @@ public void GetDateTimeOffsetFromString_Null() => [InlineData("broken")] [InlineData("")] [InlineData("2023-13-01T00:00:00Z")] + [InlineData("2023-06-14T12:23:45.5Z")] // Subsecond digit count must be a multiple of 3 [InlineData("2023-13-01T00:00:00+00")] // We require the Z [InlineData("2023-13-01T00:00:00+01")] // We require the Z - [InlineData("2023-12-01T00:00:00.000000Z")] // We only support millisecond precision + [InlineData("2023-06-14T12:23:45.0000000x0Z")] // Broken past ticks + [InlineData("2023-06-14T12:23:45.00000000xZ")] // Broken past ticks + [InlineData("2023-06-14T12:23:45.000000000z")] // Broken past ticks public void GetDateTimeOffsetFromString_Invalid(string input) => Assert.Throws(() => Utilities.GetDateTimeOffsetFromString(input)); + // Note on formatting for tick-of-second for the theory data in the following tests: + // ticks are 100ns, so xxx_yyy_z is "xxx milliseconds, yyy microseconds, z ticks within the microsecond" + [Theory] [InlineData("2023-12-01T00:00:00Z", 2023, 12, 1, 0, 0, 0, 0)] [InlineData("0001-01-01T00:00:00Z", 1, 1, 1, 0, 0, 0, 0)] - [InlineData("9999-12-31T23:59:59.999Z", 9999, 12, 31, 23, 59, 59, 999)] - [InlineData("2023-06-14T12:23:45.5Z", 2023, 6, 14, 12,23, 45, 500)] // This is slightly unfortunate, but not actively harmful. - [InlineData("2023-06-14T12:23:45.123Z", 2023, 6, 14, 12, 23, 45, 123)] + [InlineData("9999-12-31T23:59:59.999Z", 9999, 12, 31, 23, 59, 59, 999_000_0)] + [InlineData("9999-12-31T23:59:59.999999Z", 9999, 12, 31, 23, 59, 59, 999_999_0)] + [InlineData("9999-12-31T23:59:59.999999999Z", 9999, 12, 31, 23, 59, 59, 999_999_9)] + + [InlineData("2023-06-14T12:23:45.123Z", 2023, 6, 14, 12, 23, 45, 123_000_0)] + [InlineData("2023-06-14T12:23:45.123456Z", 2023, 6, 14, 12, 23, 45, 123_456_0)] + [InlineData("2023-06-14T12:23:45.123456789Z", 2023, 6, 14, 12, 23, 45, 123_456_7)] [InlineData("2023-06-14T12:23:45.000Z", 2023, 6, 14, 12, 23, 45, 0)] // .000 is redundant but valid + [InlineData("2023-06-14T12:23:45.000000Z", 2023, 6, 14, 12, 23, 45, 0)] // .000 is redundant but valid + [InlineData("2023-06-14T12:23:45.000000000Z", 2023, 6, 14, 12, 23, 45, 0)] // .000 is redundant but valid [InlineData("2023-06-14T12:23:45Z", 2023, 6, 14, 12, 23, 45, 0)] - public void GetDateTimeOffsetFromString_Valid(string input, int year, int month, int day, int hour, int minute, int second, int millisecond) + public void GetDateTimeOffsetFromString_Valid(string input, int year, int month, int day, int hour, int minute, int second, int tickOfSecond) { var actual = Utilities.GetDateTimeOffsetFromString(input).Value; - var expected = new DateTimeOffset(year, month, day, hour, minute, second, millisecond, TimeSpan.Zero); + var expected = new DateTimeOffset(year, month, day, hour, minute, second, TimeSpan.Zero).AddTicks(tickOfSecond); Assert.Equal(expected, actual); // Be explicit about this, as DateTimeOffset.Equals only compares instants in time, not offsets. Assert.Equal(TimeSpan.Zero, actual.Offset); } [Theory] - [InlineData(5000000, "2023-06-13T15:54:13.500Z")] - [InlineData(1234567, "2023-06-13T15:54:13.123Z")] - [InlineData(1239999, "2023-06-13T15:54:13.123Z")] - [InlineData(9999999, "2023-06-13T15:54:13.999Z")] - [InlineData(10000, "2023-06-13T15:54:13.001Z")] - [InlineData(100000, "2023-06-13T15:54:13.010Z")] + [InlineData(500_000_0, "2023-06-13T15:54:13.500Z")] + [InlineData(123_456_0, "2023-06-13T15:54:13.123456Z")] + [InlineData(123_456_7, "2023-06-13T15:54:13.123456700Z")] + [InlineData(123_999_9, "2023-06-13T15:54:13.123999900Z")] + [InlineData(999_999_9, "2023-06-13T15:54:13.999999900Z")] + [InlineData(1_000_0, "2023-06-13T15:54:13.001Z")] + [InlineData(10_000_0, "2023-06-13T15:54:13.010Z")] [InlineData(0, "2023-06-13T15:54:13Z")] - public void GetStringFromDateTimeOffset_MillisecondHandling(int tickOfSecond, string expectedResult) + public void GetStringFromDateTimeOffset_SubsecondHandling(int tickOfSecond, string expectedResult) { var value = new DateTimeOffset(2023, 6, 13, 15, 54, 13, TimeSpan.Zero).AddTicks(tickOfSecond); Assert.Equal(expectedResult, Utilities.GetStringFromDateTimeOffset(value));