From bae9b2a5f1d8bc8cd3ec622898c86c36d8512db7 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 28 May 2026 22:06:18 +0200 Subject: [PATCH 1/7] chore: post release version changes --- Directory.Build.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 63c71c4..408380c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - 1.1.0 + 1.2.0 + preview From 4d33d5ab4c9447d1818252fcdea511674c53cef9 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Fri, 29 May 2026 22:53:58 +0200 Subject: [PATCH 2/7] feat: hide pipelines internals from stack trace --- CHANGELOG.md | 2 + .../StackTraceTests.cs | 73 +++++++++++++++++++ .../_fixtures/StreamOpenBehavior.cs | 9 ++- .../ScalarRequestInvoker.cs | 4 +- .../StreamRequestInvoker.cs | 3 +- 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/request.dispatcher.tests/StackTraceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da7e17..47e4306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- **request.dispatcher:** Hide pipeline internals in stack frames + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 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/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) From 36f1a9eb1ba5c774526f9e0d1c581e19fae1ac6c Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Fri, 29 May 2026 23:05:24 +0200 Subject: [PATCH 3/7] feat: rename validation builder for parity Rename extensions functions and `IValidatorBuilder` for parity with dispatcher options and builder. --- CHANGELOG.md | 1 + .../DependencyInjectionTests.cs | 18 +++--- .../DispatchingValidator.cs | 2 +- ...Builder.cs => IRequestValidatorBuilder.cs} | 2 +- ...s => RequestValidatorBuilderExtensions.cs} | 62 +++++++++---------- ...nOptions.cs => RequestValidatorOptions.cs} | 4 +- .../ServiceCollectionExtensions.cs | 14 ++--- 7 files changed, 52 insertions(+), 51 deletions(-) rename src/request.validation/{IValidatorBuilder.cs => IRequestValidatorBuilder.cs} (90%) rename src/request.validation/{ValidatorBuilderExtensions.cs => RequestValidatorBuilderExtensions.cs} (59%) rename src/request.validation/{ValidationOptions.cs => RequestValidatorOptions.cs} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e4306..640e8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed - **request.dispatcher:** Hide pipeline internals in stack frames +- **request.validation:** Rename `IValidatorBuilder` to `IRequestValidatorBuilder` incl. extensions methods ### Removed diff --git a/src/request.validation.tests/DependencyInjectionTests.cs b/src/request.validation.tests/DependencyInjectionTests.cs index d3cf625..ae98a7a 100644 --- a/src/request.validation.tests/DependencyInjectionTests.cs +++ b/src/request.validation.tests/DependencyInjectionTests.cs @@ -11,7 +11,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_single_validator() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); }); @@ -27,7 +27,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_multiple_validators() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); @@ -46,7 +46,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_open_generic_validator() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(typeof(GenericValidator<>), ServiceLifetime.Transient); }); @@ -63,7 +63,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_multi_interface_validator() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); }); @@ -84,7 +84,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_generic_validator_with_constraints() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(typeof(ConstrainedValidator<>), ServiceLifetime.Transient); }); @@ -104,7 +104,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_generic_wrapper_validator_with_constraints() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(typeof(ConstrainedWrapperValidator<>), ServiceLifetime.Transient); }); @@ -124,7 +124,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_resolve_aggregate_validator_directly() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); @@ -142,7 +142,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_select_validators_from_base_classes_and_interfaces() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); @@ -161,7 +161,7 @@ internal sealed class DependencyInjectionTests public async Task I_can_select_validators_polymorphically_based_on_the_instance_type() { var services = new ServiceCollection(); - services.AddValidation(builder => + services.AddRequestValidation(builder => { builder.Add(ServiceLifetime.Transient); builder.Add(ServiceLifetime.Transient); diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs index 4fe7bff..2bd17e6 100644 --- a/src/request.validation/DispatchingValidator.cs +++ b/src/request.validation/DispatchingValidator.cs @@ -54,7 +54,7 @@ internal sealed class DispatchingValidator : IValidator { public override Validation Validate(ValidationContext context, IServiceProvider serviceProvider) { - var options = serviceProvider.GetRequiredService>().Value; + var options = serviceProvider.GetRequiredService>().Value; var validators = options.GetValidators(serviceProvider); diff --git a/src/request.validation/IValidatorBuilder.cs b/src/request.validation/IRequestValidatorBuilder.cs similarity index 90% rename from src/request.validation/IValidatorBuilder.cs rename to src/request.validation/IRequestValidatorBuilder.cs index 17d01ca..f699fbc 100644 --- a/src/request.validation/IValidatorBuilder.cs +++ b/src/request.validation/IRequestValidatorBuilder.cs @@ -8,7 +8,7 @@ namespace Geekeey.Request.Validation; /// /// Defines a builder for configuring validator registrations. /// -public interface IValidatorBuilder +public interface IRequestValidatorBuilder { /// /// Gets the service collection where the validators are registered. diff --git a/src/request.validation/ValidatorBuilderExtensions.cs b/src/request.validation/RequestValidatorBuilderExtensions.cs similarity index 59% rename from src/request.validation/ValidatorBuilderExtensions.cs rename to src/request.validation/RequestValidatorBuilderExtensions.cs index b71c728..054da7c 100644 --- a/src/request.validation/ValidatorBuilderExtensions.cs +++ b/src/request.validation/RequestValidatorBuilderExtensions.cs @@ -6,24 +6,24 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using static Geekeey.Request.Validation.ValidationOptions; +using static Geekeey.Request.Validation.RequestValidatorOptions; namespace Geekeey.Request.Validation; /// -/// Provides extension methods for configuring +/// Provides extension methods for configuring /// with additional capabilities such as searching and registering validators in assemblies or adding types directly. /// -public static class ValidatorBuilderExtensions +public static class RequestValidatorBuilderExtensions { /// /// Searches for validator types within the specified assembly and adds them to the validator /// configuration. /// - /// The to configure. + /// The to configure. /// The assembly to search for validator types. - /// The instance for further configuration. - public static IValidatorBuilder SearchInAssembly(this IValidatorBuilder builder, Assembly assembly) + /// The instance for further configuration. + public static IRequestValidatorBuilder SearchInAssembly(this IRequestValidatorBuilder builder, Assembly assembly) { ArgumentNullException.ThrowIfNull(builder); @@ -40,11 +40,11 @@ public static class ValidatorBuilderExtensions /// Searches for validator types within the specified assembly and adds them to the validator /// configuration with the given service lifetime. /// - /// The to configure. + /// 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 IValidatorBuilder SearchInAssembly(this IValidatorBuilder builder, Assembly assembly, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder SearchInAssembly(this IRequestValidatorBuilder builder, Assembly assembly, ServiceLifetime lifetime) { ArgumentNullException.ThrowIfNull(builder); @@ -60,14 +60,14 @@ public static class ValidatorBuilderExtensions /// /// Adds the specified type to the validator configuration for inspection. /// - /// The to configure. + /// The to configure. /// The type to be added to the validator configuration. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, Type type) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type) { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect([type])); return builder; @@ -78,15 +78,15 @@ public static class ValidatorBuilderExtensions /// 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 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 IValidatorBuilder Add(this IValidatorBuilder builder, Type type, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, Type type, ServiceLifetime lifetime) { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect([type])); builder.Services.Add(new ServiceDescriptor(type, type, lifetime)); @@ -97,16 +97,16 @@ public static class ValidatorBuilderExtensions /// /// Adds the specified collection of types to the validator configuration for inspection. /// - /// The to configure. + /// The to configure. /// The collection of types to be added to the validator configuration. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable types) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, IEnumerable types) { ArgumentNullException.ThrowIfNull(builder); var typeList = types.ToList(); - builder.Services.AddOptions() + builder.Services.AddOptions() .Configure(options => options.Inspect(typeList)); return builder; @@ -117,17 +117,17 @@ public static class ValidatorBuilderExtensions /// 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 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 IValidatorBuilder Add(this IValidatorBuilder builder, IEnumerable types, ServiceLifetime lifetime) + /// 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() + builder.Services.AddOptions() .Configure(options => options.Inspect(typeList)); builder.Services.Add(typeList.Select(export => new ServiceDescriptor(export, export, lifetime))); @@ -139,9 +139,9 @@ public static class ValidatorBuilderExtensions /// 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 IValidatorBuilder Add(this IValidatorBuilder builder) + /// The to configure. + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder) where TValidator : class, IValidator { return builder.Add(typeof(TValidator)); @@ -151,10 +151,10 @@ public static class ValidatorBuilderExtensions /// Adds the specified validator type to the validator configuration with the specified lifetime. /// /// The type of the validator to add. - /// The to configure. + /// The to configure. /// The lifetime scope of the validator in the service container. - /// The instance for further configuration. - public static IValidatorBuilder Add(this IValidatorBuilder builder, ServiceLifetime lifetime) + /// The instance for further configuration. + public static IRequestValidatorBuilder Add(this IRequestValidatorBuilder builder, ServiceLifetime lifetime) where TValidator : class, IValidator { return builder.Add(typeof(TValidator), lifetime); diff --git a/src/request.validation/ValidationOptions.cs b/src/request.validation/RequestValidatorOptions.cs similarity index 97% rename from src/request.validation/ValidationOptions.cs rename to src/request.validation/RequestValidatorOptions.cs index b541e86..d7c5d75 100644 --- a/src/request.validation/ValidationOptions.cs +++ b/src/request.validation/RequestValidatorOptions.cs @@ -8,12 +8,12 @@ using Microsoft.Extensions.DependencyInjection; namespace Geekeey.Request.Validation; -internal sealed class ValidationOptions +internal sealed class RequestValidatorOptions { private readonly List _search = []; private readonly Lazy _validatorsTypeIndex; - public ValidationOptions() + public RequestValidatorOptions() { _validatorsTypeIndex = new Lazy(() => new ValidatorTypeIndex(_search.Distinct())); } diff --git a/src/request.validation/ServiceCollectionExtensions.cs b/src/request.validation/ServiceCollectionExtensions.cs index 8574f4f..52b47bf 100644 --- a/src/request.validation/ServiceCollectionExtensions.cs +++ b/src/request.validation/ServiceCollectionExtensions.cs @@ -14,15 +14,15 @@ 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 IValidatorBuilder AddValidation(this IServiceCollection services) + /// An instance of to configure the validator registrations. + public static IRequestValidatorBuilder AddRequestValidation(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.AddOptions(); + services.AddOptions(); services.AddTransient(); - return new ValidatorBuilder(services); + return new RequestValidatorBuilder(services); } /// @@ -32,17 +32,17 @@ public static class ServiceCollectionExtensions /// 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 AddValidation(this IServiceCollection services, Action configure) + public static IServiceCollection AddRequestValidation(this IServiceCollection services, Action configure) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); - configure(services.AddValidation()); + configure(services.AddRequestValidation()); return services; } - private sealed class ValidatorBuilder(IServiceCollection services) : IValidatorBuilder + private sealed class RequestValidatorBuilder(IServiceCollection services) : IRequestValidatorBuilder { public IServiceCollection Services { get; } = services; } From c674de31f708ea10d17a2c2a6d40626a7cf203a7 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 19:36:28 +0200 Subject: [PATCH 4/7] chore: rename internal types Rename internal types in the validation to be more consistent with other projects --- .../DispatchingValidator.cs | 32 +---------------- src/request.validation/ValidatorInvoker.cs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 src/request.validation/ValidatorInvoker.cs diff --git a/src/request.validation/DispatchingValidator.cs b/src/request.validation/DispatchingValidator.cs index 2bd17e6..3c3db7f 100644 --- a/src/request.validation/DispatchingValidator.cs +++ b/src/request.validation/DispatchingValidator.cs @@ -4,14 +4,11 @@ using System.Collections.Concurrent; using System.Reflection.Metadata; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - [assembly: MetadataUpdateHandler(typeof(Geekeey.Request.Validation.DispatchingValidator))] namespace Geekeey.Request.Validation; -internal sealed class DispatchingValidator : IValidator +internal sealed partial class DispatchingValidator : IValidator { private static readonly ConcurrentDictionary ValidatorsHandlers = new(); @@ -44,31 +41,4 @@ internal sealed class DispatchingValidator : IValidator return handler.Validate(context, _serviceProvider); } - - private abstract class ValidatorInvoker - { - public abstract Validation Validate(ValidationContext context, IServiceProvider serviceProvider); - } - - private 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); - } - } } 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); + } +} From a0d69fde41b09b21c30ea0b946f755bfbccd24de Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 19:53:00 +0200 Subject: [PATCH 5/7] feat: release 2.0.0 --- CHANGELOG.md | 15 ++++++++++++--- Directory.Build.props | 3 +-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 640e8a5..9f1288b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,17 +26,26 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **request.dispatcher:** Reset type caches when reloading assemblies - **request.validation:** Reset type caches when reloading assemblies -## [Unreleased] +## [2.0.0] - 2026-05-30 -### Added +## 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 + +### Changed + ### Removed [1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0 [1.1.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.1.0 -[Unreleased]: https://code.geekeey.de/geekeey/request/compare/1.1.0...HEAD +[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 408380c..aec0af3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,8 +9,7 @@ - 1.2.0 - preview + 2.0.0 From 29be774abc6c4d95e3d3ac857f9f855eb49af145 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 19:56:57 +0200 Subject: [PATCH 6/7] chore: post release version changes --- Directory.Build.props | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index aec0af3..86d2794 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,8 @@ - 2.0.0 + 2.1.0 + preview From eff887b49f4c9458fa4926e97e16cdee46ae3c71 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 30 May 2026 21:17:14 +0200 Subject: [PATCH 7/7] feat: add json property path supports for object keys --- CHANGELOG.md | 2 + .../PropertyPathTests.cs | 39 +++++++++++++++++++ src/request.validation/PropertyPath.cs | 17 ++++++++ 3 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1288b..240c251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ To have a consistent experience across all packages, some public interfaces have ### Added +- **request.validation:** Support `PropertyPath` JSON converter for string values and dictionary property names + ### Changed ### Removed diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs index bce4614..2551450 100644 --- a/src/request.validation.tests/PropertyPathTests.cs +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -12,6 +12,19 @@ internal sealed class PropertyPathTests PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private static readonly JsonSerializerOptions CamelCaseDictionaryKeyJsonOptions = new() + { + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task I_can_serialize_property_paths_as_json_strings_using_the_json_naming_policy() + { + var json = JsonSerializer.Serialize((PropertyPath)"Address.Street", CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ "\"address.street\""); + } + [Test] public async Task I_can_serialize_property_paths_using_the_json_naming_policy() { @@ -54,6 +67,32 @@ internal sealed class PropertyPathTests await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"matrix[1][2].value","severity":0,"message":"Value is required.","code":null,"attemptedValue":null}"""); } + [Test] + public async Task I_can_serialize_property_paths_as_dictionary_keys_using_the_dictionary_key_policy() + { + Dictionary errors = new() + { + ["Address.Street"] = "Street is required.", + }; + + var json = JsonSerializer.Serialize(errors, CamelCaseDictionaryKeyJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"address.street":"Street is required."}"""); + } + + [Test] + public async Task I_can_deserialize_property_paths_from_dictionary_keys() + { + var json = /*lang=json,strict*/ """{"Address.Street":"Street is required."}"""; + + var errors = JsonSerializer.Deserialize>(json); + + await Assert.That(errors).IsNotNull(); + await Assert.That(errors!.Count).IsEqualTo(1); + await Assert.That(errors.ContainsKey("Address.Street")).IsTrue(); + await Assert.That(errors["Address.Street"]).IsEqualTo("Street is required."); + } + [Test] public async Task I_can_iterate_and_index_segments() { diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs index 4bf0e62..4b9bd50 100644 --- a/src/request.validation/PropertyPath.cs +++ b/src/request.validation/PropertyPath.cs @@ -399,9 +399,26 @@ internal sealed class PropertyPathJsonConverter : JsonConverter throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); } + /// + public override PropertyPath ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.PropertyName) + { + return new PropertyPath(reader.GetString()!); + } + + throw new JsonException($"Expected {nameof(JsonTokenType.PropertyName)} but got {reader.TokenType}."); + } + /// public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); } + + /// + public override void WriteAsPropertyName(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) + { + writer.WritePropertyName(value.ToJsonName(options.DictionaryKeyPolicy)); + } }