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/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..f707a77 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.AddOptionalValueSupport(); + }); +``` + +Or configure the MVC options directly: + +```csharp +builder.Services.AddControllers(options => + { + options.AddOptionalValueSupport(); + }) .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..e00b459 --- /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 . + /// + /// The MVC options to configure. + public static void AddOptionalValueSupport(this MvcOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.ModelMetadataDetailsProviders.OfType().Any()) + { + return; + } + + options.ModelMetadataDetailsProviders.Add(new OptionalValueValidationMetadataProvider()); + } +} 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/OptionalValueValidationMetadataProvider.cs b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs new file mode 100644 index 0000000..5632531 --- /dev/null +++ b/src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace OptionalValues.Mvc; + +/// +/// Provides MVC validation metadata for . +/// +internal sealed class OptionalValueValidationMetadataProvider : IValidationMetadataProvider +{ + /// + 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.Value): + context.ValidationMetadata.ValidationModelName = string.Empty; + break; + default: + context.ValidationMetadata.PropertyValidationFilter = NeverValidatePropertyFilter.Instance; + break; + } + } +} 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..d1966b6 --- /dev/null +++ b/src/OptionalValues.Mvc/PublicAPI.Shipped.txt @@ -0,0 +1,3 @@ +#nullable enable +OptionalValues.Mvc.MvcOptionsExtensions +static OptionalValues.Mvc.MvcOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.Mvc.MvcOptions! options) -> void diff --git a/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/src/OptionalValues.Mvc/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ + \ No newline at end of file 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 new file mode 100644 index 0000000..ea42b8c --- /dev/null +++ b/test/OptionalValues.Mvc.Tests/MvcControllerValidationTest.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; + +using Shouldly; + +namespace OptionalValues.Mvc.Tests; + +public class MvcControllerValidationTest +{ + [Fact] + public void AddOptionalValueSupport_ShouldConfigureOptionalValueValidationMetadata() + { + ServiceProvider services = new ServiceCollection() + .AddControllers(options => options.AddOptionalValueSupport()) + .Services + .BuildServiceProvider(); + + 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.ShouldBeTrue(); + value.ShouldNotBeNull(); + value.PropertyValidationFilter.ShouldBeNull(); + specifiedValue.ShouldNotBeNull(); + specifiedValue.PropertyValidationFilter.ShouldNotBeNull(); + isSpecified.ShouldNotBeNull(); + isSpecified.PropertyValidationFilter.ShouldNotBeNull(); + } + + [Fact] + public void AddOptionalValueSupport_ShouldOnlyRegisterProviderOnce() + { + var options = new MvcOptions(); + + options.AddOptionalValueSupport(); + options.AddOptionalValueSupport(); + + 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..f48773c --- /dev/null +++ b/test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj @@ -0,0 +1,43 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + 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+)?$",