Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,9 @@
<None Include="$(MSBuildThisFileDirectory)/assets/icon.png" CopyToPublishDirectory="Always"
Pack="true" PackagePath="\" />
</ItemGroup>

<!-- Common properties for testing -->
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
</Project>
2 changes: 2 additions & 0 deletions OptionalValues.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<Folder Name="/src/">
<Project Path="src/OptionalValues.DataAnnotations/OptionalValues.DataAnnotations.csproj" />
<Project Path="src/OptionalValues.FluentValidation/OptionalValues.FluentValidation.csproj" />
<Project Path="src/OptionalValues.Mvc/OptionalValues.Mvc.csproj" />
<Project Path="src/OptionalValues.NSwag/OptionalValues.NSwag.csproj" />
<Project Path="src/OptionalValues.Swashbuckle/OptionalValues.Swashbuckle.csproj" />
<Project Path="src/OptionalValues/OptionalValues.csproj" />
Expand All @@ -29,6 +30,7 @@
Path="test/OptionalValues.DataAnnotations.Tests/OptionalValues.DataAnnotations.Tests.csproj" />
<Project
Path="test/OptionalValues.FluentValidation.Tests/OptionalValues.FluentValidation.Tests.csproj" />
<Project Path="test/OptionalValues.Mvc.Tests/OptionalValues.Mvc.Tests.csproj" />
<Project Path="test/OptionalValues.NSwag.Tests/OptionalValues.NSwag.Tests.csproj" />
<Project
Path="test/OptionalValues.Swashbuckle.V7.Tests/OptionalValues.Swashbuckle.V7.Tests.csproj" />
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A .NET library that provides an `OptionalValue<T>` 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) |
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -352,7 +354,7 @@ public class Model<T>

The `OptionalValues` library integrates seamlessly with ASP.NET Core, allowing you to use `OptionalValue<T>` properties in your API models.

You only need to configure the `JsonSerializerOptions` to include the `OptionalValue<T>` converter:
Configure the `JsonSerializerOptions` to include the `OptionalValue<T>` converter, and for MVC controller validation add `OptionalValues.Mvc`:

```csharp
// For Minimal API
Expand All @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions src/OptionalValues.Mvc/MvcOptionsExtensions.cs
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());
}
}
10 changes: 10 additions & 0 deletions src/OptionalValues.Mvc/NeverValidatePropertyFilter.cs
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 src/OptionalValues.Mvc/OptionalValueValidationMetadataProvider.cs
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;
}
}
}
21 changes: 21 additions & 0 deletions src/OptionalValues.Mvc/OptionalValues.Mvc.csproj
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" />
Comment thread
desjoerd marked this conversation as resolved.
</ItemGroup>

</Project>
3 changes: 3 additions & 0 deletions src/OptionalValues.Mvc/PublicAPI.Shipped.txt
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
1 change: 1 addition & 0 deletions src/OptionalValues.Mvc/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

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);
}
Loading
Loading