From eff887b49f4c9458fa4926e97e16cdee46ae3c71 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 21:17:14 +0200 Subject: [PATCH] feat: add json property path supports for object keys --- CHANGELOG.md | 2 + .../PropertyPathTests.cs | 39 +++++++++++++++++++ src/request.validation/PropertyPath.cs | 17 ++++++++ 3 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1288b..240c251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ To have a consistent experience across all packages, some public interfaces have ### Added +- **request.validation:** Support `PropertyPath` JSON converter for string values and dictionary property names + ### Changed ### Removed diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs index bce4614..2551450 100644 --- a/src/request.validation.tests/PropertyPathTests.cs +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -12,6 +12,19 @@ internal sealed class PropertyPathTests PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private static readonly JsonSerializerOptions CamelCaseDictionaryKeyJsonOptions = new() + { + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task I_can_serialize_property_paths_as_json_strings_using_the_json_naming_policy() + { + var json = JsonSerializer.Serialize((PropertyPath)"Address.Street", CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ "\"address.street\""); + } + [Test] public async Task I_can_serialize_property_paths_using_the_json_naming_policy() { @@ -54,6 +67,32 @@ internal sealed class PropertyPathTests await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"matrix[1][2].value","severity":0,"message":"Value is required.","code":null,"attemptedValue":null}"""); } + [Test] + public async Task I_can_serialize_property_paths_as_dictionary_keys_using_the_dictionary_key_policy() + { + Dictionary errors = new() + { + ["Address.Street"] = "Street is required.", + }; + + var json = JsonSerializer.Serialize(errors, CamelCaseDictionaryKeyJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"address.street":"Street is required."}"""); + } + + [Test] + public async Task I_can_deserialize_property_paths_from_dictionary_keys() + { + var json = /*lang=json,strict*/ """{"Address.Street":"Street is required."}"""; + + var errors = JsonSerializer.Deserialize>(json); + + await Assert.That(errors).IsNotNull(); + await Assert.That(errors!.Count).IsEqualTo(1); + await Assert.That(errors.ContainsKey("Address.Street")).IsTrue(); + await Assert.That(errors["Address.Street"]).IsEqualTo("Street is required."); + } + [Test] public async Task I_can_iterate_and_index_segments() { diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs index 4bf0e62..4b9bd50 100644 --- a/src/request.validation/PropertyPath.cs +++ b/src/request.validation/PropertyPath.cs @@ -399,9 +399,26 @@ internal sealed class PropertyPathJsonConverter : JsonConverter throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); } + /// + public override PropertyPath ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.PropertyName) + { + return new PropertyPath(reader.GetString()!); + } + + throw new JsonException($"Expected {nameof(JsonTokenType.PropertyName)} but got {reader.TokenType}."); + } + /// public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); } + + /// + public override void WriteAsPropertyName(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) + { + writer.WritePropertyName(value.ToJsonName(options.DictionaryKeyPolicy)); + } }