From 97b739bce1e2f40ab7dc2bbef9b12539558dc112 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 14:09:48 +0000 Subject: [PATCH 01/14] Initial plan From d9dc8d637387472d307d9863c73b6dab62e464df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 14:22:09 +0000 Subject: [PATCH 02/14] Add MVC validation regression test --- .../MvcControllerValidationTest.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs diff --git a/test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs new file mode 100644 index 0000000..1894b03 --- /dev/null +++ b/test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; + +using Shouldly; + +namespace OptionalValues.DataAnnotations.Tests; + +public class MvcControllerValidationTest +{ + [Fact] + public void SpecifiedValue_ShouldBeIgnoredByMvcValidation() + { + ServiceProvider services = new ServiceCollection() + .AddControllers() + .Services + .BuildServiceProvider(); + + var metadataProvider = services.GetRequiredService(); + var metadata = metadataProvider.GetMetadataForType(typeof(OptionalValue)); + + var specifiedValue = metadata.Properties[nameof(OptionalValue.SpecifiedValue)]; + + specifiedValue.ShouldNotBeNull(); + specifiedValue.PropertyValidationFilter.ShouldNotBeNull(); + } +} + +public class MvcControllerValidationChildModel +{ + public string? Value { get; init; } +} From 5c8952d594b567e22808e96a9b14c457c6c74f58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 14:25:41 +0000 Subject: [PATCH 03/14] Ignore OptionalValue SpecifiedValue in MVC validation --- src/OptionalValues/MvcIgnoreValidationAttribute.cs | 9 +++++++++ src/OptionalValues/OptionalValue.cs | 1 + src/OptionalValues/OptionalValues.csproj | 4 ++++ 3 files changed, 14 insertions(+) create mode 100644 src/OptionalValues/MvcIgnoreValidationAttribute.cs diff --git a/src/OptionalValues/MvcIgnoreValidationAttribute.cs b/src/OptionalValues/MvcIgnoreValidationAttribute.cs new file mode 100644 index 0000000..382df94 --- /dev/null +++ b/src/OptionalValues/MvcIgnoreValidationAttribute.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace OptionalValues; + +[AttributeUsage(AttributeTargets.Property)] +internal sealed class MvcIgnoreValidationAttribute : Attribute, IPropertyValidationFilter +{ + public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) => false; +} diff --git a/src/OptionalValues/OptionalValue.cs b/src/OptionalValues/OptionalValue.cs index 5208888..d83dd9a 100644 --- a/src/OptionalValues/OptionalValue.cs +++ b/src/OptionalValues/OptionalValue.cs @@ -45,6 +45,7 @@ public OptionalValue(T value) /// The specified value of the OptionalValue. If is false, this will throw an . /// /// + [MvcIgnoreValidation] public T SpecifiedValue => IsSpecified ? Value! : throw new InvalidOperationException("Value is unspecified."); diff --git a/src/OptionalValues/OptionalValues.csproj b/src/OptionalValues/OptionalValues.csproj index f3fb206..25acfa1 100644 --- a/src/OptionalValues/OptionalValues.csproj +++ b/src/OptionalValues/OptionalValues.csproj @@ -20,4 +20,8 @@ + + + + \ No newline at end of file From ec7910dc5a47b260a029a98103942e978f448d0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 14:50:35 +0000 Subject: [PATCH 04/14] refactor: move MVC validation support to OptionalValues.Mvc --- OptionalValues.slnx | 2 ++ README.md | 21 ++++++++++++- .../MvcOptionsExtensions.cs | 25 ++++++++++++++++ ...OptionalValueValidationMetadataProvider.cs | 22 ++++++++++++++ .../OptionalValues.Mvc.csproj | 21 +++++++++++++ src/OptionalValues.Mvc/PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 6 ++++ .../MvcIgnoreValidationAttribute.cs | 9 ------ src/OptionalValues/OptionalValue.cs | 1 - src/OptionalValues/OptionalValues.csproj | 4 --- .../MvcControllerValidationTest.cs | 24 +++++++++++---- .../OptionalValues.Mvc.Tests.csproj | 30 +++++++++++++++++++ 12 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 src/OptionalValues.Mvc/MvcOptionsExtensions.cs create mode 100644 src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs create mode 100644 src/OptionalValues.Mvc/OptionalValues.Mvc.csproj create mode 100644 src/OptionalValues.Mvc/PublicAPI.Shipped.txt create mode 100644 src/OptionalValues.Mvc/PublicAPI.Unshipped.txt delete mode 100644 src/OptionalValues/MvcIgnoreValidationAttribute.cs rename test/{OptionalValues.DataAnnotations.Tests => OptionalValues.Mvc.Tests}/MvcControllerValidationTest.cs (51%) create mode 100644 test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj diff --git a/OptionalValues.slnx b/OptionalValues.slnx index e4203b9..e44e9eb 100644 --- a/OptionalValues.slnx +++ b/OptionalValues.slnx @@ -18,6 +18,7 @@ + @@ -29,6 +30,7 @@ Path="test/OptionalValues.DataAnnotations.Tests/OptionalValues.DataAnnotations.Tests.csproj" /> + diff --git a/README.md b/README.md index 7796293..25a72e8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A .NET library that provides an `OptionalValue` type, representing a value th | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | [OptionalValues](https://www.nuget.org/packages/OptionalValues) | [![NuGet](https://img.shields.io/nuget/v/OptionalValues.svg)](https://www.nuget.org/packages/OptionalValues) | | [OptionalValues.OpenApi](https://www.nuget.org/packages/OptionalValues.OpenApi) | [![NuGet](https://img.shields.io/nuget/v/OptionalValues.OpenApi.svg)](https://www.nuget.org/packages/OptionalValues.OpenApi) | +| [OptionalValues.Mvc](https://www.nuget.org/packages/OptionalValues.Mvc) | [![NuGet](https://img.shields.io/nuget/v/OptionalValues.Mvc.svg)](https://www.nuget.org/packages/OptionalValues.Mvc) | | [OptionalValues.Swashbuckle](https://www.nuget.org/packages/OptionalValues.Swashbuckle) | [![NuGet](https://img.shields.io/nuget/v/OptionalValues.Swashbuckle.svg)](https://www.nuget.org/packages/OptionalValues.Swashbuckle) | | [OptionalValues.NSwag](https://www.nuget.org/packages/OptionalValues.NSwag) | [![NuGet](https://img.shields.io/nuget/v/OptionalValues.NSwag.svg)](https://www.nuget.org/packages/OptionalValues.NSwag) | | [OptionalValues.DataAnnotations](https://www.nuget.org/packages/OptionalValues.DataAnnotations) | [![NuGet](https://img.shields.io/nuget/v/OptionalValues.DataAnnotations.svg)](https://www.nuget.org/packages/OptionalValues.DataAnnotations) | @@ -85,6 +86,7 @@ Optionally, install one or more extension packages: ```bash dotnet add package OptionalValues.Swashbuckle dotnet add package OptionalValues.NSwag +dotnet add package OptionalValues.Mvc dotnet add package OptionalValues.DataAnnotations dotnet add package OptionalValues.FluentValidation ``` @@ -352,7 +354,7 @@ public class Model The `OptionalValues` library integrates seamlessly with ASP.NET Core, allowing you to use `OptionalValue` properties in your API models. -You only need to configure the `JsonSerializerOptions` to include the `OptionalValue` converter: +Configure the `JsonSerializerOptions` to include the `OptionalValue` converter, and for MVC controller validation add `OptionalValues.Mvc`: ```csharp // For Minimal API @@ -364,6 +366,23 @@ builder.Services.ConfigureHttpJsonOptions(jsonOptions => // For MVC builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.AddOptionalValueSupport(); + }) + .AddMvcOptions(options => + { + options.AddOptionalValuesMvc(); + }); +``` + +Or configure the MVC options directly: + +```csharp +builder.Services.AddControllers(options => + { + options.AddOptionalValuesMvc(); + }) .AddJsonOptions(options => { options.JsonSerializerOptions.AddOptionalValueSupport(); diff --git a/src/OptionalValues.Mvc/MvcOptionsExtensions.cs b/src/OptionalValues.Mvc/MvcOptionsExtensions.cs new file mode 100644 index 0000000..9cc234a --- /dev/null +++ b/src/OptionalValues.Mvc/MvcOptionsExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace OptionalValues.Mvc; + +/// +/// Extension methods for to add support for validation metadata. +/// +public static class MvcOptionsExtensions +{ + /// + /// Adds validation metadata support for so MVC does not validate its child properties. + /// + /// The MVC options to configure. + public static void AddOptionalValuesMvc(this MvcOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.ModelMetadataDetailsProviders.OfType().Any()) + { + return; + } + + options.ModelMetadataDetailsProviders.Add(new OptionalValueValidationMetadataProvider()); + } +} diff --git a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs new file mode 100644 index 0000000..e35c37a --- /dev/null +++ b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace OptionalValues.Mvc; + +/// +/// Provides MVC validation metadata for so child validation is skipped. +/// +public sealed class OptionalValueValidationMetadataProvider : IValidationMetadataProvider +{ + /// + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (!OptionalValue.IsOptionalValueType(context.Key.ModelType)) + { + return; + } + + context.ValidationMetadata.ValidateChildren = false; + } +} diff --git a/src/OptionalValues.Mvc/OptionalValues.Mvc.csproj b/src/OptionalValues.Mvc/OptionalValues.Mvc.csproj new file mode 100644 index 0000000..1fc287c --- /dev/null +++ b/src/OptionalValues.Mvc/OptionalValues.Mvc.csproj @@ -0,0 +1,21 @@ + + + + net8.0;net9.0;net10.0 + + + + OptionalValues.Mvc + MVC validation metadata support for OptionalValues. + $(CommonPackageTags) aspnetcore mvc validation + + + + + + + + + + + diff --git a/src/OptionalValues.Mvc/PublicAPI.Shipped.txt b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt new file mode 100644 index 0000000..ab058de --- /dev/null +++ b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..a7f58a8 --- /dev/null +++ b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +OptionalValues.Mvc.MvcOptionsExtensions +static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValuesMvc(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void +OptionalValues.Mvc.OptionalValueValidationMetadataProvider +OptionalValues.Mvc.OptionalValueValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void +OptionalValues.Mvc.OptionalValueValidationMetadataProvider.OptionalValueValidationMetadataProvider() -> void diff --git a/src/OptionalValues/MvcIgnoreValidationAttribute.cs b/src/OptionalValues/MvcIgnoreValidationAttribute.cs deleted file mode 100644 index 382df94..0000000 --- a/src/OptionalValues/MvcIgnoreValidationAttribute.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; - -namespace OptionalValues; - -[AttributeUsage(AttributeTargets.Property)] -internal sealed class MvcIgnoreValidationAttribute : Attribute, IPropertyValidationFilter -{ - public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) => false; -} diff --git a/src/OptionalValues/OptionalValue.cs b/src/OptionalValues/OptionalValue.cs index d83dd9a..5208888 100644 --- a/src/OptionalValues/OptionalValue.cs +++ b/src/OptionalValues/OptionalValue.cs @@ -45,7 +45,6 @@ public OptionalValue(T value) /// The specified value of the OptionalValue. If is false, this will throw an . /// /// - [MvcIgnoreValidation] public T SpecifiedValue => IsSpecified ? Value! : throw new InvalidOperationException("Value is unspecified."); diff --git a/src/OptionalValues/OptionalValues.csproj b/src/OptionalValues/OptionalValues.csproj index 25acfa1..f3fb206 100644 --- a/src/OptionalValues/OptionalValues.csproj +++ b/src/OptionalValues/OptionalValues.csproj @@ -20,8 +20,4 @@ - - - - \ No newline at end of file diff --git a/test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs similarity index 51% rename from test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs rename to test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index 1894b03..dcea308 100644 --- a/test/OptionalValues.DataAnnotations.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -1,27 +1,41 @@ +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.DependencyInjection; using Shouldly; -namespace OptionalValues.DataAnnotations.Tests; +namespace OptionalValues.Mvc.Tests; public class MvcControllerValidationTest { [Fact] - public void SpecifiedValue_ShouldBeIgnoredByMvcValidation() + public void AddOptionalValuesMvc_ShouldDisableChildValidationForOptionalValueTypes() { ServiceProvider services = new ServiceCollection() - .AddControllers() + .AddControllers(options => options.AddOptionalValuesMvc()) .Services .BuildServiceProvider(); var metadataProvider = services.GetRequiredService(); var metadata = metadataProvider.GetMetadataForType(typeof(OptionalValue)); - var specifiedValue = metadata.Properties[nameof(OptionalValue.SpecifiedValue)]; + metadata.ValidateChildren.ShouldBeFalse(); specifiedValue.ShouldNotBeNull(); - specifiedValue.PropertyValidationFilter.ShouldNotBeNull(); + specifiedValue.PropertyValidationFilter.ShouldBeNull(); + } + + [Fact] + public void AddOptionalValuesMvc_ShouldOnlyRegisterProviderOnce() + { + var options = new MvcOptions(); + + options.AddOptionalValuesMvc(); + options.AddOptionalValuesMvc(); + + options.ModelMetadataDetailsProviders + .OfType() + .ShouldHaveSingleItem(); } } diff --git a/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj new file mode 100644 index 0000000..a8e9ccc --- /dev/null +++ b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + \ No newline at end of file From 2b9324dc5dc763a6681ded278c2d02f09c1fd9ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 15:05:47 +0000 Subject: [PATCH 05/14] fix: align MVC extension naming and add integration test --- README.md | 4 +- .../MvcOptionsExtensions.cs | 2 +- .../PublicAPI.Unshipped.txt | 2 +- .../MvcControllerValidationTest.cs | 83 +++++++++++++++++-- .../OptionalValues.Mvc.Tests.csproj | 4 + 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 25a72e8..f707a77 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ builder.Services.AddControllers() }) .AddMvcOptions(options => { - options.AddOptionalValuesMvc(); + options.AddOptionalValueSupport(); }); ``` @@ -381,7 +381,7 @@ Or configure the MVC options directly: ```csharp builder.Services.AddControllers(options => { - options.AddOptionalValuesMvc(); + options.AddOptionalValueSupport(); }) .AddJsonOptions(options => { diff --git a/src/OptionalValues.Mvc/MvcOptionsExtensions.cs b/src/OptionalValues.Mvc/MvcOptionsExtensions.cs index 9cc234a..69e9597 100644 --- a/src/OptionalValues.Mvc/MvcOptionsExtensions.cs +++ b/src/OptionalValues.Mvc/MvcOptionsExtensions.cs @@ -11,7 +11,7 @@ public static class MvcOptionsExtensions /// Adds validation metadata support for so MVC does not validate its child properties. /// /// The MVC options to configure. - public static void AddOptionalValuesMvc(this MvcOptions options) + public static void AddOptionalValueSupport(this MvcOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt index a7f58a8..f1b0261 100644 --- a/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt +++ b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt @@ -1,6 +1,6 @@ #nullable enable OptionalValues.Mvc.MvcOptionsExtensions -static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValuesMvc(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void +static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void OptionalValues.Mvc.OptionalValueValidationMetadataProvider OptionalValues.Mvc.OptionalValueValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void OptionalValues.Mvc.OptionalValueValidationMetadataProvider.OptionalValueValidationMetadataProvider() -> void diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index dcea308..31ef7b7 100644 --- a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -1,6 +1,19 @@ +using System.Net; +using System.Text; + +#if NET10_0_OR_GREATER +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +#endif using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; +#if NET10_0_OR_GREATER +using Microsoft.AspNetCore.TestHost; +#endif using Microsoft.Extensions.DependencyInjection; +#if NET10_0_OR_GREATER +using Microsoft.Extensions.Hosting; +#endif using Shouldly; @@ -9,10 +22,10 @@ namespace OptionalValues.Mvc.Tests; public class MvcControllerValidationTest { [Fact] - public void AddOptionalValuesMvc_ShouldDisableChildValidationForOptionalValueTypes() + public void AddOptionalValueSupport_ShouldDisableChildValidationForOptionalValueTypes() { ServiceProvider services = new ServiceCollection() - .AddControllers(options => options.AddOptionalValuesMvc()) + .AddControllers(options => options.AddOptionalValueSupport()) .Services .BuildServiceProvider(); @@ -26,12 +39,12 @@ public void AddOptionalValuesMvc_ShouldDisableChildValidationForOptionalValueTyp } [Fact] - public void AddOptionalValuesMvc_ShouldOnlyRegisterProviderOnce() + public void AddOptionalValueSupport_ShouldOnlyRegisterProviderOnce() { var options = new MvcOptions(); - options.AddOptionalValuesMvc(); - options.AddOptionalValuesMvc(); + options.AddOptionalValueSupport(); + options.AddOptionalValueSupport(); options.ModelMetadataDetailsProviders .OfType() @@ -39,7 +52,67 @@ public void AddOptionalValuesMvc_ShouldOnlyRegisterProviderOnce() } } +#if NET10_0_OR_GREATER +public class MvcControllerIntegrationValidationTest : IAsyncLifetime +{ + private WebApplication? _app; + private HttpClient? _client; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddControllers(options => options.AddOptionalValueSupport()) + .AddApplicationPart(typeof(ValidationController).Assembly) + .AddJsonOptions(options => options.JsonSerializerOptions.AddOptionalValueSupport()); + + builder.WebHost.UseTestServer(); + + _app = builder.Build(); + _app.MapControllers(); + + await _app.StartAsync(); + _client = _app.GetTestClient(); + } + + public async Task DisposeAsync() + { + if (_app != null) + { + await _app.DisposeAsync(); + } + + _client?.Dispose(); + } + + [Fact] + public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() + { + var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } +} +#endif + +public class MvcControllerValidationRequestModel +{ + public OptionalValue Child { get; init; } +} + public class MvcControllerValidationChildModel { public string? Value { get; init; } } + +#if NET10_0_OR_GREATER +[ApiController] +[Route("validation")] +public class ValidationController : ControllerBase +{ + [HttpPost] + public IActionResult Post(MvcControllerValidationRequestModel model) => Ok(model); +} +#endif diff --git a/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj index a8e9ccc..884bdb5 100644 --- a/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj +++ b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj @@ -19,6 +19,10 @@ + + + + From 34317623ddb561998b34f7537f77c512b4cab41a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 15:06:56 +0000 Subject: [PATCH 06/14] test: add specified MVC integration coverage --- .../MvcControllerValidationTest.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index 31ef7b7..dcc55e7 100644 --- a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -94,6 +94,16 @@ public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() response.StatusCode.ShouldBe(HttpStatusCode.OK); } + + [Fact] + public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() + { + var content = new StringContent("""{"child":{"value":"present"}}""", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } } #endif From 3166c89fcea8886c3d418d6b05e68faa4a7106b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 17:15:46 +0000 Subject: [PATCH 07/14] fix: restore MVC validation coverage across target frameworks --- .../MvcOptionsExtensions.cs | 2 +- ...tionalValueSpecifiedValueModelValidator.cs | 53 +++++++++++++++++ ...OptionalValueValidationMetadataProvider.cs | 5 ++ .../MvcControllerValidationTest.cs | 57 +++++++++++++++---- .../OptionalValues.Mvc.Tests.csproj | 13 ++++- 5 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs diff --git a/src/OptionalValues.Mvc/MvcOptionsExtensions.cs b/src/OptionalValues.Mvc/MvcOptionsExtensions.cs index 69e9597..e00b459 100644 --- a/src/OptionalValues.Mvc/MvcOptionsExtensions.cs +++ b/src/OptionalValues.Mvc/MvcOptionsExtensions.cs @@ -8,7 +8,7 @@ namespace OptionalValues.Mvc; public static class MvcOptionsExtensions { /// - /// Adds validation metadata support for so MVC does not validate its child properties. + /// Adds validation metadata support for . /// /// The MVC options to configure. public static void AddOptionalValueSupport(this MvcOptions options) diff --git a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs new file mode 100644 index 0000000..664da63 --- /dev/null +++ b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace OptionalValues.Mvc; + +internal sealed class OptionalValueSpecifiedValueModelValidator : IModelValidator +{ + internal static OptionalValueSpecifiedValueModelValidator Instance { get; } = new(); + + public IEnumerable Validate(ModelValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var model = context.Model; + if (model is null) + { + return []; + } + + Type modelType = model.GetType(); + if (!OptionalValue.IsOptionalValueType(modelType)) + { + return []; + } + + var isSpecifiedProperty = modelType.GetProperty(nameof(OptionalValue.IsSpecified)); + if (isSpecifiedProperty?.GetValue(model) is not true) + { + return []; + } + + var value = modelType.GetProperty(nameof(OptionalValue.Value))?.GetValue(model); + if (value is null) + { + return []; + } + + var validationResults = new List(); + var validationContext = new ValidationContext( + value, + context.ActionContext.HttpContext.RequestServices, + items: null); + + Validator.TryValidateObject(value, validationContext, validationResults, validateAllProperties: true); + + return validationResults.SelectMany(static result => + { + var memberNames = result.MemberNames.DefaultIfEmpty(string.Empty).ToArray(); + return memberNames.Select(memberName => new ModelValidationResult(memberName, result.ErrorMessage ?? string.Empty)); + }); + } +} diff --git a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs index e35c37a..b1f402f 100644 --- a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs +++ b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs @@ -18,5 +18,10 @@ public void CreateValidationMetadata(ValidationMetadataProviderContext context) } context.ValidationMetadata.ValidateChildren = false; + + if (!context.ValidationMetadata.ValidatorMetadata.Contains(OptionalValueSpecifiedValueModelValidator.Instance)) + { + context.ValidationMetadata.ValidatorMetadata.Add(OptionalValueSpecifiedValueModelValidator.Instance); + } } } diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index dcc55e7..8a94902 100644 --- a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -1,20 +1,18 @@ +using System.ComponentModel.DataAnnotations; using System.Net; +using System.Net.Http.Json; using System.Text; -#if NET10_0_OR_GREATER using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -#endif +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -#if NET10_0_OR_GREATER using Microsoft.AspNetCore.TestHost; -#endif using Microsoft.Extensions.DependencyInjection; -#if NET10_0_OR_GREATER using Microsoft.Extensions.Hosting; -#endif +using OptionalValues.DataAnnotations; using Shouldly; namespace OptionalValues.Mvc.Tests; @@ -52,7 +50,6 @@ public void AddOptionalValueSupport_ShouldOnlyRegisterProviderOnce() } } -#if NET10_0_OR_GREATER public class MvcControllerIntegrationValidationTest : IAsyncLifetime { private WebApplication? _app; @@ -88,7 +85,7 @@ public async Task DisposeAsync() [Fact] public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() { - var content = new StringContent("{}", Encoding.UTF8, "application/json"); + var content = new StringContent("""{"requiredField":null}""", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); @@ -98,26 +95,63 @@ public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() [Fact] public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() { - var content = new StringContent("""{"child":{"value":"present"}}""", Encoding.UTF8, "application/json"); + var content = new StringContent("""{"name":"short","requiredField":"present","child":{"value":"valid"}}""", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); response.StatusCode.ShouldBe(HttpStatusCode.OK); } + + [Fact] + public async Task InvalidOptionalValueDataAnnotations_ShouldFail_ControllerValidation() + { + var content = new StringContent("""{"name":"toolong","requiredField":"present"}""", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey(nameof(MvcControllerValidationRequestModel.Name)); + } + + [Fact] + public async Task InvalidChildDataAnnotations_ShouldFail_ControllerValidation() + { + var content = new StringContent("""{"requiredField":"present","child":{"value":"toolong"}}""", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey($"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Value)}"); + } } -#endif public class MvcControllerValidationRequestModel { + [OptionalStringLength(5)] + public OptionalValue Name { get; init; } + + [Specified] + public OptionalValue RequiredField { get; init; } + public OptionalValue Child { get; init; } } public class MvcControllerValidationChildModel { + [StringLength(5)] public string? Value { get; init; } } -#if NET10_0_OR_GREATER [ApiController] [Route("validation")] public class ValidationController : ControllerBase @@ -125,4 +159,3 @@ public class ValidationController : ControllerBase [HttpPost] public IActionResult Post(MvcControllerValidationRequestModel model) => Ok(model); } -#endif diff --git a/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj index 884bdb5..f48773c 100644 --- a/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj +++ b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj @@ -19,8 +19,16 @@ + + + + + + + + - + @@ -28,7 +36,8 @@ + - \ No newline at end of file + From 2c9234f863522967f88a3df02e2cda523bb8855a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 17:18:25 +0000 Subject: [PATCH 08/14] test: validate MVC support across net8-net10 --- .../OptionalValueSpecifiedValueModelValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs index 664da63..ec07478 100644 --- a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs +++ b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs @@ -46,7 +46,7 @@ public IEnumerable Validate(ModelValidationContext contex return validationResults.SelectMany(static result => { - var memberNames = result.MemberNames.DefaultIfEmpty(string.Empty).ToArray(); + var memberNames = result.MemberNames.DefaultIfEmpty(string.Empty); return memberNames.Select(memberName => new ModelValidationResult(memberName, result.ErrorMessage ?? string.Empty)); }); } From 5b9b155fd26758af4f907dc4235d4725a49fb872 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 17:20:41 +0000 Subject: [PATCH 09/14] perf: cache optional value validator accessors --- .../OptionalValueSpecifiedValueModelValidator.cs | 15 ++++++++++++--- .../MvcControllerValidationTest.cs | 11 ++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs index ec07478..45733f9 100644 --- a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs +++ b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; +using System.Reflection; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; @@ -6,6 +8,8 @@ namespace OptionalValues.Mvc; internal sealed class OptionalValueSpecifiedValueModelValidator : IModelValidator { + private static readonly ConcurrentDictionary AccessorCache = new(); + internal static OptionalValueSpecifiedValueModelValidator Instance { get; } = new(); public IEnumerable Validate(ModelValidationContext context) @@ -24,13 +28,16 @@ public IEnumerable Validate(ModelValidationContext contex return []; } - var isSpecifiedProperty = modelType.GetProperty(nameof(OptionalValue.IsSpecified)); - if (isSpecifiedProperty?.GetValue(model) is not true) + Accessors accessors = AccessorCache.GetOrAdd(modelType, static type => new( + type.GetProperty(nameof(OptionalValue.IsSpecified)) ?? throw new InvalidOperationException(), + type.GetProperty(nameof(OptionalValue.Value)) ?? throw new InvalidOperationException())); + + if (accessors.IsSpecifiedProperty.GetValue(model) is not true) { return []; } - var value = modelType.GetProperty(nameof(OptionalValue.Value))?.GetValue(model); + var value = accessors.ValueProperty.GetValue(model); if (value is null) { return []; @@ -50,4 +57,6 @@ public IEnumerable Validate(ModelValidationContext contex return memberNames.Select(memberName => new ModelValidationResult(memberName, result.ErrorMessage ?? string.Empty)); }); } + + private readonly record struct Accessors(PropertyInfo IsSpecifiedProperty, PropertyInfo ValueProperty); } diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index 8a94902..6beeee7 100644 --- a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -85,7 +85,7 @@ public async Task DisposeAsync() [Fact] public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() { - var content = new StringContent("""{"requiredField":null}""", Encoding.UTF8, "application/json"); + var content = new StringContent("{}", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); @@ -95,7 +95,7 @@ public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() [Fact] public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() { - var content = new StringContent("""{"name":"short","requiredField":"present","child":{"value":"valid"}}""", Encoding.UTF8, "application/json"); + var content = new StringContent("""{"name":"short","child":{"value":"valid"}}""", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); @@ -105,7 +105,7 @@ public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() [Fact] public async Task InvalidOptionalValueDataAnnotations_ShouldFail_ControllerValidation() { - var content = new StringContent("""{"name":"toolong","requiredField":"present"}""", Encoding.UTF8, "application/json"); + var content = new StringContent("""{"name":"toolong"}""", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); @@ -121,7 +121,7 @@ public async Task InvalidOptionalValueDataAnnotations_ShouldFail_ControllerValid [Fact] public async Task InvalidChildDataAnnotations_ShouldFail_ControllerValidation() { - var content = new StringContent("""{"requiredField":"present","child":{"value":"toolong"}}""", Encoding.UTF8, "application/json"); + var content = new StringContent("""{"child":{"value":"toolong"}}""", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); @@ -140,9 +140,6 @@ public class MvcControllerValidationRequestModel [OptionalStringLength(5)] public OptionalValue Name { get; init; } - [Specified] - public OptionalValue RequiredField { get; init; } - public OptionalValue Child { get; init; } } From 9833e223c29c21da5482ae499100fb6ac36cc130 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 18:39:00 +0000 Subject: [PATCH 10/14] fix: preserve recursive MVC validation --- .../NeverValidatePropertyFilter.cs | 10 +++ ...tionalValueSpecifiedValueModelValidator.cs | 62 ------------------- ...OptionalValueValidationMetadataProvider.cs | 15 +++-- .../MvcControllerValidationTest.cs | 47 ++++++++++++-- 4 files changed, 62 insertions(+), 72 deletions(-) create mode 100644 src/OptionalValues.Mvc/NeverValidatePropertyFilter.cs delete mode 100644 src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs diff --git a/src/OptionalValues.Mvc/NeverValidatePropertyFilter.cs b/src/OptionalValues.Mvc/NeverValidatePropertyFilter.cs new file mode 100644 index 0000000..8607209 --- /dev/null +++ b/src/OptionalValues.Mvc/NeverValidatePropertyFilter.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace OptionalValues.Mvc; + +internal sealed class NeverValidatePropertyFilter : IPropertyValidationFilter +{ + internal static NeverValidatePropertyFilter Instance { get; } = new(); + + public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) => false; +} diff --git a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs b/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs deleted file mode 100644 index 45733f9..0000000 --- a/src/OptionalValues.Mvc/OptionalValueSpecifiedValueModelValidator.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Concurrent; -using System.ComponentModel.DataAnnotations; -using System.Reflection; - -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; - -namespace OptionalValues.Mvc; - -internal sealed class OptionalValueSpecifiedValueModelValidator : IModelValidator -{ - private static readonly ConcurrentDictionary AccessorCache = new(); - - internal static OptionalValueSpecifiedValueModelValidator Instance { get; } = new(); - - public IEnumerable Validate(ModelValidationContext context) - { - ArgumentNullException.ThrowIfNull(context); - - var model = context.Model; - if (model is null) - { - return []; - } - - Type modelType = model.GetType(); - if (!OptionalValue.IsOptionalValueType(modelType)) - { - return []; - } - - Accessors accessors = AccessorCache.GetOrAdd(modelType, static type => new( - type.GetProperty(nameof(OptionalValue.IsSpecified)) ?? throw new InvalidOperationException(), - type.GetProperty(nameof(OptionalValue.Value)) ?? throw new InvalidOperationException())); - - if (accessors.IsSpecifiedProperty.GetValue(model) is not true) - { - return []; - } - - var value = accessors.ValueProperty.GetValue(model); - if (value is null) - { - return []; - } - - var validationResults = new List(); - var validationContext = new ValidationContext( - value, - context.ActionContext.HttpContext.RequestServices, - items: null); - - Validator.TryValidateObject(value, validationContext, validationResults, validateAllProperties: true); - - return validationResults.SelectMany(static result => - { - var memberNames = result.MemberNames.DefaultIfEmpty(string.Empty); - return memberNames.Select(memberName => new ModelValidationResult(memberName, result.ErrorMessage ?? string.Empty)); - }); - } - - private readonly record struct Accessors(PropertyInfo IsSpecifiedProperty, PropertyInfo ValueProperty); -} diff --git a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs index b1f402f..16cd8f9 100644 --- a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs +++ b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs @@ -3,7 +3,7 @@ namespace OptionalValues.Mvc; /// -/// Provides MVC validation metadata for so child validation is skipped. +/// Provides MVC validation metadata for . /// public sealed class OptionalValueValidationMetadataProvider : IValidationMetadataProvider { @@ -12,16 +12,19 @@ public void CreateValidationMetadata(ValidationMetadataProviderContext context) { ArgumentNullException.ThrowIfNull(context); - if (!OptionalValue.IsOptionalValueType(context.Key.ModelType)) + if (context.Key.ContainerType is null || !OptionalValue.IsOptionalValueType(context.Key.ContainerType)) { return; } - context.ValidationMetadata.ValidateChildren = false; - - if (!context.ValidationMetadata.ValidatorMetadata.Contains(OptionalValueSpecifiedValueModelValidator.Instance)) + switch (context.Key.Name) { - context.ValidationMetadata.ValidatorMetadata.Add(OptionalValueSpecifiedValueModelValidator.Instance); + case nameof(OptionalValue.Value): + context.ValidationMetadata.ValidationModelName = string.Empty; + break; + default: + context.ValidationMetadata.PropertyValidationFilter = NeverValidatePropertyFilter.Instance; + break; } } } diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index 6beeee7..ed51dbf 100644 --- a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -20,7 +20,7 @@ namespace OptionalValues.Mvc.Tests; public class MvcControllerValidationTest { [Fact] - public void AddOptionalValueSupport_ShouldDisableChildValidationForOptionalValueTypes() + public void AddOptionalValueSupport_ShouldConfigureOptionalValueValidationMetadata() { ServiceProvider services = new ServiceCollection() .AddControllers(options => options.AddOptionalValueSupport()) @@ -29,11 +29,17 @@ public void AddOptionalValueSupport_ShouldDisableChildValidationForOptionalValue var metadataProvider = services.GetRequiredService(); var metadata = metadataProvider.GetMetadataForType(typeof(OptionalValue)); + var value = metadata.Properties[nameof(OptionalValue.Value)]; var specifiedValue = metadata.Properties[nameof(OptionalValue.SpecifiedValue)]; + var isSpecified = metadata.Properties[nameof(OptionalValue.IsSpecified)]; - metadata.ValidateChildren.ShouldBeFalse(); + metadata.ValidateChildren.ShouldBeTrue(); + value.ShouldNotBeNull(); + value.PropertyValidationFilter.ShouldBeNull(); specifiedValue.ShouldNotBeNull(); - specifiedValue.PropertyValidationFilter.ShouldBeNull(); + specifiedValue.PropertyValidationFilter.ShouldNotBeNull(); + isSpecified.ShouldNotBeNull(); + isSpecified.PropertyValidationFilter.ShouldNotBeNull(); } [Fact] @@ -95,7 +101,7 @@ public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() [Fact] public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() { - var content = new StringContent("""{"name":"short","child":{"value":"valid"}}""", Encoding.UTF8, "application/json"); + var content = new StringContent("""{"name":"short","child":{"value":"valid","nested":{"grandchild":{"value":"valid"}}}}""", Encoding.UTF8, "application/json"); HttpResponseMessage response = await _client!.PostAsync("/validation", content); @@ -133,6 +139,26 @@ public async Task InvalidChildDataAnnotations_ShouldFail_ControllerValidation() problemDetails.ShouldNotBeNull(); problemDetails.Errors.ShouldContainKey($"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Value)}"); } + + [Fact] + public async Task InvalidNestedChildDataAnnotations_ShouldFail_ControllerValidation() + { + var content = new StringContent( + """{"child":{"value":"valid","nested":{"grandchild":{"value":"toolong"}}}}""", + Encoding.UTF8, + "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey( + $"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Nested)}.{nameof(MvcControllerValidationNestedChildModel.Grandchild)}.{nameof(MvcControllerValidationGrandchildModel.Value)}"); + } } public class MvcControllerValidationRequestModel @@ -144,6 +170,19 @@ public class MvcControllerValidationRequestModel } public class MvcControllerValidationChildModel +{ + [StringLength(5)] + public string? Value { get; init; } + + public MvcControllerValidationNestedChildModel? Nested { get; init; } +} + +public class MvcControllerValidationNestedChildModel +{ + public MvcControllerValidationGrandchildModel? Grandchild { get; init; } +} + +public class MvcControllerValidationGrandchildModel { [StringLength(5)] public string? Value { get; init; } From e346732d3f753a208b0432a98b7de3086a31e241 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 18:41:59 +0000 Subject: [PATCH 11/14] refactor: clarify MVC metadata guard --- .../OptionalValueValidationMetadataProvider.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs index 16cd8f9..384dc3f 100644 --- a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs +++ b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs @@ -12,7 +12,9 @@ public void CreateValidationMetadata(ValidationMetadataProviderContext context) { ArgumentNullException.ThrowIfNull(context); - if (context.Key.ContainerType is null || !OptionalValue.IsOptionalValueType(context.Key.ContainerType)) + if (context.Key.MetadataKind != ModelMetadataKind.Property || + context.Key.ContainerType is null || + !OptionalValue.IsOptionalValueType(context.Key.ContainerType)) { return; } From 4804140e58e9ecbf94de27e4cfa29da1e58c9719 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 19:23:10 +0000 Subject: [PATCH 12/14] Split MVC integration tests --- .../MvcControllerIntegrationValidationTest.cs | 206 ++++++++++++++++++ .../MvcControllerValidationTest.cs | 151 ------------- 2 files changed, 206 insertions(+), 151 deletions(-) create mode 100644 test/OptionalValues.Mvc.Tests/MvcControllerIntegrationValidationTest.cs diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerIntegrationValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerIntegrationValidationTest.cs new file mode 100644 index 0000000..c633223 --- /dev/null +++ b/test/OptionalValues.Mvc.Tests/MvcControllerIntegrationValidationTest.cs @@ -0,0 +1,206 @@ +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Net.Http.Json; +using System.Text; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using OptionalValues.DataAnnotations; +using Shouldly; + +namespace OptionalValues.Mvc.Tests; + +public class MvcControllerIntegrationValidationTest : IAsyncLifetime +{ + private WebApplication? _app; + private HttpClient? _client; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddControllers(options => options.AddOptionalValueSupport()) + .AddApplicationPart(typeof(ValidationController).Assembly) + .AddJsonOptions(options => options.JsonSerializerOptions.AddOptionalValueSupport()); + + builder.WebHost.UseTestServer(); + + _app = builder.Build(); + _app.MapControllers(); + + await _app.StartAsync(); + _client = _app.GetTestClient(); + } + + public async Task DisposeAsync() + { + if (_app != null) + { + await _app.DisposeAsync(); + } + + _client?.Dispose(); + } + + [Fact] + public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() + { + var content = new StringContent( + """{"specifiedName":null,"requiredName":"valid"}""", + Encoding.UTF8, + "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() + { + var content = new StringContent( + """{"name":"short","specifiedName":null,"requiredName":"valid","child":{"value":"valid","nested":{"grandchild":{"value":"valid"}}}}""", + Encoding.UTF8, + "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task InvalidOptionalValueDataAnnotations_ShouldFail_ControllerValidation() + { + var content = new StringContent("""{"name":"toolong"}""", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey(nameof(MvcControllerValidationRequestModel.Name)); + } + + [Fact] + public async Task SpecifiedAndRequiredValueAttributes_ShouldFail_ControllerValidation_WhenUnspecified() + { + var content = new StringContent("""{"name":"short"}""", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey(nameof(MvcControllerValidationRequestModel.SpecifiedName)); + problemDetails.Errors.ShouldContainKey(nameof(MvcControllerValidationRequestModel.RequiredName)); + } + + [Fact] + public async Task RequiredValueAttribute_ShouldFail_ControllerValidation_WhenValueIsNull() + { + var content = new StringContent( + """{"name":"short","specifiedName":null,"requiredName":null}""", + Encoding.UTF8, + "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldNotContainKey(nameof(MvcControllerValidationRequestModel.SpecifiedName)); + problemDetails.Errors.ShouldContainKey(nameof(MvcControllerValidationRequestModel.RequiredName)); + } + + [Fact] + public async Task InvalidChildDataAnnotations_ShouldFail_ControllerValidation() + { + var content = new StringContent("""{"child":{"value":"toolong"}}""", Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey($"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Value)}"); + } + + [Fact] + public async Task InvalidNestedChildDataAnnotations_ShouldFail_ControllerValidation() + { + var content = new StringContent( + """{"child":{"value":"valid","nested":{"grandchild":{"value":"toolong"}}}}""", + Encoding.UTF8, + "application/json"); + + HttpResponseMessage response = await _client!.PostAsync("/validation", content); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + HttpValidationProblemDetails? problemDetails = + await response.Content.ReadFromJsonAsync(); + + problemDetails.ShouldNotBeNull(); + problemDetails.Errors.ShouldContainKey( + $"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Nested)}.{nameof(MvcControllerValidationNestedChildModel.Grandchild)}.{nameof(MvcControllerValidationGrandchildModel.Value)}"); + } +} + +public class MvcControllerValidationRequestModel +{ + [OptionalStringLength(5)] + public OptionalValue Name { get; init; } + + [Specified] + public OptionalValue SpecifiedName { get; init; } + + [RequiredValue] + public OptionalValue RequiredName { get; init; } + + public OptionalValue Child { get; init; } +} + +public class MvcControllerValidationChildModel +{ + [StringLength(5)] + public string? Value { get; init; } + + public MvcControllerValidationNestedChildModel? Nested { get; init; } +} + +public class MvcControllerValidationNestedChildModel +{ + public MvcControllerValidationGrandchildModel? Grandchild { get; init; } +} + +public class MvcControllerValidationGrandchildModel +{ + [StringLength(5)] + public string? Value { get; init; } +} + +[ApiController] +[Route("validation")] +public class ValidationController : ControllerBase +{ + [HttpPost] + public IActionResult Post(MvcControllerValidationRequestModel model) => Ok(model); +} diff --git a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs index ed51dbf..ea42b8c 100644 --- a/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -1,18 +1,7 @@ -using System.ComponentModel.DataAnnotations; -using System.Net; -using System.Net.Http.Json; -using System.Text; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using OptionalValues.DataAnnotations; using Shouldly; namespace OptionalValues.Mvc.Tests; @@ -55,143 +44,3 @@ public void AddOptionalValueSupport_ShouldOnlyRegisterProviderOnce() .ShouldHaveSingleItem(); } } - -public class MvcControllerIntegrationValidationTest : IAsyncLifetime -{ - private WebApplication? _app; - private HttpClient? _client; - - public async Task InitializeAsync() - { - var builder = WebApplication.CreateBuilder(); - - builder.Services.AddControllers(options => options.AddOptionalValueSupport()) - .AddApplicationPart(typeof(ValidationController).Assembly) - .AddJsonOptions(options => options.JsonSerializerOptions.AddOptionalValueSupport()); - - builder.WebHost.UseTestServer(); - - _app = builder.Build(); - _app.MapControllers(); - - await _app.StartAsync(); - _client = _app.GetTestClient(); - } - - public async Task DisposeAsync() - { - if (_app != null) - { - await _app.DisposeAsync(); - } - - _client?.Dispose(); - } - - [Fact] - public async Task UnspecifiedOptionalValue_ShouldPass_ControllerValidation() - { - var content = new StringContent("{}", Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _client!.PostAsync("/validation", content); - - response.StatusCode.ShouldBe(HttpStatusCode.OK); - } - - [Fact] - public async Task SpecifiedOptionalValue_ShouldPass_ControllerValidation() - { - var content = new StringContent("""{"name":"short","child":{"value":"valid","nested":{"grandchild":{"value":"valid"}}}}""", Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _client!.PostAsync("/validation", content); - - response.StatusCode.ShouldBe(HttpStatusCode.OK); - } - - [Fact] - public async Task InvalidOptionalValueDataAnnotations_ShouldFail_ControllerValidation() - { - var content = new StringContent("""{"name":"toolong"}""", Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _client!.PostAsync("/validation", content); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); - - HttpValidationProblemDetails? problemDetails = - await response.Content.ReadFromJsonAsync(); - - problemDetails.ShouldNotBeNull(); - problemDetails.Errors.ShouldContainKey(nameof(MvcControllerValidationRequestModel.Name)); - } - - [Fact] - public async Task InvalidChildDataAnnotations_ShouldFail_ControllerValidation() - { - var content = new StringContent("""{"child":{"value":"toolong"}}""", Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _client!.PostAsync("/validation", content); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); - - HttpValidationProblemDetails? problemDetails = - await response.Content.ReadFromJsonAsync(); - - problemDetails.ShouldNotBeNull(); - problemDetails.Errors.ShouldContainKey($"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Value)}"); - } - - [Fact] - public async Task InvalidNestedChildDataAnnotations_ShouldFail_ControllerValidation() - { - var content = new StringContent( - """{"child":{"value":"valid","nested":{"grandchild":{"value":"toolong"}}}}""", - Encoding.UTF8, - "application/json"); - - HttpResponseMessage response = await _client!.PostAsync("/validation", content); - - response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); - - HttpValidationProblemDetails? problemDetails = - await response.Content.ReadFromJsonAsync(); - - problemDetails.ShouldNotBeNull(); - problemDetails.Errors.ShouldContainKey( - $"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Nested)}.{nameof(MvcControllerValidationNestedChildModel.Grandchild)}.{nameof(MvcControllerValidationGrandchildModel.Value)}"); - } -} - -public class MvcControllerValidationRequestModel -{ - [OptionalStringLength(5)] - public OptionalValue Name { get; init; } - - public OptionalValue Child { get; init; } -} - -public class MvcControllerValidationChildModel -{ - [StringLength(5)] - public string? Value { get; init; } - - public MvcControllerValidationNestedChildModel? Nested { get; init; } -} - -public class MvcControllerValidationNestedChildModel -{ - public MvcControllerValidationGrandchildModel? Grandchild { get; init; } -} - -public class MvcControllerValidationGrandchildModel -{ - [StringLength(5)] - public string? Value { get; init; } -} - -[ApiController] -[Route("validation")] -public class ValidationController : ControllerBase -{ - [HttpPost] - public IActionResult Post(MvcControllerValidationRequestModel model) => Ok(model); -} From ed2c7c0abe6581e6fde524eae793838068e8f7ea Mon Sep 17 00:00:00 2001 From: desjoerd Date: Wed, 27 May 2026 23:24:54 +0200 Subject: [PATCH 13/14] bump version to 0.10 --- src/OptionalValues.Mvc/PublicAPI.Shipped.txt | 5 +++++ src/OptionalValues.Mvc/PublicAPI.Unshipped.txt | 7 +------ version.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/OptionalValues.Mvc/PublicAPI.Shipped.txt b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt index ab058de..f1b0261 100644 --- a/src/OptionalValues.Mvc/PublicAPI.Shipped.txt +++ b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt @@ -1 +1,6 @@ #nullable enable +OptionalValues.Mvc.MvcOptionsExtensions +static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void +OptionalValues.Mvc.OptionalValueValidationMetadataProvider +OptionalValues.Mvc.OptionalValueValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void +OptionalValues.Mvc.OptionalValueValidationMetadataProvider.OptionalValueValidationMetadataProvider() -> void diff --git a/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt index f1b0261..5f28270 100644 --- a/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt +++ b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt @@ -1,6 +1 @@ -#nullable enable -OptionalValues.Mvc.MvcOptionsExtensions -static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void -OptionalValues.Mvc.OptionalValueValidationMetadataProvider -OptionalValues.Mvc.OptionalValueValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void -OptionalValues.Mvc.OptionalValueValidationMetadataProvider.OptionalValueValidationMetadataProvider() -> void + \ No newline at end of file diff --git a/version.json b/version.json index d91304a..81141b9 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.9", + "version": "0.10", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$", From ef94a893f560dc22a37719f679b805fde6106ec7 Mon Sep 17 00:00:00 2001 From: desjoerd Date: Wed, 27 May 2026 23:35:17 +0200 Subject: [PATCH 14/14] Make OptionalValueValidationMetadataProvider internal --- Directory.Build.props | 5 +++++ .../OptionalValueValidationMetadataProvider.cs | 2 +- src/OptionalValues.Mvc/PublicAPI.Shipped.txt | 3 --- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 30a5d8c..8a6d14d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -71,4 +71,9 @@ + + + + + \ No newline at end of file diff --git a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs index 384dc3f..5632531 100644 --- a/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs +++ b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs @@ -5,7 +5,7 @@ namespace OptionalValues.Mvc; /// /// Provides MVC validation metadata for . /// -public sealed class OptionalValueValidationMetadataProvider : IValidationMetadataProvider +internal sealed class OptionalValueValidationMetadataProvider : IValidationMetadataProvider { /// public void CreateValidationMetadata(ValidationMetadataProviderContext context) diff --git a/src/OptionalValues.Mvc/PublicAPI.Shipped.txt b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt index f1b0261..d1966b6 100644 --- a/src/OptionalValues.Mvc/PublicAPI.Shipped.txt +++ b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt @@ -1,6 +1,3 @@ #nullable enable OptionalValues.Mvc.MvcOptionsExtensions static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void -OptionalValues.Mvc.OptionalValueValidationMetadataProvider -OptionalValues.Mvc.OptionalValueValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void -OptionalValues.Mvc.OptionalValueValidationMetadataProvider.OptionalValueValidationMetadataProvider() -> void