Skip to content

Commit 2b1839b

Browse files
dmealingclaude
andcommitted
feat(csharp): ship the ISO 8601 timestamp converter adopters need
docs/features/api-contract.md spells field.timestamp as ISO 8601 with the UTC designator. System.Text.Json's default for DateTimeOffset renders UTC as a numeric zero offset instead. Both are valid ISO 8601, which is why this went unnoticed — the permissive "ISO 8601 with timezone" assertions accept either. It matters because the cross-port gate is a BYTE comparison of the same timestamptz row read by five ports: two spellings are two answers. A near-copy of this converter already existed — hand-written inside the integration-test project, where an adopter never sees it. That copy did not produce the documented spelling either: DateTimeOffset.ToString("o") keeps the numeric offset even at UTC, so reaching Z needs the UtcDateTime. The test harness had the workaround and the product had neither. Writes UTC with Z, and emits fractional seconds only when the instant has them, so a whole-second value round-trips to the string it arrived as rather than gaining a `.000` nothing sent. Reads any ISO 8601 spelling, and treats an offset-less value as UTC rather than as server local time — a naive string has to mean the same instant whatever machine deserializes it. Register it on ConfigureHttpJsonOptions beside JsonStringEnumConverter; the generated handlers deserialize with the app's CONFIGURED SerializerOptions precisely so a host registration reaches them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NrWUxA6euwvnFcwKxcK6A
1 parent 35dcf6e commit 2b1839b

3 files changed

Lines changed: 182 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
192192
second C#-only key would be an addition to a cross-port wire contract. Non-TPH entities are
193193
byte-identical.
194194

195+
- **C#: `field.timestamp` crossed the wire as `+00:00` rather than the documented `Z`.**
196+
`docs/features/api-contract.md` spells a timestamp ISO 8601 with the UTC designator;
197+
System.Text.Json's default for `DateTimeOffset` renders UTC as a numeric zero offset. Both
198+
are valid ISO 8601, which is why it went unnoticed — but the cross-port gate is a BYTE
199+
comparison of the same `timestamptz` row read by five ports, and two spellings are two
200+
answers. `MetaObjects.Codegen.Runtime.Iso8601TimestampConverter` writes UTC with `Z`
201+
(fractional seconds only when the instant has them, so a whole-second value round-trips to
202+
the string it arrived as) and reads any ISO 8601 spelling, treating an offset-less value as
203+
UTC rather than as server local time. Register it on `ConfigureHttpJsonOptions` beside
204+
`JsonStringEnumConverter`; the generated handlers deserialize with the app's CONFIGURED
205+
options precisely so a host registration reaches them. A near-copy previously existed only
206+
inside the integration-test project, where an adopter never saw it — and that copy did not
207+
produce the documented spelling either (`DateTimeOffset.ToString("o")` keeps the numeric
208+
offset at UTC; reaching `Z` needs the `UtcDateTime`).
209+
195210
- **C#: a constraint violation answered 500 instead of 409.** A generated CRUD route had no
196211
try/catch around `SaveChangesAsync`, so a client-supplied foreign key naming no row, or a
197212
value duplicating a unique one, reached ASP.NET's default handler and came back as a bare
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Iso8601TimestampConverter — the field.timestamp wire spelling.
2+
//
3+
// docs/features/api-contract.md pins ISO 8601 with a timezone, spelled with the UTC
4+
// designator. System.Text.Json's default renders UTC as `+00:00` instead, and both forms
5+
// are valid ISO 8601 — which is why this survived: nothing errors, and the permissive
6+
// "ISO 8601 with timezone" assertions accept either. The cross-port gate is a BYTE
7+
// comparison of the same timestamptz row read by five ports, so two spellings are two
8+
// different answers.
9+
10+
using System.Text.Json;
11+
using MetaObjects.Codegen.Runtime;
12+
using Xunit;
13+
14+
namespace MetaObjects.Codegen.Tests;
15+
16+
public class Iso8601TimestampConverterTests
17+
{
18+
private static readonly JsonSerializerOptions Options = Build();
19+
20+
private static JsonSerializerOptions Build()
21+
{
22+
var o = new JsonSerializerOptions();
23+
o.Converters.Add(new Iso8601TimestampConverter());
24+
return o;
25+
}
26+
27+
private static string Write(DateTimeOffset value) => JsonSerializer.Serialize(value, Options);
28+
private static DateTimeOffset Read(string json) => JsonSerializer.Deserialize<DateTimeOffset>(json, Options);
29+
30+
[Fact]
31+
public void A_utc_instant_writes_the_Z_designator_not_a_zero_offset()
32+
{
33+
// The whole point. DateTimeOffset.ToString("o") — including the copy that lived in
34+
// the integration-test host — writes +00:00 here, because a DateTimeOffset formats
35+
// its own numeric offset even when that offset is zero.
36+
Assert.Equal("\"2026-09-20T12:10:00Z\"", Write(new DateTimeOffset(2026, 9, 20, 12, 10, 0, TimeSpan.Zero)));
37+
}
38+
39+
[Fact]
40+
public void A_non_utc_instant_is_normalized_to_utc()
41+
{
42+
// Same instant, written the one way: 14:10+02:00 is 12:10Z.
43+
Assert.Equal(
44+
"\"2026-09-20T12:10:00Z\"",
45+
Write(new DateTimeOffset(2026, 9, 20, 14, 10, 0, TimeSpan.FromHours(2))));
46+
}
47+
48+
[Fact]
49+
public void Sub_second_precision_is_kept_at_millisecond_resolution()
50+
{
51+
Assert.Equal(
52+
"\"2026-09-20T12:10:00.123Z\"",
53+
Write(new DateTimeOffset(2026, 9, 20, 12, 10, 0, 123, TimeSpan.Zero)));
54+
}
55+
56+
[Fact]
57+
public void A_whole_second_round_trips_to_the_string_it_came_from()
58+
{
59+
// Stability is the reason `.000` is not padded on: a client sends a whole-second
60+
// instant and reads back the same bytes, so a cross-port byte comparison is not
61+
// deciding between two spellings of one value.
62+
const string wire = "\"2026-09-20T12:10:00Z\"";
63+
Assert.Equal(wire, Write(Read(wire)));
64+
}
65+
66+
[Theory]
67+
[InlineData("\"2026-09-20T12:10:00Z\"")]
68+
[InlineData("\"2026-09-20T12:10:00+00:00\"")]
69+
[InlineData("\"2026-09-20T14:10:00+02:00\"")]
70+
public void Every_iso_8601_spelling_of_one_instant_reads_as_that_instant(string wire)
71+
{
72+
Assert.Equal(new DateTimeOffset(2026, 9, 20, 12, 10, 0, TimeSpan.Zero), Read(wire));
73+
}
74+
75+
[Fact]
76+
public void A_value_with_no_offset_is_read_as_utc_not_as_server_local_time()
77+
{
78+
// A naive string must mean the same instant whatever machine deserializes it —
79+
// the only reading that survives five ports on five hosts. Without
80+
// AssumeUniversal this is the local time of whoever happened to parse it.
81+
Assert.Equal(
82+
new DateTimeOffset(2026, 9, 20, 12, 10, 0, TimeSpan.Zero),
83+
Read("\"2026-09-20T12:10:00\""));
84+
}
85+
86+
[Fact]
87+
public void A_null_is_a_json_error_not_a_default_instant()
88+
{
89+
Assert.Throws<JsonException>(() => Read("null"));
90+
}
91+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Iso8601TimestampConverter — the wire spelling of a field.timestamp.
2+
//
3+
// `docs/features/api-contract.md` states that a field.timestamp crosses the wire as ISO
4+
// 8601 WITH A TIMEZONE, spelled with the UTC designator. System.Text.Json's default for
5+
// DateTimeOffset is the round-trip form, which renders UTC as `+00:00`:
6+
//
7+
// default 2026-09-20T12:10:00+00:00
8+
// contract 2026-09-20T12:10:00Z
9+
//
10+
// Both are valid ISO 8601, which is why this went unnoticed: nothing errored, and the
11+
// permissive "ISO 8601 with timezone" assertions accept either. It matters because the
12+
// SAME timestamptz row is read by five ports and the cross-port gate is a BYTE
13+
// comparison — two renderings of one instant are two different answers.
14+
//
15+
// The generated CRUD handlers deserialize with the app's CONFIGURED SerializerOptions
16+
// precisely so a host registration like this reaches them:
17+
//
18+
// builder.Services.ConfigureHttpJsonOptions(o =>
19+
// o.SerializerOptions.Converters.Add(new Iso8601TimestampConverter()));
20+
//
21+
// It was previously hand-written per test host inside the integration-test project —
22+
// where an adopter never sees it, and where the copy did not in fact produce the
23+
// documented spelling (`DateTimeOffset.ToString("o")` keeps the numeric offset even at
24+
// UTC; reaching `Z` needs the UtcDateTime).
25+
26+
using System.Globalization;
27+
using System.Text.Json;
28+
using System.Text.Json.Serialization;
29+
30+
namespace MetaObjects.Codegen.Runtime;
31+
32+
/// <summary>
33+
/// Reads and writes <see cref="DateTimeOffset"/> in the api contract's wire spelling:
34+
/// ISO 8601, normalized to UTC, with the <c>Z</c> designator.
35+
/// </summary>
36+
public sealed class Iso8601TimestampConverter : JsonConverter<DateTimeOffset>
37+
{
38+
/// <summary>
39+
/// Parse any ISO 8601 instant and normalize it to UTC. A value with NO offset is read
40+
/// as already-UTC rather than as server local time — a naive string means the same
41+
/// instant whatever machine happens to deserialize it, which is the only reading that
42+
/// survives five ports on five hosts.
43+
/// </summary>
44+
public override DateTimeOffset Read(
45+
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
46+
{
47+
var raw = reader.GetString();
48+
if (raw is null) throw new JsonException("expected an ISO 8601 timestamp string");
49+
return DateTimeOffset.Parse(
50+
raw, CultureInfo.InvariantCulture,
51+
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
52+
}
53+
54+
/// <summary>
55+
/// Write as UTC with the <c>Z</c> designator, and with fractional seconds only when
56+
/// the instant HAS them.
57+
/// <para>
58+
/// Sub-second precision is emitted at millisecond resolution when non-zero and omitted
59+
/// entirely when zero, rather than always padding `.000`: a whole-second instant reads
60+
/// back as the string the client sent it as, so a round-trip is stable and a byte
61+
/// comparison across ports is not deciding between two spellings of the same value.
62+
/// </para>
63+
/// </summary>
64+
public override void Write(
65+
Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
66+
{
67+
// .UtcDateTime, not .ToUniversalTime(): a DateTimeOffset formats its own numeric
68+
// offset even when that offset is zero, so "o" on a UTC DateTimeOffset still spells
69+
// +00:00. Only a DateTime with Kind=Utc renders the Z.
70+
var utc = value.UtcDateTime;
71+
var format = utc.Millisecond == 0 && utc.Ticks % TimeSpan.TicksPerSecond == 0
72+
? "yyyy-MM-dd'T'HH:mm:ss'Z'"
73+
: "yyyy-MM-dd'T'HH:mm:ss.fff'Z'";
74+
writer.WriteStringValue(utc.ToString(format, CultureInfo.InvariantCulture));
75+
}
76+
}

0 commit comments

Comments
 (0)