fix(json): tolerate EspoCRM's naive datetime format (YYYY-MM-DD HH:mm:ss)

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) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-11 19:05:08 +03:00
parent 55d3e6d0f3
commit a1e25af86b
2 changed files with 94 additions and 5 deletions
@@ -0,0 +1,78 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Json
{
/// <summary>
/// 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.
/// </summary>
public sealed class FlexibleDateTimeOffsetConverter : JsonConverter<DateTimeOffset?>
{
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));
}
}
}
@@ -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;