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));
+ }
+}