diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.WarningSuppressions.xml b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.WarningSuppressions.xml index 91235bbf293d..c85b32f539f6 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.WarningSuppressions.xml +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.WarningSuppressions.xml @@ -787,5 +787,11 @@ member M:Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder.CreateModel(Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingContext) + + ILLink + IL2075 + member + M:Microsoft.AspNetCore.Mvc.ModelBinding.Validation.DefaultUnionValidationStrategy.IsUnionValueProperty(Microsoft.AspNetCore.Mvc.ModelBinding.ModelMetadata,System.Type) + \ No newline at end of file diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs index 779b3f8d9b0e..58091f8de7ef 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs @@ -478,6 +478,12 @@ internal static bool CalculateHasValidators(HashSet 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). @@ -555,6 +561,26 @@ public override IReadOnlyList 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); + } + /// public override ModelMetadata GetMetadataForType(Type modelType) { diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultUnionValidationStrategy.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultUnionValidationStrategy.cs new file mode 100644 index 000000000000..2ff5e78c4618 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultUnionValidationStrategy.cs @@ -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 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()); + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidationVisitor.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidationVisitor.cs index c8e6a7ac0c3d..b59760ba456f 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidationVisitor.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidationVisitor.cs @@ -18,6 +18,7 @@ public class ValidationVisitor { private readonly ValidationStack _currentPath; private int? _maxValidationDepth; + private IValidationStrategy? _unionValidationStrategy; /// /// Creates a new . @@ -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; @@ -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); @@ -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()); + } + /// /// Validate complex types, this covers everything VisitSimpleType does not i.e. both enumerations and complex types. /// diff --git a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.UnionValidation.cs b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.UnionValidation.cs new file mode 100644 index 000000000000..e463f35243cf --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.UnionValidation.cs @@ -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(); + 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()); + } + + [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(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()); + } +} diff --git a/src/Mvc/test/Mvc.IntegrationTests/UnionValidationIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/UnionValidationIntegrationTest.cs new file mode 100644 index 000000000000..49f78b4a2b2c --- /dev/null +++ b/src/Mvc/test/Mvc.IntegrationTests/UnionValidationIntegrationTest.cs @@ -0,0 +1,565 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.AspNetCore.Mvc.IntegrationTests; + +public class UnionValidationIntegrationTest +{ + [Fact] + public void Validate_ActiveCase_UsesTypeLevelValidation() + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new ValidatableUnion(new ValidatableCase("invalid")); + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal("Invalid case.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Fact] + public void Validate_ExplicitUnionValue_UsesTypeLevelValidation() + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new ExplicitUnion(new ValidatableCase("invalid")); + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal("Invalid case.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Validate_ValidationStateSuppressesUnionOrCase(bool suppressCase) + { + using var services = CreateServices(); + var context = CreateContext(); + var value = new ValidatableCase("invalid"); + object model = new ValidatableUnion(value); + context.ModelState.SetModelValue("model.Value.Name", "invalid", "invalid"); + var validationState = new ValidationStateDictionary + { + [suppressCase ? value : model] = new ValidationStateEntry + { + Key = suppressCase ? "model.Value" : "model", + SuppressValidation = true, + }, + }; + + services.GetRequiredService().Validate(context, validationState, "model", model); + + Assert.True(context.ModelState.IsValid); + Assert.Equal(ModelValidationState.Skipped, context.ModelState["model.Value.Name"].ValidationState); + } + + [Fact] + public void Validate_ValidationStateStrategy_OverridesUnionTraversal() + { + using var services = CreateServices(); + var context = CreateContext(); + object model = new ValidatableUnion(new ValidatableCase("invalid")); + var strategy = new Mock(); + strategy.Setup(s => s.GetChildren(It.IsAny(), "model", model)) + .Returns(() => Enumerable.Empty().GetEnumerator()); + var validationState = new ValidationStateDictionary + { + [model] = new ValidationStateEntry { Strategy = strategy.Object }, + }; + + services.GetRequiredService().Validate(context, validationState, "model", model); + + Assert.True(context.ModelState.IsValid); + strategy.Verify(s => s.GetChildren(It.IsAny(), "model", model), Times.Once); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Validate_ValidationStateMetadata_OverridesActiveCaseMetadata(bool validateCase) + { + using var services = CreateServices(); + var context = CreateContext(); + var value = new ValidatableCase("invalid"); + var model = new ValidatableUnion(value); + var metadataProvider = services.GetRequiredService(); + var validationState = new ValidationStateDictionary + { + [value] = new ValidationStateEntry + { + Key = "overridden", + Metadata = metadataProvider.GetMetadataForType(validateCase ? typeof(ValidatableCase) : typeof(object)), + }, + }; + + services.GetRequiredService().Validate(context, validationState, "model", model); + + Assert.Equal(!validateCase, context.ModelState.IsValid); + if (validateCase) + { + var entry = Assert.Single(context.ModelState); + Assert.Equal("overridden", entry.Key); + Assert.Equal("Invalid case.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + else + { + Assert.Empty(context.ModelState); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Validate_ValidationStateMetadata_OverridesUnionMetadata(bool nested, bool existingError) + { + using var services = CreateServices(); + var context = CreateContext(); + var value = new ExplicitUnion(new ValidatableCase("invalid")); + object model = nested ? new ExplicitUnion(value) : value; + var key = nested ? "model.Value" : "model"; + if (existingError) + { + context.ModelState.AddModelError(key, "Existing error."); + } + + var validationState = new ValidationStateDictionary + { + [value] = new ValidationStateEntry + { + Metadata = services.GetRequiredService().GetMetadataForType(typeof(object)), + }, + }; + + services.GetRequiredService().Validate(context, validationState, "model", model); + + Assert.Equal(!existingError, context.ModelState.IsValid); + Assert.Equal(existingError ? 1 : 0, context.ModelState.ErrorCount); + if (existingError) + { + var entry = Assert.Single(context.ModelState); + Assert.Equal(key, entry.Key); + Assert.Equal("Existing error.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Validate_ValidationStateMetadata_PreservesUnionTraversal(bool nullable) + { + using var services = CreateServices(); + var context = CreateContext(); + object model = new ValidatableUnion(new ValidatableCase("invalid")); + var validationState = new ValidationStateDictionary + { + [model] = new ValidationStateEntry + { + Metadata = services.GetRequiredService().GetMetadataForType(nullable ? typeof(ValidatableUnion?) : typeof(ValidatableUnion)), + }, + }; + + services.GetRequiredService().Validate(context, validationState, "model", model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal("Invalid case.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Validate_DecoratedMetadataProvider_PreservesValuePropertyAndCaseValidators(bool decorate, bool invalidCase) + { + using var services = CreateServices(decorateMetadataProvider: decorate); + var context = CreateContext(); + object model = invalidCase + ? new ValidatableUnion(new ValidatableCase("invalid")) + : new LengthUnion("long"); + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal(invalidCase ? "Invalid case." : "The field Value must be a string or array type with a maximum length of '3'.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Theory] + [InlineData("long", false)] + [InlineData("ok", true)] + public void Validate_TopLevelUnionValue_PreservesPropertyValidators(string value, bool isValid) + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new LengthUnion(value); + var metadata = services.GetRequiredService().GetMetadataForProperties(typeof(LengthUnion)) + .Single(property => property.PropertyName == nameof(LengthUnion.Value)); + + ((ObjectModelValidator)services.GetRequiredService()).Validate( + context, null, "model.Value", model.Value, metadata, container: model); + + Assert.Equal(isValid, context.ModelState.IsValid); + if (isValid) + { + Assert.Empty(context.ModelState); + } + else + { + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal("The field Value must be a string or array type with a maximum length of '3'.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + } + + [Fact] + public void Validate_TopLevelUnionValue_UsesTypeLevelValidation() + { + using var services = CreateServices(); + var context = CreateContext(); + var value = new ValidatableCase("invalid"); + var model = new ValidatableUnion(value); + var metadata = services.GetRequiredService().GetMetadataForProperties(typeof(ValidatableUnion)) + .Single(property => property.PropertyName == nameof(IUnion.Value)); + + ((ObjectModelValidator)services.GetRequiredService()).Validate( + context, null, "model.Value", value, metadata, container: model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal("Invalid case.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Fact] + public void Validate_ClassLevelSuppressedExplicitUnion_DoesNotInvokeGetter() + { + using var services = CreateServices(); + var context = CreateContext(); + context.ModelState.SetModelValue("model.Value.Name", "ignored", "ignored"); + + services.GetRequiredService().Validate(context, null, "model", new SuppressedExplicitUnion()); + + Assert.True(context.ModelState.IsValid); + Assert.Equal(ModelValidationState.Skipped, context.ModelState["model.Value.Name"].ValidationState); + } + + [Fact] + public void Validate_ValidationStateStrategy_PreservesDeclaredChildMetadata() + { + using var services = CreateServices(); + var context = CreateContext(); + var value = new ValidatableCase("invalid"); + object model = new ValidatableUnion(value); + var metadata = services.GetRequiredService().GetMetadataForType(typeof(object)); + var strategy = new Mock(); + strategy.Setup(s => s.GetChildren(It.IsAny(), "model", model)) + .Returns(() => new[] { new ValidationEntry(metadata, "model.Value", value) }.AsEnumerable().GetEnumerator()); + var validationState = new ValidationStateDictionary + { + [model] = new ValidationStateEntry { Strategy = strategy.Object }, + }; + + services.GetRequiredService().Validate(context, validationState, "model", model); + + Assert.True(context.ModelState.IsValid); + Assert.Empty(context.ModelState); + strategy.Verify(s => s.GetChildren(It.IsAny(), "model", model), Times.Once); + } + + [Fact] + public void Validate_UnionCycle_DoesNotRevisitCase() + { + using var services = CreateServices(); + var context = CreateContext(); + var value = new RecursiveCase(); + value.Child = new RecursiveUnion(value); + + services.GetRequiredService().Validate(context, null, "model", value.Child); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value.Name", entry.Key); + Assert.Equal("The Name field is required.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Fact] + public void Validate_UnionTraversal_RespectsMaxValidationDepth() + { + using var services = CreateServices(options => options.MaxValidationDepth = 1); + var context = CreateContext(); + var model = new ValidatableUnion(new ValidatableCase("valid")); + + var exception = Assert.Throws(() => + services.GetRequiredService().Validate(context, null, "model", model)); + + Assert.Contains("MaxValidationDepth", exception.Message); + } + + [Fact] + public void Validate_OrdinaryObjectProperty_DoesNotUseRuntimeTypeMetadata() + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new ObjectEnvelope { Value = new ValidatableCase("invalid") }; + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.True(context.ModelState.IsValid); + Assert.Empty(context.ModelState); + } + + [Fact] + public void Validate_UnionTraversal_RespectsMaxModelStateErrors() + { + using var services = CreateServices(); + var context = CreateContext(); + context.ModelState.MaxAllowedErrors = 1; + var model = new[] + { + new ValidatableUnion(new ValidatableCase("invalid")), + new ValidatableUnion(new ValidatableCase("invalid")), + }; + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.False(context.ModelState.IsValid); + Assert.True(context.ModelState.HasReachedMaxErrors); + Assert.Equal(1, context.ModelState.ErrorCount); + } + + [Fact] + public void Validate_UnionWithOtherProperties_PreservesPropertyValidationAndFilters() + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new CustomUnion { Value = new ValidatableCase("invalid") }; + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Name", entry.Key); + Assert.Equal("The Name field is required.", Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Fact] + public void Validate_UnionWithOtherObjectProperty_DoesNotUseRuntimeTypeMetadata() + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new UnionWithOtherObjectProperty { Other = new ValidatableCase("invalid") }; + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.True(context.ModelState.IsValid); + Assert.Empty(context.ModelState); + } + + [Theory] + [InlineData(null, "valid", "The Value field is required.")] + [InlineData("public", "invalid", "Invalid case.")] + public void Validate_ExplicitUnionValueWithPublicNamesake_ValidatesBothValues(string publicValue, string caseName, string errorMessage) + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new UnionWithPublicNamesake(publicValue, new ValidatableCase(caseName)); + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.False(context.ModelState.IsValid); + var entry = Assert.Single(context.ModelState); + Assert.Equal("model.Value", entry.Key); + Assert.Equal(errorMessage, Assert.Single(entry.Value.Errors).ErrorMessage); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Validate_SuppressedValue_DoesNotInvokeGetter(bool isUnion) + { + using var services = CreateServices(); + var context = CreateContext(); + object model = isUnion ? new SuppressedGetterUnion() : new SuppressedGetterModel(); + context.ModelState.SetModelValue("model.Value", "ignored", "ignored"); + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.True(context.ModelState.IsValid); + Assert.Equal(ModelValidationState.Skipped, context.ModelState["model.Value"].ValidationState); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Validate_UnionValue_EvaluatesFilterOnceBeforeGetter(bool suppressValue) + { + using var services = CreateServices(); + var context = CreateContext(); + var model = new FilterTrackingUnion { SuppressValue = suppressValue }; + + services.GetRequiredService().Validate(context, null, "model", model); + + Assert.Equal(1, model.FilterCalls); + Assert.Equal(suppressValue ? 0 : 1, model.GetterCalls); + Assert.Equal(suppressValue, context.ModelState.IsValid); + Assert.Equal(suppressValue ? 0 : 1, context.ModelState.ErrorCount); + } + + private static ServiceProvider CreateServices(Action configure = null, bool decorateMetadataProvider = false) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddControllers(options => configure?.Invoke(options)); + if (decorateMetadataProvider) + { + services.AddSingleton(provider => + new MetadataDecorator(ActivatorUtilities.CreateInstance(provider))); + } + + return services.BuildServiceProvider(); + } + + private static ActionContext CreateContext() => new() + { + HttpContext = new DefaultHttpContext(), + }; + + public record ValidatableCase(string Name) : IValidatableObject + { + public IEnumerable Validate(ValidationContext validationContext) + { + if (Name == "invalid") + { + yield return new ValidationResult("Invalid case."); + } + } + } + + public union ValidatableUnion(ValidatableCase, int); + public union RecursiveUnion(RecursiveCase, int); + + public class RecursiveCase + { + [Required] + public string Name { get; set; } + + public RecursiveUnion Child { get; set; } + } + + private class ObjectEnvelope + { + public object Value { get; set; } + } + + private class CustomUnion : IUnion + { + [Required] + public string Name { get; set; } + + [ValidateNever] + public object Value { get; set; } + } + + private class ExplicitUnion(object value) : IUnion + { + object IUnion.Value => value; + } + + private sealed class MetadataDecorator(IModelMetadataProvider inner) : IModelMetadataProvider + { + public ModelMetadata GetMetadataForType(Type modelType) => inner.GetMetadataForType(modelType); + + public IEnumerable GetMetadataForProperties(Type modelType) => inner.GetMetadataForProperties(modelType); + } + + private class LengthUnion(string value) : IUnion + { + [MaxLength(3)] + public object Value => value; + } + + [ValidateNever] + private class SuppressedExplicitUnion : IUnion + { + object IUnion.Value => throw new InvalidOperationException("Suppressed getter was invoked."); + } + + private class UnionWithOtherObjectProperty : IUnion + { + public object Value => 42; + + public object Other { get; set; } + } + + private class UnionWithPublicNamesake(object publicValue, object caseValue) : IUnion + { + [Required] + public object Value => publicValue; + + object IUnion.Value => caseValue; + } + + private class SuppressedGetterModel + { + [Required] + public string Name { get; set; } = "valid"; + + [ValidateNever] + public object Value => throw new InvalidOperationException("Suppressed getter was invoked."); + } + + private class SuppressedGetterUnion : SuppressedGetterModel, IUnion + { + } + + private class FilterTrackingUnion : IUnion + { + public bool SuppressValue { get; set; } + + public int FilterCalls { get; set; } + + public int GetterCalls { get; private set; } + + [TrackingValidationFilter] + public object Value + { + get + { + GetterCalls++; + return new ValidatableCase("invalid"); + } + } + } + + [AttributeUsage(AttributeTargets.Property)] + private sealed class TrackingValidationFilterAttribute : Attribute, IPropertyValidationFilter + { + public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) + { + var model = (FilterTrackingUnion)parentEntry.Model; + model.FilterCalls++; + return !model.SuppressValue; + } + } +} diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/UnionsController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/UnionsController.cs index 686841d9c295..ee344b5ec022 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/UnionsController.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/UnionsController.cs @@ -206,4 +206,28 @@ public class UnionsController : ControllerBase [HttpPost] public UnionEnvelope EchoEnvelope([FromBody] UnionEnvelope value) => value; + + [HttpPost] + public bool ValidateAdmin(UnionAdmin value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateUser(ValidatedUser value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateUserEnvelope(ValidatedUserEnvelope value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateOptionalUserEnvelope(OptionalValidatedUserEnvelope value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateRequiredNullableUserEnvelope(RequiredNullableValidatedUserEnvelope value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateUsers(ValidatedUser[] value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateNestedUser(NestedValidatedUser value) => ModelState.IsValid; + + [HttpPost] + public bool ValidateSuppressedUserEnvelope(SuppressedValidatedUserEnvelope value) => ModelState.IsValid; } diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Models/UnionTypes.cs b/src/Mvc/test/WebSites/FormatterWebSite/Models/UnionTypes.cs index f0946d2654a7..5c45037f2693 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Models/UnionTypes.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Models/UnionTypes.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ComponentModel.DataAnnotations; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace FormatterWebSite.Models; @@ -124,3 +126,28 @@ public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContex _ => null, }; } + +public record UnionAdmin([Required] string AdminId, [MaxLength(10)] string Role); +public record UnionCustomer([Required] string CustomerId, [Range(18, 120)] int Age); + +[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] +public union ValidatedUser(UnionAdmin, UnionCustomer); + +public record ValidatedUserEnvelope([Required] ValidatedUser User); +public record OptionalValidatedUserEnvelope(ValidatedUser? User); +public record RequiredNullableValidatedUserEnvelope([Required] ValidatedUser? User); +public record SuppressedValidatedUserEnvelope([ValidateNever] ValidatedUser User); + +[JsonUnion(TypeClassifier = typeof(NestedValidatedUserClassifierFactory))] +public union NestedValidatedUser(ValidatedUser, bool); + +public sealed class NestedValidatedUserClassifierFactory : JsonTypeClassifierFactory +{ + public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options) => + static (ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.StartObject => typeof(ValidatedUser), + JsonTokenType.True or JsonTokenType.False => typeof(bool), + _ => null, + }; +}