From 121674991084c9a3ecec6f6e41a053c2495391fd Mon Sep 17 00:00:00 2001 From: Oliver Vea Date: Sun, 29 Mar 2026 03:28:46 +0000 Subject: [PATCH] feat: add AOT-compatible JSON serialization and IParsable to Id/Id Closes #63 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Olve.Utilities/Ids/Id.cs | 63 ++++++++++++- src/Olve.Utilities/Ids/IdJsonConverter.cs | 23 +++++ src/Olve.Utilities/Ids/IdOfTJsonConverter.cs | 24 +++++ .../Ids/IdOfTJsonConverterFactory.cs | 29 ++++++ src/Olve.Utilities/Olve.Utilities.csproj | 1 + .../Ids/IdJsonSerializationTests.cs | 88 +++++++++++++++++++ .../Ids/IdParsableTests.cs | 75 ++++++++++++++++ 7 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/Olve.Utilities/Ids/IdJsonConverter.cs create mode 100644 src/Olve.Utilities/Ids/IdOfTJsonConverter.cs create mode 100644 src/Olve.Utilities/Ids/IdOfTJsonConverterFactory.cs create mode 100644 tests/Olve.Utilities.Tests/Ids/IdJsonSerializationTests.cs create mode 100644 tests/Olve.Utilities.Tests/Ids/IdParsableTests.cs diff --git a/src/Olve.Utilities/Ids/Id.cs b/src/Olve.Utilities/Ids/Id.cs index 3a69541..d4cbd8e 100644 --- a/src/Olve.Utilities/Ids/Id.cs +++ b/src/Olve.Utilities/Ids/Id.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; +using System.Text.Json.Serialization; namespace Olve.Utilities.Ids; @@ -8,7 +9,8 @@ namespace Olve.Utilities.Ids; /// Represents an opaque, type-agnostic identifier backed by a . /// [DebuggerDisplay("{ToDisplayString()}")] -public readonly record struct Id(Guid Value) : IComparable +[JsonConverter(typeof(IdJsonConverter))] +public readonly record struct Id(Guid Value) : IComparable, IParsable { /// /// Gets the underlying value for this identifier. @@ -90,6 +92,34 @@ public static bool TryParse(string text, out Id id) return false; } + /// + /// Parses a string into an . + /// + /// The string to parse. + /// An optional format provider (ignored). + /// The parsed . + /// Thrown when is not a valid GUID. + public static Id Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var result)) + return result; + throw new FormatException($"'{s}' is not a valid Id."); + } + + /// + /// Attempts to parse a string into an . + /// + /// The string to parse. + /// An optional format provider (ignored). + /// When the method returns, contains the parsed if parsing succeeded; otherwise the default value. + /// true if was parsed successfully; otherwise false. + public static bool TryParse(string? s, IFormatProvider? provider, out Id result) + { + result = default; + if (s is null) return false; + return TryParse(s, out result); + } + /// /// Returns the canonical string representation of the underlying . /// @@ -173,7 +203,8 @@ private static Guid DeterministicGuidFromName(Guid namespaceGuid, string name) /// /// The logical entity type represented by this identifier (used only for compile-time safety). [DebuggerDisplay("{ToDisplayString()}")] -public readonly record struct Id : IComparable> +[JsonConverter(typeof(IdOfTJsonConverterFactory))] +public readonly record struct Id : IComparable>, IParsable> { /// /// Gets the underlying untyped value held by this typed identifier. @@ -186,6 +217,34 @@ private static Guid DeterministicGuidFromName(Guid namespaceGuid, string name) /// The underlying value. public Id(Id value) => Value = value; + /// + /// Parses a string into an . + /// + /// The string to parse. + /// An optional format provider (ignored). + /// The parsed . + /// Thrown when is not a valid GUID. + public static Id Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var result)) + return result; + throw new FormatException($"'{s}' is not a valid Id<{typeof(T).Name}>."); + } + + /// + /// Attempts to parse a string into an . + /// + /// The string to parse. + /// An optional format provider (ignored). + /// When the method returns, contains the parsed if parsing succeeded; otherwise the default value. + /// true if was parsed successfully; otherwise false. + public static bool TryParse(string? s, IFormatProvider? provider, out Id result) + { + result = default; + if (s is null) return false; + return Id.TryParse(s, out result); + } + /// /// Returns a human-readable representation of this typed identifier, including the logical type name and value. /// diff --git a/src/Olve.Utilities/Ids/IdJsonConverter.cs b/src/Olve.Utilities/Ids/IdJsonConverter.cs new file mode 100644 index 0000000..307c9e3 --- /dev/null +++ b/src/Olve.Utilities/Ids/IdJsonConverter.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Olve.Utilities.Ids; + +/// +/// AOT-safe JSON converter for that serializes as a GUID string. +/// +public sealed class IdJsonConverter : JsonConverter +{ + /// + public override Id Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var guid = reader.GetGuid(); + return new Id(guid); + } + + /// + public override void Write(Utf8JsonWriter writer, Id value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/Olve.Utilities/Ids/IdOfTJsonConverter.cs b/src/Olve.Utilities/Ids/IdOfTJsonConverter.cs new file mode 100644 index 0000000..830eaea --- /dev/null +++ b/src/Olve.Utilities/Ids/IdOfTJsonConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Olve.Utilities.Ids; + +/// +/// AOT-safe JSON converter for that serializes as a GUID string. +/// +/// The logical entity type. +public sealed class IdOfTJsonConverter : JsonConverter> +{ + /// + public override Id Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var guid = reader.GetGuid(); + return new Id(new Id(guid)); + } + + /// + public override void Write(Utf8JsonWriter writer, Id value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value.Value); + } +} diff --git a/src/Olve.Utilities/Ids/IdOfTJsonConverterFactory.cs b/src/Olve.Utilities/Ids/IdOfTJsonConverterFactory.cs new file mode 100644 index 0000000..1a668d5 --- /dev/null +++ b/src/Olve.Utilities/Ids/IdOfTJsonConverterFactory.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Olve.Utilities.Ids; + +/// +/// Factory that creates instances for any . +/// In AOT contexts, the source generator bypasses this factory entirely when consumers +/// declare [JsonSerializable(typeof(Id<MyEntity>))] on their . +/// +public sealed class IdOfTJsonConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsGenericType + && typeToConvert.GetGenericTypeDefinition() == typeof(Id<>); + + /// + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "In AOT contexts, the source generator bypasses this factory. " + + "MakeGenericType is only called in JIT scenarios.")] + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var typeArg = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(IdOfTJsonConverter<>).MakeGenericType(typeArg); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } +} diff --git a/src/Olve.Utilities/Olve.Utilities.csproj b/src/Olve.Utilities/Olve.Utilities.csproj index a367f2c..2c2ad74 100644 --- a/src/Olve.Utilities/Olve.Utilities.csproj +++ b/src/Olve.Utilities/Olve.Utilities.csproj @@ -12,6 +12,7 @@ true README.md true + true true true diff --git a/tests/Olve.Utilities.Tests/Ids/IdJsonSerializationTests.cs b/tests/Olve.Utilities.Tests/Ids/IdJsonSerializationTests.cs new file mode 100644 index 0000000..1b9c181 --- /dev/null +++ b/tests/Olve.Utilities.Tests/Ids/IdJsonSerializationTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Olve.Utilities.Ids; +using Assert = TUnit.Assertions.Assert; + +namespace Olve.Utilities.Tests.Ids; + +public partial class IdJsonSerializationTests +{ + private readonly record struct Pipeline; + + [JsonSerializable(typeof(Id))] + [JsonSerializable(typeof(Id))] + private partial class TestJsonContext : JsonSerializerContext; + + [Test] + public async Task Id_RoundTrips_ThroughJson() + { + var original = Id.New(); + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize(json); + + await Assert.That(deserialized).IsEqualTo(original); + } + + [Test] + public async Task IdOfT_RoundTrips_ThroughJson() + { + var original = Id.New(); + var json = JsonSerializer.Serialize(original); + var deserialized = JsonSerializer.Deserialize>(json); + + await Assert.That(deserialized).IsEqualTo(original); + } + + [Test] + public async Task Id_SerializesAsBareGuidString() + { + var id = Id.New(); + var json = JsonSerializer.Serialize(id); + + await Assert.That(json).IsEqualTo($"\"{id.Value}\""); + } + + [Test] + public async Task IdOfT_SerializesAsBareGuidString() + { + var id = Id.New(); + var json = JsonSerializer.Serialize(id); + + await Assert.That(json).IsEqualTo($"\"{id.Value.Value}\""); + } + + [Test] + public async Task Id_RoundTrips_ThroughAotContext() + { + var original = Id.New(); + var json = JsonSerializer.Serialize(original, TestJsonContext.Default.Id); + var deserialized = JsonSerializer.Deserialize(json, TestJsonContext.Default.Id); + + await Assert.That(deserialized).IsEqualTo(original); + } + + [Test] + public async Task IdOfT_RoundTrips_ThroughAotContext() + { + var original = Id.New(); + var json = JsonSerializer.Serialize(original, TestJsonContext.Default.IdPipeline); + var deserialized = JsonSerializer.Deserialize(json, TestJsonContext.Default.IdPipeline); + + await Assert.That(deserialized).IsEqualTo(original); + } + + [Test] + public void Id_DeserializeInvalidString_ThrowsJsonException() + { + var json = "\"not-a-guid\""; + + try + { + JsonSerializer.Deserialize(json); + Assert.Fail("Should have thrown"); + } + catch (JsonException) + { + } + } +} diff --git a/tests/Olve.Utilities.Tests/Ids/IdParsableTests.cs b/tests/Olve.Utilities.Tests/Ids/IdParsableTests.cs new file mode 100644 index 0000000..052383c --- /dev/null +++ b/tests/Olve.Utilities.Tests/Ids/IdParsableTests.cs @@ -0,0 +1,75 @@ +using Olve.Utilities.Ids; +using Assert = TUnit.Assertions.Assert; + +namespace Olve.Utilities.Tests.Ids; + +public class IdParsableTests +{ + private readonly record struct Pipeline; + + [Test] + public async Task Id_Parse_ValidString_ReturnsParsedId() + { + var original = Id.New(); + var text = original.ToString(); + + var parsed = Id.Parse(text, null); + + await Assert.That(parsed).IsEqualTo(original); + } + + [Test] + public void Id_Parse_InvalidString_ThrowsFormatException() + { + try + { + Id.Parse("bad", null); + Assert.Fail("Should have thrown"); + } + catch (FormatException) + { + } + } + + [Test] + public async Task Id_TryParse_NullString_ReturnsFalse() + { + var result = Id.TryParse(null, null, out var id); + + await Assert.That(result).IsFalse(); + await Assert.That(id).IsEqualTo(default(Id)); + } + + [Test] + public async Task IdOfT_Parse_ValidString_ReturnsParsedId() + { + var original = Id.New(); + var text = original.ToString(); + + var parsed = Id.Parse(text, null); + + await Assert.That(parsed).IsEqualTo(original); + } + + [Test] + public void IdOfT_Parse_InvalidString_ThrowsFormatException() + { + try + { + Id.Parse("bad", null); + Assert.Fail("Should have thrown"); + } + catch (FormatException) + { + } + } + + [Test] + public async Task IdOfT_TryParse_NullString_ReturnsFalse() + { + var result = Id.TryParse(null, null, out var id); + + await Assert.That(result).IsFalse(); + await Assert.That(id).IsEqualTo(default(Id)); + } +}