Compare commits

..

11 commits

Author SHA1 Message Date
eff887b49f
feat: add json property path supports for object keys
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 2m4s
default / dotnet-default-workflow (push) Successful in 2m1s
2026-05-30 21:17:14 +02:00
29be774abc
chore: post release version changes
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 2m3s
default / dotnet-default-workflow (push) Successful in 2m50s
2026-05-30 19:56:57 +02:00
a0d69fde41
feat: release 2.0.0
All checks were successful
release / dotnet-release-workflow (push) Successful in 3m7s
2026-05-30 19:55:03 +02:00
c674de31f7
chore: rename internal types
Some checks failed
default / dotnet-default-workflow (pull_request) Successful in 2m5s
default / dotnet-default-workflow (push) Has been cancelled
Rename internal types in the validation to be more consistent with other
projects
2026-05-30 19:36:28 +02:00
36f1a9eb1b
feat: rename validation builder for parity
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 1m58s
default / dotnet-default-workflow (push) Successful in 1m56s
Rename extensions functions and `IValidatorBuilder` for parity with
dispatcher options and builder.
2026-05-29 23:13:06 +02:00
4d33d5ab4c
feat: hide pipelines internals from stack trace
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 2m4s
default / dotnet-default-workflow (push) Successful in 1m59s
2026-05-29 23:10:01 +02:00
bae9b2a5f1
chore: post release version changes
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 2m8s
default / dotnet-default-workflow (push) Successful in 2m7s
release / dotnet-release-workflow (push) Successful in 2m11s
2026-05-28 22:07:54 +02:00
cc2a643f63
feat: release 1.1.0
All checks were successful
release / dotnet-release-workflow (push) Successful in 2m8s
2026-05-28 22:07:54 +02:00
21b6f04f8c
fix: type cache reset when metadata updates occure
All checks were successful
default / dotnet-default-workflow (pull_request) Successful in 3m28s
default / dotnet-default-workflow (push) Successful in 2m10s
2026-05-28 20:39:51 +02:00
22142882b5
feat: add support for validator resolution from dependency injection 2026-05-28 20:12:00 +02:00
21ca8a3757
chore: post release version changes
All checks were successful
default / dotnet-default-workflow (push) Successful in 3m2s
2026-05-26 22:26:46 +02:00
28 changed files with 989 additions and 7 deletions

View file

@ -12,13 +12,42 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **request.result:** Optional result wrapper types (`Result<T>`) for structured responses - **request.result:** Optional result wrapper types (`Result<T>`) for structured responses
- **request.validation:** Automatic validation pipeline behavior with FluentValidation integration - **request.validation:** Automatic validation pipeline behavior with FluentValidation integration
## [1.1.0] - 2026-05-28
### Added
- **request.validation:** Dependency injection support for validators via `IServiceCollection.AddValidation()`
- **request.validation:** `IValidatorBuilder` for fluent validator registration and assembly scanning
- **request.validation:** Support for open generic validators and automatic closing during resolution
- **request.validation:** `Validate<T>` extension method for simplified validator invocation
### Changed
- **request.dispatcher:** Reset type caches when reloading assemblies
- **request.validation:** Reset type caches when reloading assemblies
## [2.0.0] - 2026-05-30
## Breaking changes
To have a consistent experience across all packages, some public interfaces have been renamed.
### Changed
- **request.dispatcher:** Hide pipeline internals in stack frames
- **request.validation:** Rename `IValidatorBuilder` to `IRequestValidatorBuilder` incl. extensions methods
## [Unreleased] ## [Unreleased]
### Added ### Added
- **request.validation:** Support `PropertyPath` JSON converter for string values and dictionary property names
### Changed ### Changed
### Removed ### Removed
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0
[Unreleased]: https://code.geekeey.de/geekeey/request/compare/1.0.0...HEAD [1.1.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.1.0
[2.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/2.0.0
[Unreleased]: https://code.geekeey.de/geekeey/request/compare/2.0.0...HEAD

View file

@ -9,7 +9,8 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<VersionPrefix>1.0.0</VersionPrefix> <VersionPrefix>2.1.0</VersionPrefix>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -0,0 +1,73 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Dispatcher.Tests;
internal sealed class StackTraceTests
{
[Test]
public async Task I_can_not_see_pipeline_internals_are_hidden_from_stack_trace_scalar()
{
// Arrange
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(FailingScalarHandler))
.Add(typeof(ScalarOpenBehavior<,>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new FailingScalarRequest();
// Act
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
dispatcher.DispatchAsync(request));
// Assert
await Assert.That(exception).IsNotNull();
var stackTrace = await Assert.That(exception.StackTrace).IsNotNull();
await Assert.That(stackTrace).Contains(nameof(FailingScalarHandler));
await Assert.That(stackTrace).Contains(nameof(ScalarOpenBehavior<,>));
// 3. Verify that the internal lambda from ScalarRequestInvoker.Chain is HIDDEN.
// In C#, these lambdas usually appear as "ScalarRequestInvoker`2.<>c__DisplayClass..." or similar.
// Since we added [StackTraceHidden], this frame should be omitted.
await Assert.That(stackTrace).DoesNotContain("ScalarRequestInvoker+<>");
}
[Test]
public async Task I_can_not_see_pipeline_internals_are_hidden_from_stack_trace_stream()
{
// Arrange
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(FailingStreamHandler))
.Add(typeof(StreamOpenBehavior<,>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new FailingStreamRequest();
// Act
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
dispatcher.DispatchAsync(request).ToListAsync().AsTask());
// Assert
await Assert.That(exception).IsNotNull();
var stackTrace = await Assert.That(exception.StackTrace).IsNotNull();
await Assert.That(stackTrace).Contains(nameof(FailingStreamHandler));
await Assert.That(stackTrace).Contains(nameof(StreamOpenBehavior<,>));
// 3. Verify that the internal lambda from ScalarRequestInvoker.Chain is HIDDEN.
// In C#, these lambdas usually appear as "ScalarRequestInvoker`2.<>c__DisplayClass..." or similar.
// Since we added [StackTraceHidden], this frame should be omitted.
await Assert.That(stackTrace).DoesNotContain("StreamRequestInvoker+<>");
}
}

View file

@ -1,14 +1,19 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
namespace Geekeey.Request.Dispatcher.Tests; namespace Geekeey.Request.Dispatcher.Tests;
public class StreamOpenBehavior<TRequest, TResponse>(StreamTestTracker tracker) : IStreamRequestBehavior<TRequest, TResponse> public class StreamOpenBehavior<TRequest, TResponse>(StreamTestTracker tracker) : IStreamRequestBehavior<TRequest, TResponse>
where TRequest : IStreamRequest<TResponse> where TRequest : IStreamRequest<TResponse>
{ {
public IAsyncEnumerable<TResponse> HandleAsync(TRequest request, StreamHandlerDelegate<TResponse> next, CancellationToken cancellationToken) public async IAsyncEnumerable<TResponse> HandleAsync(TRequest request, StreamHandlerDelegate<TResponse> next, [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
tracker.Executed = true; tracker.Executed = true;
return next(request, cancellationToken); await foreach (var response in next(request, cancellationToken))
{
yield return response;
}
} }
} }

View file

@ -20,6 +20,12 @@ internal sealed class RequestDispatcher : IRequestDispatcher
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
} }
public static void ClearCache(Type[]? _)
{
ScalarRequestHandlers.Clear();
StreamRequestHandlers.Clear();
}
public Task<TResponse> DispatchAsync<TResponse>(IScalarRequest<TResponse> request, CancellationToken cancellationToken = default) public Task<TResponse> DispatchAsync<TResponse>(IScalarRequest<TResponse> request, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);

View file

@ -1,6 +1,8 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -36,7 +38,7 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
static ScalarHandlerDelegate<TResponse> Chain(ScalarHandlerDelegate<TResponse> next, IScalarRequestBehavior<TRequest, TResponse> filter) static ScalarHandlerDelegate<TResponse> Chain(ScalarHandlerDelegate<TResponse> next, IScalarRequestBehavior<TRequest, TResponse> filter)
{ {
return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); return [StackTraceHidden] (req, ct) => filter.HandleAsync((TRequest)req, next, ct);
} }
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct) Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)

View file

@ -1,6 +1,7 @@
// Copyright (c) The Geekeey Authors // Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2 // SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -41,7 +42,7 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
static StreamHandlerDelegate<TResponse> Chain(StreamHandlerDelegate<TResponse> next, IStreamRequestBehavior<TRequest, TResponse> filter) static StreamHandlerDelegate<TResponse> Chain(StreamHandlerDelegate<TResponse> next, IStreamRequestBehavior<TRequest, TResponse> filter)
{ {
return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); return [StackTraceHidden] (req, ct) => filter.HandleAsync((TRequest)req, next, ct);
} }
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct) IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)

View file

@ -0,0 +1,177 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation.Tests;
internal sealed class DependencyInjectionTests
{
[Test]
public async Task I_can_resolve_single_validator()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add<OrderedFailuresValidator>(ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var result = validator.Validate(new StringValueModel());
await Assert.That(result.Problems).Count().IsEqualTo(3);
}
[Test]
public async Task I_can_resolve_multiple_validators()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add<PersonValidator>(ServiceLifetime.Transient);
builder.Add<AnotherPersonValidator>(ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var result = validator.Validate(new Person());
await Assert.That(result.Problems).Count().IsEqualTo(2);
await Assert.That(result.Problems.Any(p => p.Message == "PersonValidator failure.")).IsTrue();
await Assert.That(result.Problems.Any(p => p.Message == "AnotherPersonValidator failure.")).IsTrue();
}
[Test]
public async Task I_can_resolve_open_generic_validator()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add(typeof(GenericValidator<>), ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var result = validator.Validate(new Person());
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.Problems[0].Message).IsEqualTo("GenericValidator failure.");
}
[Test]
public async Task I_can_resolve_multi_interface_validator()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add<MultiInterfaceValidator>(ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var personResult = validator.Validate(new Person());
await Assert.That(personResult.Problems).Count().IsEqualTo(1);
await Assert.That(personResult.Problems[0].Message).IsEqualTo("MultiInterfaceValidator failure.");
var addressResult = validator.Validate(new Address());
await Assert.That(addressResult.Problems).Count().IsEqualTo(1);
await Assert.That(addressResult.Problems[0].Message).IsEqualTo("MultiInterfaceValidator failure.");
}
[Test]
public async Task I_can_resolve_generic_validator_with_constraints()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add(typeof(ConstrainedValidator<>), ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var teamResult = validator.Validate(new Team());
await Assert.That(teamResult.Problems).Count().IsEqualTo(1);
await Assert.That(teamResult.Problems[0].Message).IsEqualTo("ConstrainedValidator failure.");
var personResult = validator.Validate(new Person());
await Assert.That(personResult.Problems).Count().IsEqualTo(0);
}
[Test]
public async Task I_can_resolve_generic_wrapper_validator_with_constraints()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add(typeof(ConstrainedWrapperValidator<>), ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var teamWrapperResult = validator.Validate(new Wrapper<Team>());
await Assert.That(teamWrapperResult.Problems).Count().IsEqualTo(1);
await Assert.That(teamWrapperResult.Problems[0].Message).IsEqualTo("ConstrainedWrapperValidator failure.");
var personWrapperResult = validator.Validate(new Wrapper<Person>());
await Assert.That(personWrapperResult.Problems).Count().IsEqualTo(0);
}
[Test]
public async Task I_can_resolve_aggregate_validator_directly()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add<PersonValidator>(ServiceLifetime.Transient);
builder.Add<AnotherPersonValidator>(ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var person = new Person { Name = "John" };
var result = validator.Validate(person);
await Assert.That(result.Problems).Count().IsEqualTo(2);
}
[Test]
public async Task I_can_select_validators_from_base_classes_and_interfaces()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add<EntityValidator>(ServiceLifetime.Transient);
builder.Add<AnimalValidator>(ServiceLifetime.Transient);
builder.Add<DogValidator>(ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var dog = new Dog { Id = 0, Name = "", Breed = "" };
var result = validator.Validate(dog);
await Assert.That(result.Problems).Count().IsEqualTo(3);
}
[Test]
public async Task I_can_select_validators_polymorphically_based_on_the_instance_type()
{
var services = new ServiceCollection();
services.AddRequestValidation(builder =>
{
builder.Add<AnimalValidator>(ServiceLifetime.Transient);
builder.Add<DogValidator>(ServiceLifetime.Transient);
});
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator>();
var dog = new Dog { Name = "", Breed = "" };
var result = validator.Validate(new ValidationContext<object>(dog));
await Assert.That(result.Problems).Count().IsEqualTo(2);
}
}

View file

@ -12,6 +12,19 @@ internal sealed class PropertyPathTests
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 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] [Test]
public async Task I_can_serialize_property_paths_using_the_json_naming_policy() 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}"""); 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<PropertyPath, string> 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<Dictionary<PropertyPath, string>>(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] [Test]
public async Task I_can_iterate_and_index_segments() public async Task I_can_iterate_and_index_segments()
{ {

View file

@ -0,0 +1,12 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class AnotherPersonValidator : Validator<Person>
{
public AnotherPersonValidator()
{
RuleFor(p => p.Name).Must(_ => false, "AnotherPersonValidator failure.");
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ConstrainedValidator<T> : Validator<T> where T : ICustomConstraint
{
public ConstrainedValidator()
{
RuleFor(x => x).Must(_ => false, "ConstrainedValidator failure.");
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ConstrainedWrapperValidator<T> : Validator<Wrapper<T>> where T : ICustomConstraint
{
public ConstrainedWrapperValidator()
{
RuleFor(x => x).Must(_ => false, "ConstrainedWrapperValidator failure.");
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class GenericValidator<T> : Validator<T>
{
public GenericValidator()
{
RuleFor(x => x).Must(_ => false, "GenericValidator failure.");
}
}

View file

@ -0,0 +1,6 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal interface ICustomConstraint { }

View file

@ -0,0 +1,32 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class MultiInterfaceValidator : IValidator<Person>, IValidator<Address>
{
public Validation Validate(Person instance)
{
return Validate(new ValidationContext<Person>(instance));
}
public Validation Validate(Address instance)
{
return Validate(new ValidationContext<Address>(instance));
}
public Validation Validate(ValidationContext context)
{
if (context.Instance is Person)
{
return new Validation([new Problem { PropertyPath = new PropertyPath("Person"), Message = "MultiInterfaceValidator failure." }]);
}
if (context.Instance is Address)
{
return new Validation([new Problem { PropertyPath = new PropertyPath("Address"), Message = "MultiInterfaceValidator failure." }]);
}
return new Validation([]);
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class PersonValidator : Validator<Person>
{
public PersonValidator()
{
RuleFor(p => p.Name).Must(_ => false, "PersonValidator failure.");
}
}

View file

@ -0,0 +1,44 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
public interface IEntity
{
int Id { get; set; }
}
public abstract class Animal : IEntity
{
public int Id { get; set; }
public string? Name { get; set; }
}
public class Dog : Animal
{
public string? Breed { get; set; }
}
internal sealed class EntityValidator : Validator<IEntity>
{
public EntityValidator()
{
RuleFor(x => x.Id).GreaterThan(0);
}
}
internal sealed class AnimalValidator : Validator<Animal>
{
public AnimalValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
internal sealed class DogValidator : Validator<Dog>
{
public DogValidator()
{
RuleFor(x => x.Breed).NotEmpty();
}
}

View file

@ -3,7 +3,7 @@
namespace Geekeey.Request.Validation.Tests; namespace Geekeey.Request.Validation.Tests;
internal sealed class Team internal sealed class Team : ICustomConstraint
{ {
public IEnumerable<Member> Members { get; init; } = []; public IEnumerable<Member> Members { get; init; } = [];
} }

View file

@ -0,0 +1,6 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class Wrapper<T> { }

View file

@ -0,0 +1,44 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.Concurrent;
using System.Reflection.Metadata;
[assembly: MetadataUpdateHandler(typeof(Geekeey.Request.Validation.DispatchingValidator))]
namespace Geekeey.Request.Validation;
internal sealed partial class DispatchingValidator : IValidator
{
private static readonly ConcurrentDictionary<Type, ValidatorInvoker> ValidatorsHandlers = new();
private readonly IServiceProvider _serviceProvider;
public DispatchingValidator(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public static void ClearCache(Type[]? _)
{
ValidatorsHandlers.Clear();
}
public Validation Validate(ValidationContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Instance is null)
{
return new Validation([]);
}
var handler = ValidatorsHandlers.GetOrAdd(context.Instance.GetType(), static key =>
{
var type = typeof(ValidatorInvoker<>).MakeGenericType(key);
return (ValidatorInvoker)Activator.CreateInstance(type)!;
});
return handler.Validate(context, _serviceProvider);
}
}

View file

@ -28,4 +28,9 @@
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" /> <None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,17 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation;
/// <summary>
/// Defines a builder for configuring validator registrations.
/// </summary>
public interface IRequestValidatorBuilder
{
/// <summary>
/// Gets the service collection where the validators are registered.
/// </summary>
IServiceCollection Services { get; }
}

View file

@ -399,9 +399,26 @@ internal sealed class PropertyPathJsonConverter : JsonConverter<PropertyPath>
throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}.");
} }
/// <inheritdoc />
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}.");
}
/// <inheritdoc /> /// <inheritdoc />
public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options)
{ {
writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy));
} }
/// <inheritdoc />
public override void WriteAsPropertyName(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options)
{
writer.WritePropertyName(value.ToJsonName(options.DictionaryKeyPolicy));
}
} }

View file

@ -0,0 +1,167 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using static Geekeey.Request.Validation.RequestValidatorOptions;
namespace Geekeey.Request.Validation;
/// <summary>
/// Provides extension methods for configuring <see cref="IRequestValidatorBuilder"/>
/// with additional capabilities such as searching and registering validators in assemblies or adding types directly.
/// </summary>
public static class RequestValidatorBuilderExtensions
{
/// <summary>
/// Searches for validator types within the specified assembly and adds them to the validator
/// configuration.
/// </summary>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <param name="assembly">The assembly to search for validator types.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder SearchInAssembly(this IRequestValidatorBuilder builder, Assembly assembly)
{
ArgumentNullException.ThrowIfNull(builder);
var exports = assembly.GetTypes()
.Where(type => type is { IsClass: true, IsAbstract: false })
.Where(IsValidatorImplementationType);
builder.Add(exports);
return builder;
}
/// <summary>
/// Searches for validator types within the specified assembly and adds them to the validator
/// configuration with the given service lifetime.
/// </summary>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <param name="assembly">The assembly to search for validator types.</param>
/// <param name="lifetime">The lifetime with which the validators are registered in the dependency injection container.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder SearchInAssembly(this IRequestValidatorBuilder builder, Assembly assembly, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
var exports = assembly.GetTypes()
.Where(type => type is { IsClass: true, IsAbstract: false })
.Where(IsValidatorImplementationType);
builder.Add(exports, lifetime);
return builder;
}
/// <summary>
/// Adds the specified type to the validator configuration for inspection.
/// </summary>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <param name="type">The type to be added to the validator configuration.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<RequestValidatorOptions>()
.Configure(options => options.Inspect([type]));
return builder;
}
/// <summary>
/// Adds the specified type to the validator configuration.
/// This also adds the type to the service collection with the specified lifetime,
/// allowing it to be resolved as a dependency.
/// </summary>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> used to configure the validators.</param>
/// <param name="type">The type to be added to the validator configuration.</param>
/// <param name="lifetime">The lifetime scope of the type in the service container.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<RequestValidatorOptions>()
.Configure(options => options.Inspect([type]));
builder.Services.Add(new ServiceDescriptor(type, type, lifetime));
return builder;
}
/// <summary>
/// Adds the specified collection of types to the validator configuration for inspection.
/// </summary>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <param name="types">The collection of types to be added to the validator configuration.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable<Type> types)
{
ArgumentNullException.ThrowIfNull(builder);
var typeList = types.ToList();
builder.Services.AddOptions<RequestValidatorOptions>()
.Configure(options => options.Inspect(typeList));
return builder;
}
/// <summary>
/// Adds the specified collection of types to the validator configuration for inspection.
/// This also adds the specified collection of types to the service collection with the specified lifetime,
/// allowing it to be resolved as a dependency.
/// </summary>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <param name="types">The collection of types to be added to the validator configuration.</param>
/// <param name="lifetime">The lifetime scope of the types in the service container.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable<Type> types, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
var typeList = types.ToList();
builder.Services.AddOptions<RequestValidatorOptions>()
.Configure(options => options.Inspect(typeList));
builder.Services.Add(typeList.Select(export => new ServiceDescriptor(export, export, lifetime)));
return builder;
}
/// <summary>
/// Adds the specified validator type to the validator configuration.
/// </summary>
/// <typeparam name="TValidator">The type of the validator to add.</typeparam>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder Add<TValidator>(this IRequestValidatorBuilder builder)
where TValidator : class, IValidator
{
return builder.Add(typeof(TValidator));
}
/// <summary>
/// Adds the specified validator type to the validator configuration with the specified lifetime.
/// </summary>
/// <typeparam name="TValidator">The type of the validator to add.</typeparam>
/// <param name="builder">The <see cref="IRequestValidatorBuilder"/> to configure.</param>
/// <param name="lifetime">The lifetime scope of the validator in the service container.</param>
/// <returns>The <see cref="IRequestValidatorBuilder"/> instance for further configuration.</returns>
public static IRequestValidatorBuilder Add<TValidator>(this IRequestValidatorBuilder builder, ServiceLifetime lifetime)
where TValidator : class, IValidator
{
return builder.Add(typeof(TValidator), lifetime);
}
private static bool IsValidatorImplementationType(Type type)
{
return type.GetInterfaces().Any(IsValidatorType);
}
}

View file

@ -0,0 +1,145 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections;
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation;
internal sealed class RequestValidatorOptions
{
private readonly List<Type> _search = [];
private readonly Lazy<TypeIndex> _validatorsTypeIndex;
public RequestValidatorOptions()
{
_validatorsTypeIndex = new Lazy<TypeIndex>(() => new ValidatorTypeIndex(_search.Distinct()));
}
public void Inspect(IEnumerable<Type> assembly)
{
if (_validatorsTypeIndex.IsValueCreated)
{
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
}
_search.AddRange(assembly);
}
public IEnumerable<IValidator<T>> GetValidators<T>(IServiceProvider services)
{
return _validatorsTypeIndex.Value.Resolve<IValidator<T>>(services);
}
private abstract class TypeIndex
{
private readonly ConcurrentDictionary<Type, Func<IServiceProvider, IEnumerable>> _cache = new();
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
protected readonly List<Type> _openTypeInfo = [];
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
{
foreach (var type in collection)
{
if (type.IsGenericTypeDefinition)
{
if (type.GetInterfaces().Any(predicate))
{
_openTypeInfo.Add(type);
}
}
else
{
foreach (var @interface in type.GetInterfaces().Where(predicate))
{
(_closedTypeInfo.TryGetValue(@interface, out var list) ? list : _closedTypeInfo[@interface] = []).Add(type);
}
}
}
}
public IEnumerable<T> Resolve<T>(IServiceProvider services)
{
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
}
protected abstract IReadOnlyList<Type> IsAssignableTo(Type type);
private Func<IServiceProvider, IEnumerable<T>> CreateResolverFactory<T>(Type @interface)
{
var list = IsAssignableTo(@interface);
return ResolverFactory;
IEnumerable<T> ResolverFactory(IServiceProvider services)
{
foreach (var type in list)
{
yield return (T)ActivatorUtilities.GetServiceOrCreateInstance(services, type);
}
}
}
}
internal static bool IsValidatorType(Type type)
{
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IValidator<>);
}
private sealed class ValidatorTypeIndex(IEnumerable<Type> collection)
: TypeIndex(collection, IsValidatorType)
{
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{
var result = new List<Type>();
foreach (var kvp in _closedTypeInfo)
{
if (@interface.IsAssignableFrom(kvp.Key))
{
result.AddRange(kvp.Value);
}
}
var validatedType = @interface.GetGenericArguments()[0];
foreach (var type in _openTypeInfo)
{
try
{
// open type case one: Validator<T> : IValidator<T>
// We try to close it with the validated type.
var impl = type.MakeGenericType(validatedType);
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
}
}
catch (ArgumentException)
{
}
try
{
// open type case two: Validator<T> : IValidator<Wrapper<T>>
// If the validated type is generic, we try to close the validator with the validated type's generic arguments.
if (validatedType.IsGenericType)
{
var impl = type.MakeGenericType(validatedType.GetGenericArguments());
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
}
}
}
catch (ArgumentException)
{
}
}
return result;
}
}
}

View file

@ -0,0 +1,49 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Validation;
/// <summary>
/// Provides extension methods for configuring and registering validator services in the <see cref="IServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds validator services to the specified <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The service collection to which the validator services will be added.</param>
/// <returns>An instance of <see cref="IRequestValidatorBuilder"/> to configure the validator registrations.</returns>
public static IRequestValidatorBuilder AddRequestValidation(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<RequestValidatorOptions>();
services.AddTransient<IValidator, DispatchingValidator>();
return new RequestValidatorBuilder(services);
}
/// <summary>
/// Adds validator services to the specified <see cref="IServiceCollection"/>
/// and configures them using the provided <see cref="Action{T}"/>.
/// </summary>
/// <param name="services">The service collection to which the validator services will be added.</param>
/// <param name="configure">A delegate to configure the validator builder.</param>
/// <returns>The service collection with the validator services added.</returns>
public static IServiceCollection AddRequestValidation(this IServiceCollection services, Action<IRequestValidatorBuilder> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
configure(services.AddRequestValidation());
return services;
}
private sealed class RequestValidatorBuilder(IServiceCollection services) : IRequestValidatorBuilder
{
public IServiceCollection Services { get; } = services;
}
}

View file

@ -0,0 +1,23 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation;
/// <summary>
/// Extension methods for <see cref="IValidator"/>.
/// </summary>
public static class ValidatorExtensions
{
/// <summary>
/// Executes the validation logic for a specified instance of type <typeparamref name="T"/> and returns the validation result.
/// </summary>
/// <param name="validator">The <see cref="IValidator"/> instance used to perform the validation.</param>
/// <param name="instance">The instance of type <typeparamref name="T"/> to validate.</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>
/// <returns>A <see cref="Validation"/> object containing the results of the validation, including any problems encountered.</returns>
public static Validation Validate<T>(this IValidator validator, T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary<object, object?>? items = null)
{
return validator.Validate(new ValidationContext<T>(instance, serviceProvider, items));
}
}

View file

@ -0,0 +1,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Geekeey.Request.Validation;
internal abstract class ValidatorInvoker
{
public abstract Validation Validate(ValidationContext context, IServiceProvider serviceProvider);
}
internal sealed class ValidatorInvoker<T> : ValidatorInvoker
{
public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<IOptions<RequestValidatorOptions>>().Value;
var validators = options.GetValidators<T>(serviceProvider);
var problems = new List<Problem>();
foreach (var validator in validators)
{
if (validator.Validate(context) is { IsValid: false, Problems: { } result })
{
problems.AddRange(result);
}
}
return new Validation(problems);
}
}