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));