diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs new file mode 100644 index 0000000..2eebfa9 --- /dev/null +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Text.Json; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class PropertyPathTests +{ + private static readonly JsonSerializerOptions CamelCaseJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task I_serialize_property_paths_using_the_json_naming_policy() + { + var problem = new Problem + { + PropertyPath = "Address.Street", + Message = "Street is required.", + }; + + var json = JsonSerializer.Serialize(problem, CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"address.street","severity":0,"message":"Street is required.","code":null,"attemptedValue":null}"""); + } + + [Test] + public async Task I_serialize_indexed_property_paths_using_the_json_naming_policy() + { + var problem = new Problem + { + PropertyPath = "Members[1].Name", + Message = "Member name is required.", + }; + + var json = JsonSerializer.Serialize(problem, CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"members[1].name","severity":0,"message":"Member name is required.","code":null,"attemptedValue":null}"""); + } + + [Test] + public async Task I_can_combine_property_paths_with_the_plus_operator() + { + var propertyPath = "Address" + (PropertyPath)"Street"; + + await Assert.That(propertyPath).IsEqualTo("Address.Street"); + } + + [Test] + public async Task I_can_combine_property_paths_with_the_plus_operator_when_one_side_is_empty() + { + var leftEmpty = "" + (PropertyPath)"Street"; + var rightEmpty = (PropertyPath)"Address" + ""; + + await Assert.That(leftEmpty).IsEqualTo("Street"); + await Assert.That(rightEmpty).IsEqualTo("Address"); + } + + [Test] + public async Task I_can_append_a_collection_index_with_the_plus_operator() + { + var propertyPath = (PropertyPath)"Members" + 1; + + await Assert.That(propertyPath).IsEqualTo("Members[1]"); + } + + [Test] + public async Task I_can_append_a_collection_index_with_the_plus_operator_when_the_property_name_is_empty() + { + var propertyPath = (PropertyPath)"" + 1; + + await Assert.That(propertyPath).IsEqualTo("[1]"); + } +} \ No newline at end of file diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs index 476f5c5..8c4a34a 100644 --- a/src/request.validation.tests/RuleBuilderExtensionsTests.cs +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -311,7 +311,7 @@ internal sealed class RuleBuilderExtensionsTests .Throws(); } - private static async Task AssertSingleProblem(Validation validation, string propertyName, string message) + private static async Task AssertSingleProblem(Validation validation, string propertyPath, string message) { await Assert.That(validation.Problems).Count().IsEqualTo(1); @@ -319,7 +319,7 @@ internal sealed class RuleBuilderExtensionsTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyName).IsEqualTo(propertyName); + await Assert.That(problem.PropertyPath).IsEqualTo(propertyPath); await Assert.That(problem.Message).IsEqualTo(message); } } diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs index 09fe3c1..52cbe5c 100644 --- a/src/request.validation.tests/ValidatorTests.cs +++ b/src/request.validation.tests/ValidatorTests.cs @@ -21,7 +21,7 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyName).IsEqualTo(nameof(Person.Name)); + await Assert.That(problem.PropertyPath).IsEqualTo(nameof(Person.Name)); await Assert.That(problem.Message).IsEqualTo("Name is required."); await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED"); await Assert.That(problem.Severity).IsEqualTo(Severity.Warning); @@ -58,7 +58,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Address.Street"); + await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street"); } [Test] @@ -87,7 +87,7 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Members[1].Name"); + await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Members[1].Name"); } [Test] @@ -105,7 +105,7 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyName).IsEqualTo("Address.Street"); + await Assert.That(problem.PropertyPath).IsEqualTo("Address.Street"); await Assert.That(problem.Message).IsEqualTo("Value is required."); } } @@ -124,7 +124,7 @@ internal sealed class ValidatorTests using (Assert.Multiple()) { - await Assert.That(problem.PropertyName).IsEqualTo(nameof(Person.Name)); + await Assert.That(problem.PropertyPath).IsEqualTo(nameof(Person.Name)); await Assert.That(problem.Message).IsEqualTo("Value is required."); } } @@ -208,8 +208,8 @@ internal sealed class ValidatorTests }); await Assert.That(result.Problems).Count().IsEqualTo(2); - await Assert.That(result.Problems[0].PropertyName).IsEqualTo("Members[0].Name"); - await Assert.That(result.Problems[1].PropertyName).IsEqualTo("Members[2].Name"); + await Assert.That(result.Problems[0].PropertyPath).IsEqualTo("Members[0].Name"); + await Assert.That(result.Problems[1].PropertyPath).IsEqualTo("Members[2].Name"); } [Test] @@ -230,7 +230,7 @@ internal sealed class ValidatorTests var result = validator.Validate(context); await Assert.That(result.Problems).Count().IsEqualTo(1); - await Assert.That(result.Problems.Single().PropertyName).IsEqualTo("Address.Street"); + await Assert.That(result.Problems.Single().PropertyPath).IsEqualTo("Address.Street"); } [Test] diff --git a/src/request.validation/Problem.cs b/src/request.validation/Problem.cs index 6624462..0a2e781 100644 --- a/src/request.validation/Problem.cs +++ b/src/request.validation/Problem.cs @@ -11,7 +11,7 @@ public record Problem /// /// The name of the property. /// - public required string PropertyName { get; init; } + public required PropertyPath PropertyPath { get; init; } /// /// Custom severity level associated with the failure. diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs new file mode 100644 index 0000000..b8af046 --- /dev/null +++ b/src/request.validation/PropertyPath.cs @@ -0,0 +1,169 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Geekeey.Request.Validation; + +/// +/// Represents a property path used by validation problems. +/// +[JsonConverter(typeof(PropertyPathJsonConverter))] +public readonly struct PropertyPath : IEquatable +{ + /// + /// Creates a new property path. + /// + public PropertyPath(string value) + { + ArgumentNullException.ThrowIfNull(value); + Value = value; + } + + /// + /// Gets the raw property path. + /// + public string Value { get; } = string.Empty; + + /// + public override string ToString() + { + return Value; + } + + /// + /// Converts a string into a property path. + /// + public static implicit operator PropertyPath(string value) + { + return new PropertyPath(value); + } + + /// + /// Converts a property path into its string representation. + /// + public static implicit operator string(PropertyPath propertyPath) + { + return propertyPath.Value; + } + + /// + public bool Equals(PropertyPath other) + { + return string.Equals(Value, other.Value, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) + { + return obj is PropertyPath other && Equals(other); + } + + /// + public override int GetHashCode() + { + return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + } + + /// + /// Compares two property paths for ordinal equality. + /// + public static bool operator ==(PropertyPath left, PropertyPath right) + { + return left.Equals(right); + } + + /// + /// Compares two property paths for ordinal inequality. + /// + public static bool operator !=(PropertyPath left, PropertyPath right) + { + return !left.Equals(right); + } + + /// + /// Combines two property paths using the same rules as nested validation paths. + /// + public static PropertyPath operator +(PropertyPath prefix, PropertyPath suffix) + { + if (prefix.Value is not { Length: > 0 }) + { + return suffix; + } + + if (suffix.Value is not { Length: > 0 }) + { + return prefix; + } + + return $"{prefix}.{suffix}"; + } + + /// + /// Appends a collection index to a property path. + /// + public static PropertyPath operator +(PropertyPath propertyPath, int index) + { + return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]"; + } + + internal string ToJsonName(JsonNamingPolicy? namingPolicy) + { + var value = Value ?? string.Empty; + if (namingPolicy is null || value.Length is 0) + { + return value; + } + + var builder = new StringBuilder(value.Length); + var first = true; + + foreach (var range in value.AsSpan().Split('.')) + { + if (!first) + { + builder.Append('.'); + } + + var segment = value.AsSpan()[range]; + if (segment.Length is not 0) + { + if (segment.IndexOf('[') is >= 0 and var x) + { + builder.Append(namingPolicy.ConvertName(segment[..x].ToString())); + builder.Append(segment[x..]); + } + else + { + builder.Append(namingPolicy.ConvertName(segment.ToString())); + } + } + + first = false; + } + + return builder.ToString(); + } +} + +internal sealed class PropertyPathJsonConverter : JsonConverter +{ + /// + public override PropertyPath Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String) + { + return new PropertyPath(reader.GetString()!); + } + + throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); + } + + /// + public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); + } +} \ No newline at end of file diff --git a/src/request.validation/Rule.cs b/src/request.validation/Rule.cs index 459ea57..e5ecebf 100644 --- a/src/request.validation/Rule.cs +++ b/src/request.validation/Rule.cs @@ -18,14 +18,14 @@ internal abstract record Rule : Rule { protected Rule(Expression expression) { - PropertyName = GetPropertyPath(expression); + PropertyPath = GetPropertyPath(expression); } - public string PropertyName { get; } + public PropertyPath PropertyPath { get; } public IReadOnlyList> Steps { get; init; } = []; - private static string GetPropertyPath(Expression expression) + private static PropertyPath GetPropertyPath(Expression expression) { var members = new Stack(); @@ -34,18 +34,15 @@ internal abstract record Rule : Rule { switch (current) { - case LambdaExpression lambdaExpression: - current = lambdaExpression.Body; + case LambdaExpression expr: + current = expr.Body; break; - case MemberExpression memberExpression: - members.Push(memberExpression.Member.Name); - current = memberExpression.Expression; + case MemberExpression expr: + members.Push(expr.Member.Name); + current = expr.Expression; break; - case UnaryExpression - { - NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked - } unaryExpression: - current = unaryExpression.Operand; + case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } expr: + current = expr.Operand; break; case ParameterExpression: current = null; @@ -55,7 +52,7 @@ internal abstract record Rule : Rule } } - return string.Join(".", members); + return string.Join('.', members); } public override IEnumerable Validate(ValidationContext context) @@ -95,7 +92,7 @@ internal sealed record PropertyRule : Rule } var value = _accessor(instance); - return Steps.SelectMany(step => step.Validate(value, context, PropertyName, Code, Severity)); + return Steps.SelectMany(step => step.Validate(value, context, PropertyPath, Code, Severity)); } } @@ -123,11 +120,11 @@ internal sealed record CollectionRule : Rule var index = 0; foreach (var element in collection) { - var propertyName = string.IsNullOrEmpty(PropertyName) ? $"[{index}]" : $"{PropertyName}[{index}]"; + var propertyPath = PropertyPath + index; foreach (var step in Steps) { - foreach (var problem in step.Validate(element, context, propertyName, Code, Severity)) + foreach (var problem in step.Validate(element, context, propertyPath, Code, Severity)) { yield return problem; } diff --git a/src/request.validation/RuleStep.cs b/src/request.validation/RuleStep.cs index 5259e1a..7fd1e5b 100644 --- a/src/request.validation/RuleStep.cs +++ b/src/request.validation/RuleStep.cs @@ -5,7 +5,7 @@ namespace Geekeey.Request.Validation; internal interface IRuleStep { - IEnumerable Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity); + IEnumerable Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity); } internal sealed record PredicateRuleStep : IRuleStep @@ -19,7 +19,7 @@ internal sealed record PredicateRuleStep : IRuleStep _message = message; } - public IEnumerable Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity) + public IEnumerable Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity) { if (_predicate(value)) { @@ -28,7 +28,7 @@ internal sealed record PredicateRuleStep : IRuleStep yield return new Problem { - PropertyName = propertyPath, + PropertyPath = propertyPath, Message = _message, Code = code, Severity = severity, @@ -46,7 +46,7 @@ internal sealed record ValidatorRuleStep : IRuleStep _resolver = resolver; } - public IEnumerable Validate(TValue value, ValidationContext context, string propertyPath, string? code, Severity severity) + public IEnumerable Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity) { if (value is null) { @@ -58,22 +58,7 @@ internal sealed record ValidatorRuleStep : IRuleStep foreach (var problem in validation.Problems) { - yield return problem with { PropertyName = AppendPropertyPath(propertyPath, problem.PropertyName) }; + yield return problem with { PropertyPath = propertyPath + problem.PropertyPath }; } } - - private static string AppendPropertyPath(string prefix, string suffix) - { - if (string.IsNullOrEmpty(prefix)) - { - return suffix; - } - - if (string.IsNullOrEmpty(suffix)) - { - return prefix; - } - - return $"{prefix}.{suffix}"; - } } \ No newline at end of file diff --git a/src/request.validation/package-readme.md b/src/request.validation/package-readme.md index 2645a05..212a162 100644 --- a/src/request.validation/package-readme.md +++ b/src/request.validation/package-readme.md @@ -77,7 +77,7 @@ var validation = validator.Validate(new CreateUserRequest( foreach (var problem in validation.Problems) { - Console.WriteLine($"{problem.PropertyName}: {problem.Message}"); + Console.WriteLine($"{problem.PropertyPath}: {problem.Message}"); } ```