-
Notifications
You must be signed in to change notification settings - Fork 1
✨ Add verified MVC validation support for OptionalValue<T> with the package OptionalValues.Mvc
#33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
97b739b
Initial plan
Copilot d9dc8d6
Add MVC validation regression test
Copilot 5c8952d
Ignore OptionalValue SpecifiedValue in MVC validation
Copilot ec7910d
refactor: move MVC validation support to OptionalValues.Mvc
Copilot 2b9324d
fix: align MVC extension naming and add integration test
Copilot 3431762
test: add specified MVC integration coverage
Copilot 3166c89
fix: restore MVC validation coverage across target frameworks
Copilot 2c9234f
test: validate MVC support across net8-net10
Copilot 5b9b155
perf: cache optional value validator accessors
Copilot 9833e22
fix: preserve recursive MVC validation
Copilot e346732
refactor: clarify MVC metadata guard
Copilot 4804140
Split MVC integration tests
Copilot ed2c7c0
bump version to 0.10
desjoerd ef94a89
Make OptionalValueValidationMetadataProvider internal
desjoerd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| using Microsoft.AspNetCore.Mvc; | ||
|
|
||
| namespace OptionalValues.Mvc; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for <see cref="MvcOptions"/> to add support for <see cref="OptionalValue{T}"/> validation metadata. | ||
| /// </summary> | ||
| public static class MvcOptionsExtensions | ||
| { | ||
| /// <summary> | ||
| /// Adds validation metadata support for <see cref="OptionalValue{T}"/>. | ||
| /// </summary> | ||
| /// <param name="options">The MVC options to configure.</param> | ||
| public static void AddOptionalValueSupport(this MvcOptions options) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(options); | ||
|
|
||
| if (options.ModelMetadataDetailsProviders.OfType<OptionalValueValidationMetadataProvider>().Any()) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| options.ModelMetadataDetailsProviders.Add(new OptionalValueValidationMetadataProvider()); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
32 changes: 32 additions & 0 deletions
32
src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; | ||
|
|
||
| namespace OptionalValues.Mvc; | ||
|
|
||
| /// <summary> | ||
| /// Provides MVC validation metadata for <see cref="OptionalValue{T}"/>. | ||
| /// </summary> | ||
| internal sealed class OptionalValueValidationMetadataProvider : IValidationMetadataProvider | ||
| { | ||
| /// <inheritdoc /> | ||
| public void CreateValidationMetadata(ValidationMetadataProviderContext context) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(context); | ||
|
|
||
| if (context.Key.MetadataKind != ModelMetadataKind.Property || | ||
| context.Key.ContainerType is null || | ||
| !OptionalValue.IsOptionalValueType(context.Key.ContainerType)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| switch (context.Key.Name) | ||
| { | ||
| case nameof(OptionalValue<object>.Value): | ||
| context.ValidationMetadata.ValidationModelName = string.Empty; | ||
| break; | ||
| default: | ||
| context.ValidationMetadata.PropertyValidationFilter = NeverValidatePropertyFilter.Instance; | ||
| break; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks> | ||
| </PropertyGroup> | ||
|
|
||
| <PropertyGroup> | ||
| <PackageId>OptionalValues.Mvc</PackageId> | ||
| <Description>MVC validation metadata support for OptionalValues.</Description> | ||
| <PackageTags>$(CommonPackageTags) aspnetcore mvc validation</PackageTags> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\OptionalValues\OptionalValues.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| #nullable enable | ||
| OptionalValues.Mvc.MvcOptionsExtensions | ||
| static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| |
206 changes: 206 additions & 0 deletions
206
test/OptionalValues.Mvc.Tests/MvcControllerIntegrationValidationTest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HttpValidationProblemDetails>(); | ||
|
|
||
| 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<HttpValidationProblemDetails>(); | ||
|
|
||
| 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<HttpValidationProblemDetails>(); | ||
|
|
||
| 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<HttpValidationProblemDetails>(); | ||
|
|
||
| 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<HttpValidationProblemDetails>(); | ||
|
|
||
| problemDetails.ShouldNotBeNull(); | ||
| problemDetails.Errors.ShouldContainKey( | ||
| $"{nameof(MvcControllerValidationRequestModel.Child)}.{nameof(MvcControllerValidationChildModel.Nested)}.{nameof(MvcControllerValidationNestedChildModel.Grandchild)}.{nameof(MvcControllerValidationGrandchildModel.Value)}"); | ||
| } | ||
| } | ||
|
|
||
| public class MvcControllerValidationRequestModel | ||
| { | ||
| [OptionalStringLength(5)] | ||
| public OptionalValue<string> Name { get; init; } | ||
|
|
||
| [Specified] | ||
| public OptionalValue<string?> SpecifiedName { get; init; } | ||
|
|
||
| [RequiredValue] | ||
| public OptionalValue<string?> RequiredName { get; init; } | ||
|
|
||
| public OptionalValue<MvcControllerValidationChildModel> 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); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.