From 54bcb4748f0fbd75db3525cf9ce8e756eda54e10 Mon Sep 17 00:00:00 2001 From: dylan-apiiro Date: Mon, 20 Jul 2026 13:31:03 +0300 Subject: [PATCH] Make Member.ExpiresAt tolerant of unparseable expiry dates GitLab returns a project member's expires_at as a user-entered date that can be malformed (e.g. Fiserv's "20235-12-04", a 5-digit year beyond DateTime's max). Deserializing it into a non-nullable DateTime threw "Could not convert string to DateTime" and failed the whole member list, so the repository's access never synced. Add TolerantNullableDateTimeConverter, which yields null for unparseable/empty values, and make Member.ExpiresAt a nullable DateTime? using it. The rest of the member list now deserializes even when one member has a bad expiry date. expires_at is also genuinely optional in GitLab, so DateTime? is the correct type. Bump package version to 0.1.46 (0.1.45 was published then reverted). Co-Authored-By: Claude Opus 4.8 --- src/GitLabApiClient/GitLabApiClient.csproj | 2 +- .../TolerantNullableDateTimeConverter.cs | 41 +++++++++++++ src/GitLabApiClient/Models/Member.cs | 4 +- .../Internal/MemberExpiresAtTest.cs | 58 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/GitLabApiClient/Internal/Http/Serialization/TolerantNullableDateTimeConverter.cs create mode 100644 test/GitLabApiClient.Test/Internal/MemberExpiresAtTest.cs diff --git a/src/GitLabApiClient/GitLabApiClient.csproj b/src/GitLabApiClient/GitLabApiClient.csproj index 404739c4..9e8a2335 100644 --- a/src/GitLabApiClient/GitLabApiClient.csproj +++ b/src/GitLabApiClient/GitLabApiClient.csproj @@ -4,7 +4,7 @@ net8.0 10 Apiiro.GitLabApiClient - 0.1.44 + 0.1.46 Apiiro Apiiro GitLabApiClient diff --git a/src/GitLabApiClient/Internal/Http/Serialization/TolerantNullableDateTimeConverter.cs b/src/GitLabApiClient/Internal/Http/Serialization/TolerantNullableDateTimeConverter.cs new file mode 100644 index 00000000..a9ab827c --- /dev/null +++ b/src/GitLabApiClient/Internal/Http/Serialization/TolerantNullableDateTimeConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using Newtonsoft.Json; + +namespace GitLabApiClient.Internal.Http.Serialization +{ + internal sealed class TolerantNullableDateTimeConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => + objectType == typeof(DateTime?) || objectType == typeof(DateTime); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + switch (reader.TokenType) + { + case JsonToken.Null: + return null; + case JsonToken.Date: + return reader.Value is DateTime dateTime ? dateTime : (DateTime?)null; + case JsonToken.String: + return DateTime.TryParse((string)reader.Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + ? parsed + : (DateTime?)null; + default: + return null; + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value is DateTime dateTime) + { + writer.WriteValue(dateTime); + } + else + { + writer.WriteNull(); + } + } + } +} diff --git a/src/GitLabApiClient/Models/Member.cs b/src/GitLabApiClient/Models/Member.cs index dd63ca43..fbbd02b2 100644 --- a/src/GitLabApiClient/Models/Member.cs +++ b/src/GitLabApiClient/Models/Member.cs @@ -1,4 +1,5 @@ using System; +using GitLabApiClient.Internal.Http.Serialization; using Newtonsoft.Json; namespace GitLabApiClient.Models @@ -9,6 +10,7 @@ public sealed class Member : Account public int AccessLevel { get; set; } [JsonProperty("expires_at")] - public DateTime ExpiresAt { get; set; } + [JsonConverter(typeof(TolerantNullableDateTimeConverter))] + public DateTime? ExpiresAt { get; set; } } } diff --git a/test/GitLabApiClient.Test/Internal/MemberExpiresAtTest.cs b/test/GitLabApiClient.Test/Internal/MemberExpiresAtTest.cs new file mode 100644 index 00000000..189a1de1 --- /dev/null +++ b/test/GitLabApiClient.Test/Internal/MemberExpiresAtTest.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using GitLabApiClient.Models; +using Newtonsoft.Json; +using Xunit; + +namespace GitLabApiClient.Test.Internal +{ + public class MemberExpiresAtTest + { + [Fact] + public void UnparseableExpiresAt_YieldsNull_AndRestOfListStillDeserializes() + { + const string json = @"[ + { ""id"": 1, ""username"": ""valid"", ""access_level"": 30, ""expires_at"": ""2025-12-04"" }, + { ""id"": 2, ""username"": ""broken"", ""access_level"": 40, ""expires_at"": ""20235-12-04"" } + ]"; + + var members = JsonConvert.DeserializeObject>(json); + + members.Should().HaveCount(2); + members[0].ExpiresAt.Should().Be(new DateTime(2025, 12, 4)); + members[1].Username.Should().Be("broken"); + members[1].ExpiresAt.Should().BeNull(); + } + + [Fact] + public void NullExpiresAt_YieldsNull() + { + const string json = @"{ ""id"": 1, ""access_level"": 30, ""expires_at"": null }"; + + var member = JsonConvert.DeserializeObject(json); + + member.ExpiresAt.Should().BeNull(); + } + + [Fact] + public void MissingExpiresAt_YieldsNull() + { + const string json = @"{ ""id"": 1, ""access_level"": 30 }"; + + var member = JsonConvert.DeserializeObject(json); + + member.ExpiresAt.Should().BeNull(); + } + + [Fact] + public void ValidExpiresAt_IsParsed() + { + const string json = @"{ ""id"": 1, ""access_level"": 30, ""expires_at"": ""2025-12-04"" }"; + + var member = JsonConvert.DeserializeObject(json); + + member.ExpiresAt.Should().Be(new DateTime(2025, 12, 4)); + } + } +}