From a1e25af86b5caf4045f9945c78fad4bf20ef1386 Mon Sep 17 00:00:00 2001 From: PointStar Date: Mon, 11 May 2026 19:05:08 +0300 Subject: [PATCH] fix(json): tolerate EspoCRM's naive datetime format (YYYY-MM-DD HH:mm:ss) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cases lookup for "אברהם מוסטקי" was still failing after the Case.number fix, with a new exception: JsonException at $.list[0].createdAt The JSON value is not in a supported DateTimeOffset format. EspoCRM serializes datetimes as "2024-01-15 14:30:00" without a timezone designator, but .NET's built-in DateTimeOffset reader only accepts strict ISO 8601 with a tz suffix. Adding the FlexibleStringConverter to CreatedAt is not enough since the target type is DateTimeOffset, not string. Solution: a dedicated FlexibleDateTimeOffsetConverter that: - Accepts ISO 8601 with offset (fast path) - Accepts "YYYY-MM-DD HH:mm:ss" / "YYYY-MM-DD" / variants, treating them as UTC - Accepts Unix epoch numbers - Accepts null / empty strings Registered globally on the JsonOptions used by EspoCrmClient, so every model that exposes a DateTimeOffset? deserializes correctly without per-property attributes. Tests: 39 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Json/FlexibleDateTimeOffsetConverter.cs | 78 +++++++++++++++++++ .../Services/EspoCrmClient.cs | 21 +++-- 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs diff --git a/src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs b/src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs new file mode 100644 index 0000000..03cad1b --- /dev/null +++ b/src/OutlookAddin.Core/Json/FlexibleDateTimeOffsetConverter.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MarcusLaw.OutlookAddin.Core.Json +{ + /// + /// EspoCRM serializes date/datetime fields in one of these shapes: + /// * "YYYY-MM-DD HH:mm:ss" (datetime, naive — server is UTC by convention) + /// * "YYYY-MM-DD" (date only) + /// * ISO 8601 "YYYY-MM-DDTHH:mm:ss(+zz:zz|Z)" (some custom builds) + /// * empty string / null + /// .NET's default Utf8JsonReader.GetDateTimeOffset only accepts strict + /// ISO 8601 with a timezone, so any of the first two forms throws and + /// the whole list response fails to deserialize. This converter parses + /// all four and assumes UTC when no offset is present. + /// + public sealed class FlexibleDateTimeOffsetConverter : JsonConverter + { + private static readonly string[] Formats = new[] + { + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mm:ss.fffK", + "yyyy-MM-dd" + }; + + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.String: + var s = reader.GetString(); + if (string.IsNullOrWhiteSpace(s)) return null; + // Fast path — ISO with offset, what .NET accepts natively. + if (DateTimeOffset.TryParse( + s, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var dto)) + { + return dto; + } + foreach (var fmt in Formats) + { + if (DateTime.TryParseExact( + s, fmt, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var dt)) + { + return new DateTimeOffset(dt, TimeSpan.Zero); + } + } + return null; + case JsonTokenType.Number: + // Some installs emit Unix epoch seconds. + if (reader.TryGetInt64(out var epoch)) + { + return DateTimeOffset.FromUnixTimeSeconds(epoch); + } + return null; + default: + reader.Skip(); + return null; + } + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index 3be29d0..96252ed 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -20,12 +20,23 @@ namespace MarcusLaw.OutlookAddin.Core.Services { private const string ApiPrefix = "api/v1/"; - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + // EspoCRM emits dates as "YYYY-MM-DD HH:mm:ss" (no timezone) which + // .NET's built-in DateTimeOffset reader rejects. Register a global + // tolerant converter so every model that exposes a DateTimeOffset? + // works without per-property attributes. + options.Converters.Add(new MarcusLaw.OutlookAddin.Core.Json.FlexibleDateTimeOffsetConverter()); + return options; + } private readonly HttpClient _httpClient; private readonly ILogger _logger;