feat: create request projects for basic CQRS
This commit is contained in:
commit
d614788e06
190 changed files with 12236 additions and 0 deletions
31
src/request.validation/Geekeey.Request.Validation.csproj
Normal file
31
src/request.validation/Geekeey.Request.Validation.csproj
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<InternalsVisibleTo Include="Geekeey.Request.Validation.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
|
||||
<PackageDescription>Lightweight validation library for C# with composable validators, fluent rules, and structured validation results.</PackageDescription>
|
||||
<PackageIcon>package-icon.png</PackageIcon>
|
||||
<PackageProjectUrl>https://code.geekeey.de/geekeey/request/src/branch/main/src/request.validation</PackageProjectUrl>
|
||||
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
|
||||
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
|
||||
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
55
src/request.validation/IPropertyRuleBuilder.cs
Normal file
55
src/request.validation/IPropertyRuleBuilder.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a builder for defining a validation rule for a specific property of a type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object to be validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property to validate.</typeparam>
|
||||
public interface IPropertyRuleBuilder<T, out TProperty>
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a validation rule that must be met for the property to be considered valid.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The predicate function that determines if the property value is valid.</param>
|
||||
/// <param name="message">The error message to be returned if the validation fails.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> Must(Func<TProperty, bool> predicate, string message);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the validator to be used for validating the property value.
|
||||
/// </summary>
|
||||
/// <param name="validator">The validator instance to use for validation.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> SetValidator(IValidator validator);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the validator to be used for validating the property value.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValidator">The type of the validator to use for validation.</typeparam>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> SetValidator<TValidator>() where TValidator : IValidator;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error code for the validation rule.
|
||||
/// </summary>
|
||||
/// <param name="code">The error code to be associated with the validation rule.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> WithCode(string code);
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the property path reported by the validation rule.
|
||||
/// </summary>
|
||||
/// <param name="transform">The function used to transform the rule property path.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> WithPropertyPath(Func<PropertyPath, PropertyPath> transform);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the severity of the validation rule.
|
||||
/// </summary>
|
||||
/// <param name="severity">The severity level of the validation rule.</param>
|
||||
/// <returns>The current rule builder instance for method chaining.</returns>
|
||||
IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity);
|
||||
}
|
||||
31
src/request.validation/IValidator.cs
Normal file
31
src/request.validation/IValidator.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validator for a particular type.
|
||||
/// </summary>
|
||||
public interface IValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs validation on the provided <see cref="ValidationContext"/> and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="context">The validation context containing the instance to validate, service provider, and additional items.</param>
|
||||
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
|
||||
Validation Validate(ValidationContext context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validator for a particular type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the instance to validate.</typeparam>
|
||||
public interface IValidator<in T> : IValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the validation logic for a specified instance of type <typeparamref name="T"/> and returns the validation result.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance of type <typeparamref name="T"/> to validate.</param>
|
||||
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
|
||||
Validation Validate(T instance);
|
||||
}
|
||||
45
src/request.validation/Problem.cs
Normal file
45
src/request.validation/Problem.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a validation problem identified during a request validation process.
|
||||
/// </summary>
|
||||
public record Problem
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the property.
|
||||
/// </summary>
|
||||
public required PropertyPath PropertyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom severity level associated with the failure.
|
||||
/// </summary>
|
||||
public Severity Severity { get; init; } = Severity.Error;
|
||||
|
||||
/// <summary>
|
||||
/// The error message
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error code.
|
||||
/// </summary>
|
||||
public string? Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The property value that caused the failure.
|
||||
/// </summary>
|
||||
public object? AttemptedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts building a new problem with the specified message.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>A builder for the problem.</returns>
|
||||
public static ProblemBuilder Create(string message)
|
||||
{
|
||||
return new ProblemBuilder(message);
|
||||
}
|
||||
}
|
||||
94
src/request.validation/ProblemBuilder.cs
Normal file
94
src/request.validation/ProblemBuilder.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// A builder for creating <see cref="Problem"/> instances.
|
||||
/// </summary>
|
||||
public sealed class ProblemBuilder
|
||||
{
|
||||
private string? _code;
|
||||
private Severity _severity = Severity.Error;
|
||||
private PropertyPath _propertyPath;
|
||||
private object? _attemptedValue;
|
||||
private readonly string _message;
|
||||
|
||||
/// <summary>
|
||||
/// A builder for creating <see cref="Problem"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public ProblemBuilder(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error code.
|
||||
/// </summary>
|
||||
/// <param name="code">The error code.</param>
|
||||
/// <returns>The builder instance.</returns>
|
||||
public ProblemBuilder WithCode(string code)
|
||||
{
|
||||
_code = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the severity.
|
||||
/// </summary>
|
||||
/// <param name="severity">The severity.</param>
|
||||
/// <returns>The builder instance.</returns>
|
||||
public ProblemBuilder WithSeverity(Severity severity)
|
||||
{
|
||||
_severity = severity;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the property path.
|
||||
/// </summary>
|
||||
/// <param name="propertyPath">The property path.</param>
|
||||
/// <returns>The builder instance.</returns>
|
||||
public ProblemBuilder WithPropertyPath(PropertyPath propertyPath)
|
||||
{
|
||||
_propertyPath = propertyPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the attempted value.
|
||||
/// </summary>
|
||||
/// <param name="value">The attempted value.</param>
|
||||
/// <returns>The builder instance.</returns>
|
||||
public ProblemBuilder WithAttemptedValue(object? value)
|
||||
{
|
||||
_attemptedValue = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="Problem"/> instance.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="Problem"/>.</returns>
|
||||
public Problem Build()
|
||||
{
|
||||
return new Problem
|
||||
{
|
||||
Message = _message,
|
||||
Code = _code,
|
||||
Severity = _severity,
|
||||
PropertyPath = _propertyPath,
|
||||
AttemptedValue = _attemptedValue
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implicitly converts the builder to a <see cref="Problem"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to convert.</param>
|
||||
public static implicit operator Problem(ProblemBuilder builder)
|
||||
{
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
407
src/request.validation/PropertyPath.cs
Normal file
407
src/request.validation/PropertyPath.cs
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Collections;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a property path used by validation problems.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(PropertyPathJsonConverter))]
|
||||
public readonly struct PropertyPath : IEquatable<PropertyPath>, IEquatable<string>, IReadOnlyList<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new property path.
|
||||
/// </summary>
|
||||
public PropertyPath(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a property path from a lambda expression.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type containing the property.</typeparam>
|
||||
/// <param name="expression">The expression representing the property access.</param>
|
||||
/// <returns>A new <see cref="PropertyPath"/>.</returns>
|
||||
public static PropertyPath Of<T>(Expression<Func<T, object?>> expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
return FromExpression(expression);
|
||||
}
|
||||
|
||||
internal static PropertyPath FromExpression(Expression expression)
|
||||
{
|
||||
var segments = new List<string>();
|
||||
|
||||
var current = expression;
|
||||
while (current is not null)
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
case LambdaExpression expr:
|
||||
current = expr.Body;
|
||||
break;
|
||||
case MemberExpression expr:
|
||||
segments.Add(expr.Member.Name);
|
||||
current = expr.Expression;
|
||||
break;
|
||||
case MethodCallExpression { Method.Name: "get_Item", Arguments.Count: 1 } expr:
|
||||
segments.Add($"[{Evaluate(expr.Arguments[0])}]");
|
||||
current = expr.Object;
|
||||
break;
|
||||
case BinaryExpression { NodeType: ExpressionType.ArrayIndex } expr:
|
||||
segments.Add($"[{Evaluate(expr.Right)}]");
|
||||
current = expr.Left;
|
||||
break;
|
||||
case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } expr:
|
||||
current = expr.Operand;
|
||||
break;
|
||||
case ParameterExpression:
|
||||
current = null;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("Only simple member access expressions are supported.");
|
||||
}
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
for (var i = segments.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var segment = segments[i];
|
||||
if (builder.Length > 0 && !segment.StartsWith('['))
|
||||
{
|
||||
builder.Append('.');
|
||||
}
|
||||
|
||||
builder.Append(segment);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static object? Evaluate(Expression expression)
|
||||
{
|
||||
if (expression is ConstantExpression constant)
|
||||
{
|
||||
return constant.Value;
|
||||
}
|
||||
|
||||
if (expression is MemberExpression member && member.Expression is ConstantExpression closure)
|
||||
{
|
||||
var value = closure.Value;
|
||||
if (member.Member is System.Reflection.FieldInfo field)
|
||||
{
|
||||
return field.GetValue(value);
|
||||
}
|
||||
|
||||
if (member.Member is System.Reflection.PropertyInfo prop)
|
||||
{
|
||||
return prop.GetValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Expression.Lambda(expression).Compile().DynamicInvoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw property path.
|
||||
/// </summary>
|
||||
public string Value { get; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of path segments.
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = 0;
|
||||
var enumerator = new Enumerator(Value);
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the property path contains no segments.
|
||||
/// </summary>
|
||||
public bool IsEmpty => Value.Length is 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the segment at the specified index.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<char> this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
|
||||
var currentIndex = 0;
|
||||
var enumerator = new Enumerator(Value);
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
if (currentIndex == index)
|
||||
{
|
||||
return enumerator.Current;
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(index), index, "The segment index must refer to an existing property path segment.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string into a property path.
|
||||
/// </summary>
|
||||
public static implicit operator PropertyPath(string value)
|
||||
{
|
||||
return new PropertyPath(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a property path into its string representation.
|
||||
/// </summary>
|
||||
public static implicit operator string(PropertyPath propertyPath)
|
||||
{
|
||||
return propertyPath.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(PropertyPath other)
|
||||
{
|
||||
return string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(string? other)
|
||||
{
|
||||
return string.Equals(Value, other, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return (obj is PropertyPath other && Equals(other)) || (obj is string s && Equals(s));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two property paths for ordinal equality.
|
||||
/// </summary>
|
||||
public static bool operator ==(PropertyPath left, PropertyPath right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two property paths for ordinal inequality.
|
||||
/// </summary>
|
||||
public static bool operator !=(PropertyPath left, PropertyPath right)
|
||||
{
|
||||
return !left.Equals(right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines two property paths using the same rules as nested validation paths.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a collection index to a property path.
|
||||
/// </summary>
|
||||
public static PropertyPath operator +(PropertyPath propertyPath, int index)
|
||||
{
|
||||
return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an allocation-free enumerator over the path segments.
|
||||
/// </summary>
|
||||
public Enumerator GetEnumerator()
|
||||
{
|
||||
return new Enumerator(Value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
string IReadOnlyList<string>.this[int index] => this[index].ToString();
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator<string> IEnumerable<string>.GetEnumerator()
|
||||
{
|
||||
var enumerator = new Enumerator(Value);
|
||||
|
||||
List<string> segments = [];
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
segments.Add(enumerator.Current.ToString());
|
||||
}
|
||||
|
||||
return segments.GetEnumerator();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
var enumerator = new Enumerator(Value);
|
||||
|
||||
List<string> segments = [];
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
segments.Add(enumerator.Current.ToString());
|
||||
}
|
||||
|
||||
return segments.GetEnumerator();
|
||||
}
|
||||
|
||||
internal string ToJsonName(JsonNamingPolicy? namingPolicy)
|
||||
{
|
||||
var value = Value;
|
||||
if (namingPolicy is null || value.Length is 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
var first = true;
|
||||
var enumerator = new Enumerator(value);
|
||||
while (enumerator.MoveNext())
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
builder.Append('.');
|
||||
}
|
||||
|
||||
if (enumerator.Current is { IsEmpty: false } segment)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates path segments without allocations.
|
||||
/// </summary>
|
||||
public ref struct Enumerator
|
||||
{
|
||||
private readonly ReadOnlySpan<char> _value;
|
||||
private int _nextStart;
|
||||
|
||||
internal Enumerator(ReadOnlySpan<char> value)
|
||||
{
|
||||
_value = value;
|
||||
_nextStart = value.Length is 0 ? -1 : 0;
|
||||
Current = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current path segment.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<char> Current { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next path segment.
|
||||
/// </summary>
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_nextStart < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = _nextStart;
|
||||
var bracketDepth = 0;
|
||||
|
||||
for (var i = start; i < _value.Length; i++)
|
||||
{
|
||||
switch (_value[i])
|
||||
{
|
||||
case '[':
|
||||
bracketDepth++;
|
||||
break;
|
||||
case ']':
|
||||
bracketDepth = int.Max(bracketDepth - 1, 0);
|
||||
break;
|
||||
case '.' when bracketDepth is 0:
|
||||
Current = _value[start..i];
|
||||
_nextStart = i + 1;
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Current = _value[start..];
|
||||
_nextStart = -1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PropertyPathJsonConverter : JsonConverter<PropertyPath>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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}.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy));
|
||||
}
|
||||
}
|
||||
119
src/request.validation/Rule.cs
Normal file
119
src/request.validation/Rule.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
internal abstract record Rule
|
||||
{
|
||||
protected Rule(Expression expression)
|
||||
{
|
||||
PropertyPath = PropertyPath.FromExpression(expression);
|
||||
}
|
||||
|
||||
protected PropertyPath GetPropertyPath()
|
||||
{
|
||||
return PropertyPathTransform(PropertyPath);
|
||||
}
|
||||
|
||||
public PropertyPath PropertyPath { get; }
|
||||
|
||||
public Severity Severity { get; init; } = Severity.Error;
|
||||
|
||||
public string? Code { get; init; }
|
||||
|
||||
public Func<PropertyPath, PropertyPath> PropertyPathTransform { get; init; } = static path => path;
|
||||
|
||||
public abstract IEnumerable<Problem> Validate(ValidationContext context);
|
||||
}
|
||||
|
||||
internal abstract record Rule<T, TProperty> : Rule
|
||||
{
|
||||
protected Rule(Expression expression)
|
||||
: base(expression)
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<IRuleStep<TProperty>> Steps { get; init; } = [];
|
||||
|
||||
public override IEnumerable<Problem> Validate(ValidationContext context)
|
||||
{
|
||||
if (context.Instance is T instance)
|
||||
{
|
||||
return Validate(instance, context);
|
||||
}
|
||||
|
||||
if (context.Instance is null && default(T) is null)
|
||||
{
|
||||
return Validate((T)context.Instance!, context);
|
||||
}
|
||||
|
||||
var actualType = context.Instance?.GetType().FullName ?? "null";
|
||||
throw new InvalidOperationException(
|
||||
$"Expected validation context instance of type '{typeof(T).FullName}', but got '{actualType}'.");
|
||||
}
|
||||
|
||||
protected abstract IEnumerable<Problem> Validate(T instance, ValidationContext context);
|
||||
}
|
||||
|
||||
internal sealed record PropertyRule<T, TProperty> : Rule<T, TProperty>
|
||||
{
|
||||
private readonly Func<T, TProperty> _accessor;
|
||||
|
||||
public PropertyRule(Expression<Func<T, TProperty>> expression) : base(expression)
|
||||
{
|
||||
_accessor = expression.Compile();
|
||||
}
|
||||
|
||||
protected override IEnumerable<Problem> Validate(T instance, ValidationContext context)
|
||||
{
|
||||
if (Steps.Count is 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var value = _accessor(instance);
|
||||
var propertyPath = GetPropertyPath();
|
||||
return Steps.SelectMany(step => step.Validate(value, context, propertyPath, Code, Severity));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CollectionRule<T, TElement> : Rule<T, TElement>
|
||||
{
|
||||
private readonly Func<T, IEnumerable<TElement>> _accessor;
|
||||
|
||||
public CollectionRule(Expression<Func<T, IEnumerable<TElement>>> expression) : base(expression)
|
||||
{
|
||||
_accessor = expression.Compile();
|
||||
}
|
||||
|
||||
protected override IEnumerable<Problem> Validate(T instance, ValidationContext context)
|
||||
{
|
||||
if (Steps.Count is 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (_accessor(instance) is not { } collection)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var element in collection)
|
||||
{
|
||||
var propertyPath = GetPropertyPath() + index;
|
||||
|
||||
foreach (var step in Steps)
|
||||
{
|
||||
foreach (var problem in step.Validate(element, context, propertyPath, Code, Severity))
|
||||
{
|
||||
yield return problem;
|
||||
}
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
334
src/request.validation/RuleBuilderExtensions.cs
Normal file
334
src/request.validation/RuleBuilderExtensions.cs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Provides built-in validators for common validation scenarios.
|
||||
/// </summary>
|
||||
public static class RuleBuilderExtensions
|
||||
{
|
||||
private static bool IsNull<TProperty>([NotNullWhen(false)] TProperty? value)
|
||||
{
|
||||
object? boxed = value;
|
||||
return boxed is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is not null for reference types.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated. Must be a nullable reference type.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-null condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> NotNull<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule)
|
||||
where TProperty : class?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => value is not null, "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is not null for nullable value types.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The underlying non-nullable value type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-null condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty?> NotNull<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty?> rule)
|
||||
where TProperty : struct
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => value.HasValue, "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value is not null, empty, or whitespace.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-empty condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> NotEmpty<T>(this IPropertyRuleBuilder<T, string?> rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => !string.IsNullOrWhiteSpace(value), "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the collection property value is not null and contains at least one element.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TElement">The type of the elements in the collection being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <returns>The updated rule builder with the not-empty condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, IEnumerable<TElement>?> NotEmpty<T, TElement>(
|
||||
this IPropertyRuleBuilder<T, IEnumerable<TElement>?> rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(static value => value is not null && value.Any(), "Value is required.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the current rule property path from emitted validation problems.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the property path transform is applied.</param>
|
||||
/// <returns>The updated rule builder with the property path removed.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> WithoutPropertyPath<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.WithPropertyPath(static _ => string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value meets the specified minimum length.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="minLength">The minimum allowed number of characters.</param>
|
||||
/// <returns>The updated rule builder with the minimum length condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> MinLength<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
int minLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minLength);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.Length >= minLength,
|
||||
$"Value must be at least {minLength} characters long.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value does not exceed the specified maximum length.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="maxLength">The maximum allowed number of characters.</param>
|
||||
/// <returns>The updated rule builder with the maximum length condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> MaxLength<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
int maxLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.Length <= maxLength,
|
||||
$"Value must be at most {maxLength} characters long.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value falls within the specified inclusive length range.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="minLength">The minimum allowed number of characters.</param>
|
||||
/// <param name="maxLength">The maximum allowed number of characters.</param>
|
||||
/// <returns>The updated rule builder with the length range condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> Length<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
int minLength,
|
||||
int maxLength)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minLength);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(maxLength);
|
||||
|
||||
if (maxLength < minLength)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLength),
|
||||
"Maximum length must be greater than or equal to minimum length.");
|
||||
}
|
||||
|
||||
return rule.Must(value => IsNull(value) || (value.Length >= minLength && value.Length <= maxLength),
|
||||
$"Value must be between {minLength} and {maxLength} characters long.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is greater than the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be greater than.</param>
|
||||
/// <returns>The updated rule builder with the greater-than condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> GreaterThan<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0,
|
||||
$"Value must be greater than {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is greater than or equal to the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be greater than or equal to.</param>
|
||||
/// <returns>The updated rule builder with the greater-than-or-equal condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> GreaterThanOrEqualTo<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0,
|
||||
$"Value must be greater than or equal to {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is less than the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be less than.</param>
|
||||
/// <returns>The updated rule builder with the less-than condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> LessThan<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0,
|
||||
$"Value must be less than {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is less than or equal to the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The value that the property must be less than or equal to.</param>
|
||||
/// <returns>The updated rule builder with the less-than-or-equal condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> LessThanOrEqualTo<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0,
|
||||
$"Value must be less than or equal to {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value falls within the specified inclusive range.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="minValue">The minimum allowed value.</param>
|
||||
/// <param name="maxValue">The maximum allowed value.</param>
|
||||
/// <returns>The updated rule builder with the range condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> Between<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty minValue,
|
||||
TProperty maxValue)
|
||||
where TProperty : IComparable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
if (minValue is not null && maxValue is not null && minValue.CompareTo(maxValue) > 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxValue),
|
||||
"Maximum value must be greater than or equal to minimum value.");
|
||||
}
|
||||
|
||||
return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0),
|
||||
$"Value must be between {minValue} and {maxValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is equal to the specified comparison value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="comparisonValue">The required value for the property.</param>
|
||||
/// <returns>The updated rule builder with the equality condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> Equal<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty comparisonValue)
|
||||
where TProperty : IEquatable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => EqualityComparer<TProperty>.Default.Equals(value, comparisonValue),
|
||||
$"Value must be equal to {comparisonValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the property value is not equal to the specified disallowed value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <typeparam name="TProperty">The type of the property being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="disallowedValue">The value that the property must not equal.</param>
|
||||
/// <returns>The updated rule builder with the inequality condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, TProperty> NotEqual<T, TProperty>(
|
||||
this IPropertyRuleBuilder<T, TProperty> rule,
|
||||
TProperty disallowedValue)
|
||||
where TProperty : IEquatable<TProperty>?
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
return rule.Must(value => !EqualityComparer<TProperty>.Default.Equals(value, disallowedValue),
|
||||
$"Value must not be equal to {disallowedValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value matches the specified regular expression pattern.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="pattern">The regular expression pattern that the property value must match.</param>
|
||||
/// <returns>The updated rule builder with the pattern-matching condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> Matches<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
string pattern)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentException.ThrowIfNullOrEmpty(pattern);
|
||||
|
||||
return rule.Must(value => IsNull(value) || Regex.IsMatch(value, pattern),
|
||||
"Value is not in the correct format.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a rule to ensure that the string property value matches the specified regular expression.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object being validated.</typeparam>
|
||||
/// <param name="rule">The rule builder to which the condition is applied.</param>
|
||||
/// <param name="regex">The regular expression that the property value must match.</param>
|
||||
/// <returns>The updated rule builder with the pattern-matching condition added.</returns>
|
||||
public static IPropertyRuleBuilder<T, string?> Matches<T>(
|
||||
this IPropertyRuleBuilder<T, string?> rule,
|
||||
Regex regex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentNullException.ThrowIfNull(regex);
|
||||
|
||||
return rule.Must(value => IsNull(value) || regex.IsMatch(value),
|
||||
"Value is not in the correct format.");
|
||||
}
|
||||
}
|
||||
64
src/request.validation/RuleStep.cs
Normal file
64
src/request.validation/RuleStep.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
internal interface IRuleStep<in TValue>
|
||||
{
|
||||
IEnumerable<Problem> Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity);
|
||||
}
|
||||
|
||||
internal sealed record PredicateRuleStep<TValue> : IRuleStep<TValue>
|
||||
{
|
||||
private readonly Func<TValue, bool> _predicate;
|
||||
private readonly string _message;
|
||||
|
||||
public PredicateRuleStep(Func<TValue, bool> predicate, string message)
|
||||
{
|
||||
_predicate = predicate;
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity)
|
||||
{
|
||||
if (_predicate(value))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new Problem
|
||||
{
|
||||
PropertyPath = propertyPath,
|
||||
Message = _message,
|
||||
Code = code,
|
||||
Severity = severity,
|
||||
AttemptedValue = value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ValidatorRuleStep<TValue> : IRuleStep<TValue>
|
||||
{
|
||||
private readonly Func<ValidationContext, IValidator> _resolver;
|
||||
|
||||
public ValidatorRuleStep(Func<ValidationContext, IValidator> resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public IEnumerable<Problem> Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var clone = new ValidationContext<object?>(value, context.ServiceProvider, context.Items);
|
||||
var validation = _resolver(context).Validate(clone);
|
||||
|
||||
foreach (var problem in validation.Problems)
|
||||
{
|
||||
yield return problem with { PropertyPath = propertyPath + problem.PropertyPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/request.validation/Severity.cs
Normal file
25
src/request.validation/Severity.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the severity of a rule.
|
||||
/// </summary>
|
||||
public enum Severity
|
||||
{
|
||||
/// <summary>
|
||||
/// Error
|
||||
/// </summary>
|
||||
Error,
|
||||
|
||||
/// <summary>
|
||||
/// Warning
|
||||
/// </summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>
|
||||
/// Info
|
||||
/// </summary>
|
||||
Info,
|
||||
}
|
||||
32
src/request.validation/Validation.cs
Normal file
32
src/request.validation/Validation.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of executing one or more validations against a given object or request.
|
||||
/// </summary>
|
||||
public sealed class Validation
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the result of executing one or more validations against a given object or request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class contains the list of validation problems identified during the validation process.
|
||||
/// A validation is considered successful if no problems are found.
|
||||
/// </remarks>
|
||||
public Validation(IEnumerable<Problem> problems)
|
||||
{
|
||||
Problems = [.. problems];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the validation was successful.
|
||||
/// </summary>
|
||||
public bool IsValid => Problems.Count is 0;
|
||||
|
||||
/// <summary>
|
||||
/// The problems that were found during validation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Problem> Problems { get; }
|
||||
}
|
||||
64
src/request.validation/ValidationContext.cs
Normal file
64
src/request.validation/ValidationContext.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the context in which validation occurs, providing information about the
|
||||
/// instance being validated, service provider, and additional items for use in validation.
|
||||
/// </summary>
|
||||
public abstract class ValidationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new validation context.
|
||||
/// </summary>
|
||||
/// <param name="instance">The object currently being validated.</param>
|
||||
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
|
||||
/// <param name="items">Per-call state shared across nested validation operations.</param>
|
||||
protected ValidationContext(object? instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
|
||||
{
|
||||
Instance = instance;
|
||||
ServiceProvider = serviceProvider;
|
||||
Items = items ?? ReadOnlyDictionary<object, object?>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The object currently being validated.
|
||||
/// </summary>
|
||||
public object? Instance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The service provider available for nested validator resolution.
|
||||
/// </summary>
|
||||
public IServiceProvider? ServiceProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-call state shared across nested validation operations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<object, object?> Items { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the context in which validation occurs, providing information about the
|
||||
/// instance being validated, service provider, and additional items for use in validation.
|
||||
/// </summary>
|
||||
public sealed class ValidationContext<T> : ValidationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new validation context.
|
||||
/// </summary>
|
||||
/// <param name="instance">The object currently being validated.</param>
|
||||
/// <param name="serviceProvider">The service provider available for nested validator resolution.</param>
|
||||
/// <param name="items">Per-call state shared across nested validation operations.</param>
|
||||
public ValidationContext(T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
|
||||
: base(instance, serviceProvider, items)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The object currently being validated.
|
||||
/// </summary>
|
||||
public new T? Instance => base.Instance is T value ? value : default;
|
||||
}
|
||||
152
src/request.validation/Validator.cs
Normal file
152
src/request.validation/Validator.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Geekeey.Request.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the base class for defining validation logic for a specific type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the object to be validated.</typeparam>
|
||||
public abstract class Validator<T> : IValidator<T>
|
||||
{
|
||||
private readonly List<Rule> _rules = [];
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validation rule for a specific property of the type being validated.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">The type of the property to validate.</typeparam>
|
||||
/// <param name="expression">An expression representing the property to validate.</param>
|
||||
/// <returns>An object that allows further configuration of the validation rule.</returns>
|
||||
public IPropertyRuleBuilder<T, TProperty> RuleFor<TProperty>(
|
||||
Expression<Func<T, TProperty>> expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
_rules.Add(new PropertyRule<T, TProperty>(expression));
|
||||
|
||||
return new PropertyRuleBuilder<TProperty>(_rules, _rules.Count - 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a validation rule for each element in a collection property of the type being validated.
|
||||
/// </summary>
|
||||
/// <typeparam name="TElement">The type of the elements in the collection to validate.</typeparam>
|
||||
/// <param name="expression">An expression representing the collection property to validate.</param>
|
||||
/// <returns>An object that allows further configuration of the validation rule.</returns>
|
||||
public IPropertyRuleBuilder<T, TElement> RuleForEach<TElement>(
|
||||
Expression<Func<T, IEnumerable<TElement>>> expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
_rules.Add(new CollectionRule<T, TElement>(expression));
|
||||
|
||||
return new PropertyRuleBuilder<TElement>(_rules, _rules.Count - 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Validation Validate(T instance)
|
||||
{
|
||||
return Validate(new ValidationContext<T>(instance));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Validation Validate(ValidationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
return new Validation(_rules.SelectMany(rule => rule.Validate(context)));
|
||||
}
|
||||
|
||||
private static IValidator ResolveValidator<TValidator>(ValidationContext context)
|
||||
where TValidator : IValidator
|
||||
{
|
||||
if (context.ServiceProvider is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve validator of type '{typeof(TValidator).FullName}' because the validation context has no service provider.");
|
||||
}
|
||||
|
||||
if (context.ServiceProvider.GetService(typeof(TValidator)) is not TValidator validator)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve validator of type '{typeof(TValidator).FullName}' from the validation context service provider.");
|
||||
}
|
||||
|
||||
return validator;
|
||||
}
|
||||
|
||||
private sealed class PropertyRuleBuilder<TProperty>
|
||||
: IPropertyRuleBuilder<T, TProperty>
|
||||
{
|
||||
private readonly List<Rule> _rules;
|
||||
private readonly int _index;
|
||||
|
||||
public PropertyRuleBuilder(List<Rule> rules, int index)
|
||||
{
|
||||
_rules = rules;
|
||||
_index = index;
|
||||
}
|
||||
|
||||
private Rule<T, TProperty> CurrentRule
|
||||
{
|
||||
get => (Rule<T, TProperty>)_rules[_index];
|
||||
set => _rules[_index] = value;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> Must(Func<TProperty, bool> predicate, string message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
ArgumentException.ThrowIfNullOrEmpty(message);
|
||||
|
||||
var step = new PredicateRuleStep<TProperty>(predicate, message);
|
||||
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> SetValidator(IValidator validator)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(validator);
|
||||
|
||||
var step = new ValidatorRuleStep<TProperty>(_ => validator);
|
||||
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> SetValidator<TValidator>() where TValidator : IValidator
|
||||
{
|
||||
var step = new ValidatorRuleStep<TProperty>(ResolveValidator<TValidator>);
|
||||
CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> WithCode(string code)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(code);
|
||||
|
||||
CurrentRule = CurrentRule with { Code = code };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> WithPropertyPath(Func<PropertyPath, PropertyPath> transform)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transform);
|
||||
|
||||
CurrentRule = CurrentRule with { PropertyPathTransform = transform };
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IPropertyRuleBuilder<T, TProperty> WithSeverity(Severity severity)
|
||||
{
|
||||
CurrentRule = CurrentRule with { Severity = severity };
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/request.validation/package-icon.png
Normal file
BIN
src/request.validation/package-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
110
src/request.validation/package-readme.md
Normal file
110
src/request.validation/package-readme.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
## Features
|
||||
|
||||
- **Composable validators:** Build validators by inheriting from `Validator<T>` and defining rules with `RuleFor` and
|
||||
`RuleForEach`.
|
||||
- **Built-in and custom rules:** Use helpers like `NotEmpty`, `Length`, `Between`, and `Matches`, or define custom
|
||||
predicates with `Must`.
|
||||
- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity,
|
||||
code, and attempted value.
|
||||
- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution.
|
||||
- **Configurable paths:** Rewrite or remove rule property paths when the reported path should differ from the CLR
|
||||
member path.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Install the NuGet package:
|
||||
|
||||
```shell
|
||||
dotnet add package Geekeey.Request.Validation
|
||||
```
|
||||
|
||||
You may need to add our NuGet feed to your nuget.config this can be done by running the following command:
|
||||
|
||||
```shell
|
||||
dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```csharp
|
||||
using Geekeey.Request.Validation;
|
||||
|
||||
public sealed record Address(string? Street);
|
||||
|
||||
public sealed record CreateUserRequest(
|
||||
string? Name,
|
||||
int Age,
|
||||
Address? Address,
|
||||
IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed class AddressValidator : Validator<Address>
|
||||
{
|
||||
public AddressValidator()
|
||||
{
|
||||
RuleFor(address => address.Street)
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CreateUserRequestValidator : Validator<CreateUserRequest>
|
||||
{
|
||||
public CreateUserRequestValidator()
|
||||
{
|
||||
RuleFor(request => request.Name)
|
||||
.NotEmpty()
|
||||
.Length(2, 100)
|
||||
.WithCode("NAME_INVALID");
|
||||
|
||||
RuleFor(request => request.Age)
|
||||
.Between(18, 120);
|
||||
|
||||
RuleFor(request => request.Address)
|
||||
.SetValidator(new AddressValidator());
|
||||
|
||||
RuleForEach(request => request.Tags)
|
||||
.NotEmpty()
|
||||
.WithSeverity(Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LoginInput(string? Username);
|
||||
|
||||
public sealed record LoginRequest(LoginInput Input);
|
||||
|
||||
public sealed class LoginInputValidator : Validator<LoginInput>
|
||||
{
|
||||
public LoginInputValidator()
|
||||
{
|
||||
RuleFor(input => input.Username)
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LoginRequestValidator : Validator<LoginRequest>
|
||||
{
|
||||
public LoginRequestValidator()
|
||||
{
|
||||
RuleFor(request => request.Input)
|
||||
.WithoutPropertyPath()
|
||||
.SetValidator(new LoginInputValidator());
|
||||
}
|
||||
}
|
||||
|
||||
var validator = new CreateUserRequestValidator();
|
||||
var validation = validator.Validate(new CreateUserRequest(
|
||||
Name: "",
|
||||
Age: 16,
|
||||
Address: new Address(""),
|
||||
Tags: ["", "admin"]));
|
||||
|
||||
foreach (var problem in validation.Problems)
|
||||
{
|
||||
Console.WriteLine($"{problem.PropertyPath}: {problem.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
The resulting `Problem` entries include full property paths like `Address.Street` and `Tags[0]`, making it easy to
|
||||
surface validation errors back to callers or APIs.
|
||||
|
||||
If the validation path needs to differ from the CLR property path, use `WithPropertyPath(...)` for a custom transform
|
||||
or `WithoutPropertyPath()` to drop the current rule path entirely before nested paths are appended.
|
||||
Loading…
Add table
Add a link
Reference in a new issue