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
Original file line number Diff line number Diff line change
Expand Up @@ -787,5 +787,11 @@
<property name="Scope">member</property>
<property name="Target">M:Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder.CreateModel(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext)</property>
</attribute>
<attribute fullname="System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute">
<argument>ILLink</argument>
<argument>IL2075</argument>
<property name="Scope">member</property>
<property name="Target">M:Microsoft.AspNetCore.Mvc.ModelBinding.Validation.DefaultUnionValidationStrategy.IsUnionValueProperty(Microsoft.AspNetCore.Mvc.ModelBinding.ModelMetadata,System.Type)</property>
</attribute>
</assembly>
</linker>
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,12 @@ internal static bool CalculateHasValidators(HashSet<DefaultModelMetadata> visite
return defaultModelMetadata._hasValidators.Value;
}

if (typeof(IUnion).IsAssignableFrom(Nullable.GetUnderlyingType(metadata.ModelType) ?? metadata.ModelType))
{
// The active case's validators are determined from its runtime type.
return true;
}

if (defaultModelMetadata.ValidationMetadata.HasValidators != false)
{
// Either the ModelMetadata instance has some validators (HasValidators = true) or it is non-deterministic (HasValidators = null).
Expand Down Expand Up @@ -555,6 +561,26 @@ public override IReadOnlyList<object> ValidatorMetadata

internal DefaultMetadataDetails Details => _details;

internal ModelMetadata GetMetadataForRuntimeType(Type modelType)
{
return _provider is ModelMetadataProvider provider && _details.Key.PropertyInfo is { } propertyInfo
? provider.GetMetadataForProperty(propertyInfo, modelType)
: this;
}

internal ModelMetadata CreateUnionValueMetadata()
{
var propertyInfo = typeof(IUnion).GetProperty(nameof(IUnion.Value))!;
var details = new DefaultMetadataDetails(
ModelMetadataIdentity.ForProperty(propertyInfo, typeof(object), ModelType),
ModelAttributes.GetAttributesForProperty(ModelType, propertyInfo))
{
ContainerMetadata = this,
};

return new DefaultModelMetadata(_provider, _detailsProvider, details, _modelBindingMessageProvider);
}

/// <inheritdoc />
public override ModelMetadata GetMetadataForType(Type modelType)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

internal sealed class DefaultUnionValidationStrategy : IValidationStrategy
{
private readonly IModelMetadataProvider _metadataProvider;

public DefaultUnionValidationStrategy(IModelMetadataProvider metadataProvider)
{
_metadataProvider = metadataProvider;
}

public IEnumerator<ValidationEntry> GetChildren(ModelMetadata metadata, string key, object model)
{
var hasValueProperty = false;
using var enumerator = DefaultComplexObjectValidationStrategy.Instance.GetChildren(metadata, key, model);
while (enumerator.MoveNext())
{
var entry = enumerator.Current;
if (IsUnionValueProperty(entry.Metadata, model.GetType()))
{
hasValueProperty = true;
}

yield return entry;
}

if (!hasValueProperty)
{
// IUnion.Value can be implemented explicitly.
yield return new ValidationEntry(
metadata is DefaultModelMetadata defaultMetadata
? defaultMetadata.CreateUnionValueMetadata()
: _metadataProvider.GetMetadataForType(typeof(object)),
ModelNames.CreatePropertyModelName(key, nameof(IUnion.Value)),
() => ((IUnion)model).Value);
}
}

internal static bool IsUnionValueProperty(ModelMetadata metadata, Type unionType)
{
if (metadata.MetadataKind != ModelMetadataKind.Property || metadata.PropertyName != nameof(IUnion.Value))
{
return false;
}

var getter = metadata is DefaultModelMetadata propertyMetadata
? propertyMetadata.Details.Key.PropertyInfo?.GetMethod
: metadata.ContainerType?.GetProperty(nameof(IUnion.Value))?.GetMethod;
var unionGetter = unionType.GetInterfaceMap(typeof(IUnion)).TargetMethods[0];

return getter is not null && (getter.DeclaringType == typeof(IUnion) || getter.GetBaseDefinition() == unionGetter.GetBaseDefinition());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class ValidationVisitor
{
private readonly ValidationStack _currentPath;
private int? _maxValidationDepth;
private IValidationStrategy? _unionValidationStrategy;

/// <summary>
/// Creates a new <see cref="ValidationVisitor"/>.
Expand Down Expand Up @@ -310,6 +311,13 @@ private bool VisitImplementation(ref ModelMetadata metadata, ref string? key, ob
};
}

// Model and Strategy still describe the parent until StateManager.Recurse.
if (Model is IUnion && Strategy is null &&
(Metadata is null || typeof(IUnion).IsAssignableFrom(Nullable.GetUnderlyingType(Metadata.ModelType) ?? Metadata.ModelType)))
{
metadata = GetUnionCaseMetadata(metadata, model);
}

var entry = GetValidationEntry(model);
key = entry?.Key ?? key ?? string.Empty;
metadata = entry?.Metadata ?? metadata;
Expand Down Expand Up @@ -351,6 +359,11 @@ private bool VisitImplementation(ref ModelMetadata metadata, ref string? key, ob

using (StateManager.Recurse(this, key ?? string.Empty, metadata, model, strategy!))
{
if (Model is IUnion && typeof(IUnion).IsAssignableFrom(Nullable.GetUnderlyingType(Metadata!.ModelType) ?? Metadata.ModelType))
{
return VisitComplexType(_unionValidationStrategy ??= new DefaultUnionValidationStrategy(MetadataProvider));
}

if (Metadata!.IsEnumerableType)
{
return VisitComplexType(DefaultCollectionValidationStrategy.Instance);
Expand All @@ -365,6 +378,32 @@ private bool VisitImplementation(ref ModelMetadata metadata, ref string? key, ob
}
}

private ModelMetadata GetUnionCaseMetadata(ModelMetadata metadata, object? model)
{
if (metadata.ModelType != typeof(object) || model is null ||
(metadata.MetadataKind != ModelMetadataKind.Type && !DefaultUnionValidationStrategy.IsUnionValueProperty(metadata, Model!.GetType())))
{
return metadata;
}

// Retain property attributes even when the registered provider decorates the metadata's provider.
if (metadata.MetadataKind == ModelMetadataKind.Property)
{
if (MetadataProvider is ModelMetadataProvider provider &&
metadata is DefaultModelMetadata propertyMetadata &&
propertyMetadata.Details.Key.PropertyInfo is { } propertyInfo)
{
return provider.GetMetadataForProperty(propertyInfo, model.GetType());
}

return metadata is DefaultModelMetadata defaultMetadata
? defaultMetadata.GetMetadataForRuntimeType(model.GetType())
: metadata;
}

return MetadataProvider.GetMetadataForType(model.GetType());
}

/// <summary>
/// Validate complex types, this covers everything VisitSimpleType does not i.e. both enumerations and complex types.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net;
using System.Net.Http.Json;
using FormatterWebSite.Models;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Mvc.FunctionalTests;

public partial class SystemTextJsonInputFormatterTest
{
[Theory]
[InlineData("ValidateAdmin", "{\"adminId\":\"A1\",\"role\":\"SuperAdminRoleExceeding10\"}", "Role", "The field Role must be a string or array type with a maximum length of '10'.")]
[InlineData("ValidateUser", "{\"adminId\":\"A1\",\"role\":\"SuperAdminRoleExceeding10\"}", "Value.Role", "The field Role must be a string or array type with a maximum length of '10'.")]
[InlineData("ValidateUser", "{\"adminId\":null,\"role\":\"Admin\"}", "Value.AdminId", "The AdminId field is required.")]
[InlineData("ValidateUser", "{\"customerId\":\"C1\",\"age\":17}", "Value.Age", "The field Age must be between 18 and 120.")]
[InlineData("ValidateUser", "{\"customerId\":null,\"age\":18}", "Value.CustomerId", "The CustomerId field is required.")]
[InlineData("ValidateUserEnvelope", "{\"user\":{\"adminId\":\"A1\",\"role\":\"SuperAdminRoleExceeding10\"}}", "User.Value.Role", "The field Role must be a string or array type with a maximum length of '10'.")]
[InlineData("ValidateOptionalUserEnvelope", "{\"user\":{\"customerId\":\"C1\",\"age\":121}}", "User.Value.Age", "The field Age must be between 18 and 120.")]
[InlineData("ValidateRequiredNullableUserEnvelope", "{}", "User", "The User field is required.")]
[InlineData("ValidateRequiredNullableUserEnvelope", "{\"user\":null}", "User", "The User field is required.")]
[InlineData("ValidateUsers", "[{\"adminId\":\"A1\",\"role\":\"Admin\"},{\"customerId\":\"C1\",\"age\":17}]", "[1].Value.Age", "The field Age must be between 18 and 120.")]
[InlineData("ValidateNestedUser", "{\"customerId\":\"C1\",\"age\":17}", "Value.Value.Age", "The field Age must be between 18 and 120.")]
public async Task UnionValidation_InvalidCase_ReturnsAutomaticBadRequest(
string action, string payload, string errorKey, string errorMessage)
{
var response = await Client.PostAsync($"/Unions/{action}", JsonBody(payload));

await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
var error = Assert.Single(problem.Errors);
Assert.Equal(errorKey, error.Key);
Assert.Equal(errorMessage, Assert.Single(error.Value));
}

[Theory]
[InlineData("ValidateUser", "{\"adminId\":\"A1\",\"role\":\"Admin\"}")]
[InlineData("ValidateUser", "{\"customerId\":\"C1\",\"age\":18}")]
[InlineData("ValidateUserEnvelope", "{\"user\":{\"customerId\":\"C1\",\"age\":120}}")]
[InlineData("ValidateOptionalUserEnvelope", "{\"user\":{\"adminId\":\"A1\",\"role\":\"Admin\"}}")]
[InlineData("ValidateRequiredNullableUserEnvelope", "{\"user\":{\"adminId\":\"A1\",\"role\":\"Admin\"}}")]
[InlineData("ValidateUsers", "[{\"adminId\":\"A1\",\"role\":\"Admin\"},{\"customerId\":\"C1\",\"age\":18}]")]
[InlineData("ValidateNestedUser", "{\"adminId\":\"A1\",\"role\":\"Admin\"}")]
[InlineData("ValidateNestedUser", "true")]
[InlineData("ValidateUserEnvelope", "{}")]
[InlineData("ValidateUserEnvelope", "{\"user\":null}")]
[InlineData("ValidateOptionalUserEnvelope", "{}")]
[InlineData("ValidateOptionalUserEnvelope", "{\"user\":null}")]
[InlineData("ValidateSuppressedUserEnvelope", "{\"user\":{\"customerId\":\"C1\",\"age\":17}}")]
public async Task UnionValidation_ValidDefaultOrSuppressedCase_IsAccepted(string action, string payload)
{
var response = await Client.PostAsync($"/Unions/{action}", JsonBody(payload));

await response.AssertStatusCodeAsync(HttpStatusCode.OK);
Assert.True(await response.Content.ReadFromJsonAsync<bool>());
}

[Theory]
[InlineData(typeof(ValidatedUser))]
[InlineData(typeof(UnionCustomer))]
public async Task UnionValidation_SuppressedChildValidation_IsAccepted(Type suppressedType)
{
using var factory = Factory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
services.Configure<MvcOptions>(options =>
options.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(suppressedType)))));
using var client = factory.CreateClient();

var response = await client.PostAsync("/Unions/ValidateUser", JsonBody("{\"customerId\":\"C1\",\"age\":17}"));

await response.AssertStatusCodeAsync(HttpStatusCode.OK);
Assert.True(await response.Content.ReadFromJsonAsync<bool>());
}
}
Loading
Loading