diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c26b92..240c251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) for structured responses - **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` 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] ### Added +- **request.validation:** Support `PropertyPath` JSON converter for string values and dictionary property names + ### Changed ### Removed [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 diff --git a/Directory.Build.props b/Directory.Build.props index 0a0e05e..86d2794 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - 1.0.0 + 2.1.0 + preview diff --git a/src/request.dispatcher.tests/StackTraceTests.cs b/src/request.dispatcher.tests/StackTraceTests.cs new file mode 100644 index 0000000..636dfb6 --- /dev/null +++ b/src/request.dispatcher.tests/StackTraceTests.cs @@ -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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(FailingScalarHandler)) + .Add(typeof(ScalarOpenBehavior<,>))); + + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var request = new FailingScalarRequest(); + + // Act + var exception = await Assert.ThrowsAsync(() => + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(FailingStreamHandler)) + .Add(typeof(StreamOpenBehavior<,>))); + + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var request = new FailingStreamRequest(); + + // Act + var exception = await Assert.ThrowsAsync(() => + 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+<>"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs index 3d90be3..d5ded39 100644 --- a/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs +++ b/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs @@ -1,14 +1,19 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Runtime.CompilerServices; + namespace Geekeey.Request.Dispatcher.Tests; public class StreamOpenBehavior(StreamTestTracker tracker) : IStreamRequestBehavior where TRequest : IStreamRequest { - public IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + public async IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, [EnumeratorCancellation] CancellationToken cancellationToken) { tracker.Executed = true; - return next(request, cancellationToken); + await foreach (var response in next(request, cancellationToken)) + { + yield return response; + } } } diff --git a/src/request.dispatcher/RequestDispatcher.cs b/src/request.dispatcher/RequestDispatcher.cs index c4083bd..2acb426 100644 --- a/src/request.dispatcher/RequestDispatcher.cs +++ b/src/request.dispatcher/RequestDispatcher.cs @@ -20,6 +20,12 @@ internal sealed class RequestDispatcher : IRequestDispatcher _serviceProvider = serviceProvider; } + public static void ClearCache(Type[]? _) + { + ScalarRequestHandlers.Clear(); + StreamRequestHandlers.Clear(); + } + public Task DispatchAsync(IScalarRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); diff --git a/src/request.dispatcher/ScalarRequestInvoker.cs b/src/request.dispatcher/ScalarRequestInvoker.cs index feb02bf..63de03e 100644 --- a/src/request.dispatcher/ScalarRequestInvoker.cs +++ b/src/request.dispatcher/ScalarRequestInvoker.cs @@ -1,6 +1,8 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Diagnostics; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -36,7 +38,7 @@ internal sealed class ScalarRequestInvoker : ScalarRequestI static ScalarHandlerDelegate Chain(ScalarHandlerDelegate next, IScalarRequestBehavior filter) { - return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); + return [StackTraceHidden] (req, ct) => filter.HandleAsync((TRequest)req, next, ct); } Task Head(IScalarRequest r, CancellationToken ct) diff --git a/src/request.dispatcher/StreamRequestInvoker.cs b/src/request.dispatcher/StreamRequestInvoker.cs index 6557146..882d538 100644 --- a/src/request.dispatcher/StreamRequestInvoker.cs +++ b/src/request.dispatcher/StreamRequestInvoker.cs @@ -1,6 +1,7 @@ // Copyright (c) The Geekeey Authors // SPDX-License-Identifier: EUPL-1.2 +using System.Diagnostics; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; @@ -41,7 +42,7 @@ internal sealed class StreamRequestInvoker : StreamRequestI static StreamHandlerDelegate Chain(StreamHandlerDelegate next, IStreamRequestBehavior filter) { - return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); + return [StackTraceHidden] (req, ct) => filter.HandleAsync((TRequest)req, next, ct); } IAsyncEnumerable Head(IStreamRequest r, CancellationToken ct) diff --git a/src/request.validation.tests/DependencyInjectionTests.cs b/src/request.validation.tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..ae98a7a --- /dev/null +++ b/src/request.validation.tests/DependencyInjectionTests.cs @@ -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(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + 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(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + 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(); + 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(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + + 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(); + + 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(); + + var teamWrapperResult = validator.Validate(new Wrapper()); + await Assert.That(teamWrapperResult.Problems).Count().IsEqualTo(1); + await Assert.That(teamWrapperResult.Problems[0].Message).IsEqualTo("ConstrainedWrapperValidator failure."); + + var personWrapperResult = validator.Validate(new Wrapper()); + 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(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + + 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(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + 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(ServiceLifetime.Transient); + builder.Add(ServiceLifetime.Transient); + }); + var provider = services.BuildServiceProvider(); + + var validator = provider.GetRequiredService(); + var dog = new Dog { Name = "", Breed = "" }; + var result = validator.Validate(new ValidationContext(dog)); + + await Assert.That(result.Problems).Count().IsEqualTo(2); + } +} diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs index bce4614..2551450 100644 --- a/src/request.validation.tests/PropertyPathTests.cs +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -12,6 +12,19 @@ internal sealed class PropertyPathTests PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private static readonly JsonSerializerOptions CamelCaseDictionaryKeyJsonOptions = new() + { + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task I_can_serialize_property_paths_as_json_strings_using_the_json_naming_policy() + { + var json = JsonSerializer.Serialize((PropertyPath)"Address.Street", CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ "\"address.street\""); + } + [Test] public async Task I_can_serialize_property_paths_using_the_json_naming_policy() { @@ -54,6 +67,32 @@ internal sealed class PropertyPathTests await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"matrix[1][2].value","severity":0,"message":"Value is required.","code":null,"attemptedValue":null}"""); } + [Test] + public async Task I_can_serialize_property_paths_as_dictionary_keys_using_the_dictionary_key_policy() + { + Dictionary errors = new() + { + ["Address.Street"] = "Street is required.", + }; + + var json = JsonSerializer.Serialize(errors, CamelCaseDictionaryKeyJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"address.street":"Street is required."}"""); + } + + [Test] + public async Task I_can_deserialize_property_paths_from_dictionary_keys() + { + var json = /*lang=json,strict*/ """{"Address.Street":"Street is required."}"""; + + var errors = JsonSerializer.Deserialize>(json); + + await Assert.That(errors).IsNotNull(); + await Assert.That(errors!.Count).IsEqualTo(1); + await Assert.That(errors.ContainsKey("Address.Street")).IsTrue(); + await Assert.That(errors["Address.Street"]).IsEqualTo("Street is required."); + } + [Test] public async Task I_can_iterate_and_index_segments() { diff --git a/src/request.validation.tests/_fixtures/AnotherPersonValidator.cs b/src/request.validation.tests/_fixtures/AnotherPersonValidator.cs new file mode 100644 index 0000000..be49f70 --- /dev/null +++ b/src/request.validation.tests/_fixtures/AnotherPersonValidator.cs @@ -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 +{ + public AnotherPersonValidator() + { + RuleFor(p => p.Name).Must(_ => false, "AnotherPersonValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/ConstrainedValidator.cs b/src/request.validation.tests/_fixtures/ConstrainedValidator.cs new file mode 100644 index 0000000..11e9833 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ConstrainedValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ConstrainedValidator : Validator where T : ICustomConstraint +{ + public ConstrainedValidator() + { + RuleFor(x => x).Must(_ => false, "ConstrainedValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs b/src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs new file mode 100644 index 0000000..4dd88e2 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ConstrainedWrapperValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ConstrainedWrapperValidator : Validator> where T : ICustomConstraint +{ + public ConstrainedWrapperValidator() + { + RuleFor(x => x).Must(_ => false, "ConstrainedWrapperValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/GenericValidator.cs b/src/request.validation.tests/_fixtures/GenericValidator.cs new file mode 100644 index 0000000..6c9254e --- /dev/null +++ b/src/request.validation.tests/_fixtures/GenericValidator.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class GenericValidator : Validator +{ + public GenericValidator() + { + RuleFor(x => x).Must(_ => false, "GenericValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/ICustomConstraint.cs b/src/request.validation.tests/_fixtures/ICustomConstraint.cs new file mode 100644 index 0000000..4b135a6 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ICustomConstraint.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal interface ICustomConstraint { } diff --git a/src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs b/src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs new file mode 100644 index 0000000..5a1f732 --- /dev/null +++ b/src/request.validation.tests/_fixtures/MultiInterfaceValidator.cs @@ -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, IValidator
+{ + public Validation Validate(Person instance) + { + return Validate(new ValidationContext(instance)); + } + + public Validation Validate(Address instance) + { + return Validate(new ValidationContext
(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([]); + } +} diff --git a/src/request.validation.tests/_fixtures/PersonValidator.cs b/src/request.validation.tests/_fixtures/PersonValidator.cs new file mode 100644 index 0000000..788fdea --- /dev/null +++ b/src/request.validation.tests/_fixtures/PersonValidator.cs @@ -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 +{ + public PersonValidator() + { + RuleFor(p => p.Name).Must(_ => false, "PersonValidator failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/PolymorphicTypes.cs b/src/request.validation.tests/_fixtures/PolymorphicTypes.cs new file mode 100644 index 0000000..fe060fa --- /dev/null +++ b/src/request.validation.tests/_fixtures/PolymorphicTypes.cs @@ -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 +{ + public EntityValidator() + { + RuleFor(x => x.Id).GreaterThan(0); + } +} + +internal sealed class AnimalValidator : Validator +{ + public AnimalValidator() + { + RuleFor(x => x.Name).NotEmpty(); + } +} + +internal sealed class DogValidator : Validator +{ + public DogValidator() + { + RuleFor(x => x.Breed).NotEmpty(); + } +} diff --git a/src/request.validation.tests/_fixtures/Team.cs b/src/request.validation.tests/_fixtures/Team.cs index 6b17c01..ce4e7b6 100644 --- a/src/request.validation.tests/_fixtures/Team.cs +++ b/src/request.validation.tests/_fixtures/Team.cs @@ -3,7 +3,7 @@ namespace Geekeey.Request.Validation.Tests; -internal sealed class Team +internal sealed class Team : ICustomConstraint { public IEnumerable Members { get; init; } = []; } diff --git a/src/request.validation.tests/_fixtures/Wrapper.cs b/src/request.validation.tests/_fixtures/Wrapper.cs new file mode 100644 index 0000000..ecb0feb --- /dev/null +++ b/src/request.validation.tests/_fixtures/Wrapper.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class Wrapper { } diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs new file mode 100644 index 0000000..3c3db7f --- /dev/null +++ b/src/request.validation/DispatchingValidator.cs @@ -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 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); + } +} diff --git a/src/request.validation/Geekeey.Request.Validation.csproj b/src/request.validation/Geekeey.Request.Validation.csproj index 098287f..33bea6b 100644 --- a/src/request.validation/Geekeey.Request.Validation.csproj +++ b/src/request.validation/Geekeey.Request.Validation.csproj @@ -28,4 +28,9 @@ + + + + + diff --git a/src/request.validation/IRequestValidatorBuilder.cs b/src/request.validation/IRequestValidatorBuilder.cs new file mode 100644 index 0000000..f699fbc --- /dev/null +++ b/src/request.validation/IRequestValidatorBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation; + +/// +/// Defines a builder for configuring validator registrations. +/// +public interface IRequestValidatorBuilder +{ + /// + /// Gets the service collection where the validators are registered. + /// + IServiceCollection Services { get; } +} diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs index 4bf0e62..4b9bd50 100644 --- a/src/request.validation/PropertyPath.cs +++ b/src/request.validation/PropertyPath.cs @@ -399,9 +399,26 @@ internal sealed class PropertyPathJsonConverter : JsonConverter throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); } + /// + public override PropertyPath ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.PropertyName) + { + return new PropertyPath(reader.GetString()!); + } + + throw new JsonException($"Expected {nameof(JsonTokenType.PropertyName)} but got {reader.TokenType}."); + } + /// public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); } + + /// + public override void WriteAsPropertyName(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) + { + writer.WritePropertyName(value.ToJsonName(options.DictionaryKeyPolicy)); + } } diff --git a/src/request.validation/RequestValidatorBuilderExtensions.cs b/src/request.validation/RequestValidatorBuilderExtensions.cs new file mode 100644 index 0000000..054da7c --- /dev/null +++ b/src/request.validation/RequestValidatorBuilderExtensions.cs @@ -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; + +/// +/// Provides extension methods for configuring +/// with additional capabilities such as searching and registering validators in assemblies or adding types directly. +/// +public static class RequestValidatorBuilderExtensions +{ + /// + /// Searches for validator types within the specified assembly and adds them to the validator + /// configuration. + /// + /// The to configure. + /// The assembly to search for validator types. + /// The instance for further configuration. + 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; + } + + /// + /// Searches for validator types within the specified assembly and adds them to the validator + /// configuration with the given service lifetime. + /// + /// The to configure. + /// The assembly to search for validator types. + /// The lifetime with which the validators are registered in the dependency injection container. + /// The instance for further configuration. + 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; + } + + /// + /// Adds the specified type to the validator configuration for inspection. + /// + /// The to configure. + /// The type to be added to the validator configuration. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([type])); + + return builder; + } + + /// + /// 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. + /// + /// The used to configure the validators. + /// The type to be added to the validator configuration. + /// The lifetime scope of the type in the service container. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([type])); + + builder.Services.Add(new ServiceDescriptor(type, type, lifetime)); + + return builder; + } + + /// + /// Adds the specified collection of types to the validator configuration for inspection. + /// + /// The to configure. + /// The collection of types to be added to the validator configuration. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable types) + { + ArgumentNullException.ThrowIfNull(builder); + + var typeList = types.ToList(); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(typeList)); + + return builder; + } + + /// + /// 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. + /// + /// The to configure. + /// The collection of types to be added to the validator configuration. + /// The lifetime scope of the types in the service container. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable types, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + var typeList = types.ToList(); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(typeList)); + + builder.Services.Add(typeList.Select(export => new ServiceDescriptor(export, export, lifetime))); + + return builder; + } + + /// + /// Adds the specified validator type to the validator configuration. + /// + /// The type of the validator to add. + /// The to configure. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder) + where TValidator : class, IValidator + { + return builder.Add(typeof(TValidator)); + } + + /// + /// Adds the specified validator type to the validator configuration with the specified lifetime. + /// + /// The type of the validator to add. + /// The to configure. + /// The lifetime scope of the validator in the service container. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(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); + } +} diff --git a/src/request.validation/RequestValidatorOptions.cs b/src/request.validation/RequestValidatorOptions.cs new file mode 100644 index 0000000..d7c5d75 --- /dev/null +++ b/src/request.validation/RequestValidatorOptions.cs @@ -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 _search = []; + private readonly Lazy _validatorsTypeIndex; + + public RequestValidatorOptions() + { + _validatorsTypeIndex = new Lazy(() => new ValidatorTypeIndex(_search.Distinct())); + } + + public void Inspect(IEnumerable assembly) + { + if (_validatorsTypeIndex.IsValueCreated) + { + throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); + } + + _search.AddRange(assembly); + } + + public IEnumerable> GetValidators(IServiceProvider services) + { + return _validatorsTypeIndex.Value.Resolve>(services); + } + + private abstract class TypeIndex + { + private readonly ConcurrentDictionary> _cache = new(); + + protected readonly Dictionary> _closedTypeInfo = []; + protected readonly List _openTypeInfo = []; + + protected TypeIndex(IEnumerable collection, Func 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 Resolve(IServiceProvider services) + { + return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(services); + } + + protected abstract IReadOnlyList IsAssignableTo(Type type); + + private Func> CreateResolverFactory(Type @interface) + { + var list = IsAssignableTo(@interface); + return ResolverFactory; + + IEnumerable 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 collection) + : TypeIndex(collection, IsValidatorType) + { + protected override IReadOnlyList IsAssignableTo(Type @interface) + { + var result = new List(); + + 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 : IValidator + // 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 : IValidator> + // 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; + } + } +} diff --git a/src/request.validation/ServiceCollectionExtensions.cs b/src/request.validation/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..52b47bf --- /dev/null +++ b/src/request.validation/ServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation; + +/// +/// Provides extension methods for configuring and registering validator services in the . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds validator services to the specified . + /// + /// The service collection to which the validator services will be added. + /// An instance of to configure the validator registrations. + public static IRequestValidatorBuilder AddRequestValidation(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + services.AddTransient(); + + return new RequestValidatorBuilder(services); + } + + /// + /// Adds validator services to the specified + /// and configures them using the provided . + /// + /// The service collection to which the validator services will be added. + /// A delegate to configure the validator builder. + /// The service collection with the validator services added. + public static IServiceCollection AddRequestValidation(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + configure(services.AddRequestValidation()); + + return services; + } + + private sealed class RequestValidatorBuilder(IServiceCollection services) : IRequestValidatorBuilder + { + public IServiceCollection Services { get; } = services; + } +} diff --git a/src/request.validation/ValidatorExtensions.cs b/src/request.validation/ValidatorExtensions.cs new file mode 100644 index 0000000..7f0c69c --- /dev/null +++ b/src/request.validation/ValidatorExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Extension methods for . +/// +public static class ValidatorExtensions +{ + /// + /// Executes the validation logic for a specified instance of type and returns the validation result. + /// + /// The instance used to perform the validation. + /// The instance of type to validate. + /// The service provider available for nested validator resolution. + /// Per-call state shared across nested validation operations. + /// A object containing the results of the validation, including any problems encountered. + public static Validation Validate(this IValidator validator, T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary? items = null) + { + return validator.Validate(new ValidationContext(instance, serviceProvider, items)); + } +} diff --git a/src/request.validation/ValidatorInvoker.cs b/src/request.validation/ValidatorInvoker.cs new file mode 100644 index 0000000..ad41030 --- /dev/null +++ b/src/request.validation/ValidatorInvoker.cs @@ -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 : ValidatorInvoker +{ + public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>().Value; + + var validators = options.GetValidators(serviceProvider); + + var problems = new List(); + + foreach (var validator in validators) + { + if (validator.Validate(context) is { IsValid: false, Problems: { } result }) + { + problems.AddRange(result); + } + } + + return new Validation(problems); + } +}