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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user