diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs index 48395f7cb488..7f9e1d99a79e 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs @@ -494,6 +494,11 @@ internal IReadOnlyDictionary BoundConstructorPrope /// internal virtual bool PropertyHasValidators => false; + /// + /// Gets the name of a model, if specified explicitly, to be used on + /// + internal virtual string? ValidationModelName { get; } + /// /// Throws if the ModelMetadata is for a record type with validation on properties. /// diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs index b74e5fd7e206..429c18fef155 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs @@ -471,6 +471,9 @@ public override bool? HasValidators internal override bool PropertyHasValidators => ValidationMetadata.PropertyHasValidators; + /// + internal override string? ValidationModelName => ValidationMetadata.ValidationModelName; + internal static bool CalculateHasValidators(HashSet visited, ModelMetadata metadata) { RuntimeHelpers.EnsureSufficientExecutionStack(); diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/SystemTextJsonValidationMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/SystemTextJsonValidationMetadataProvider.cs new file mode 100644 index 000000000000..d34e6f48aa42 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/SystemTextJsonValidationMetadataProvider.cs @@ -0,0 +1,77 @@ +// 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.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +/// +/// An implementation of and for +/// the System.Text.Json.Serialization attribute classes. +/// +public sealed class SystemTextJsonValidationMetadataProvider : IDisplayMetadataProvider, IValidationMetadataProvider +{ + private readonly JsonNamingPolicy _jsonNamingPolicy; + + /// + /// Creates a new with the default + /// + public SystemTextJsonValidationMetadataProvider() + : this(JsonNamingPolicy.CamelCase) + { } + + /// + /// Creates a new with an optional + /// + /// The to be used to configure the metadata provider. + public SystemTextJsonValidationMetadataProvider(JsonNamingPolicy namingPolicy) + { + if (namingPolicy == null) + { + throw new ArgumentNullException(nameof(namingPolicy)); + } + + _jsonNamingPolicy = namingPolicy; + } + + /// + public void CreateDisplayMetadata(DisplayMetadataProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var propertyName = ReadPropertyNameFrom(context.Attributes); + + if (!string.IsNullOrEmpty(propertyName)) + { + context.DisplayMetadata.DisplayName = () => propertyName; + } + } + + /// + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var propertyName = ReadPropertyNameFrom(context.Attributes); + + if (string.IsNullOrEmpty(propertyName)) + { + propertyName = _jsonNamingPolicy.ConvertName(context.Key.Name!); + } + + context.ValidationMetadata.ValidationModelName = propertyName; + } + + private static string? ReadPropertyNameFrom(IReadOnlyList attributes) + => attributes?.OfType().FirstOrDefault()?.Name; +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ValidationMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ValidationMetadata.cs index c42dc3b669f1..fd75089d8f65 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ValidationMetadata.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ValidationMetadata.cs @@ -52,4 +52,9 @@ public class ValidationMetadata /// Gets or sets a value that determines if validators can be constructed using metadata on properties. /// internal bool PropertyHasValidators { get; set; } + + /// + /// Gets or sets a model name that will be used in . + /// + public string? ValidationModelName { get; set; } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs index a4e32bc69f1a..cf5d96b05d1e 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs @@ -105,7 +105,7 @@ public bool MoveNext() else { var property = _properties[_index - _parameters.Count]; - var propertyName = property.BinderModelName ?? property.PropertyName; + var propertyName = property.ValidationModelName ?? property.BinderModelName ?? property.PropertyName; var key = ModelNames.CreatePropertyModelName(_key, propertyName); if (_model == null) diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..d326535ca4d1 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -1 +1,8 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider.CreateDisplayMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.DisplayMetadataProviderContext! context) -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider.SystemTextJsonValidationMetadataProvider() -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.SystemTextJsonValidationMetadataProvider.SystemTextJsonValidationMetadataProvider(System.Text.Json.JsonNamingPolicy! namingPolicy) -> void +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadata.ValidationModelName.get -> string? +Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadata.ValidationModelName.set -> void diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/SystemTextJsonValidationMetadataProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/SystemTextJsonValidationMetadataProviderTest.cs new file mode 100644 index 000000000000..11964d793636 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/SystemTextJsonValidationMetadataProviderTest.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +public class SystemTextJsonValidationMetadataProviderTest +{ + [Fact] + public void CreateValidationMetadata_SetValidationPropertyName_WithJsonPropertyNameAttribute() + { + var metadataProvider = new SystemTextJsonValidationMetadataProvider(); + var propertyName = "sample-data"; + + var key = ModelMetadataIdentity.ForProperty(typeof(SampleTestClass).GetProperty(nameof(SampleTestClass.NoAttributesProperty)), typeof(int), typeof(SampleTestClass)); + var modelAttributes = new ModelAttributes(Array.Empty(), new[] { new JsonPropertyNameAttribute(propertyName) }, Array.Empty()); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.NotNull(context.ValidationMetadata.ValidationModelName); + Assert.Equal(propertyName, context.ValidationMetadata.ValidationModelName); + } + + [Fact] + public void CreateValidationMetadata_SetValidationPropertyName_CamelCaseWithDefaultNamingPolicy() + { + var metadataProvider = new SystemTextJsonValidationMetadataProvider(); + var propertyName = nameof(SampleTestClass.NoAttributesProperty); + + var key = ModelMetadataIdentity.ForProperty(typeof(SampleTestClass).GetProperty(propertyName), typeof(int), typeof(SampleTestClass)); + var modelAttributes = new ModelAttributes(Array.Empty(), Array.Empty(), Array.Empty()); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.NotNull(context.ValidationMetadata.ValidationModelName); + Assert.Equal(JsonNamingPolicy.CamelCase.ConvertName(propertyName), context.ValidationMetadata.ValidationModelName); + } + + [Theory] + [MemberData(nameof(NamingPolicies))] + public void CreateValidationMetadata_SetValidationPropertyName_WithJsonNamingPolicy(JsonNamingPolicy namingPolicy) + { + var metadataProvider = new SystemTextJsonValidationMetadataProvider(namingPolicy); + var propertyName = nameof(SampleTestClass.NoAttributesProperty); + + var key = ModelMetadataIdentity.ForProperty(typeof(SampleTestClass).GetProperty(propertyName), typeof(int), typeof(SampleTestClass)); + var modelAttributes = new ModelAttributes(Array.Empty(), Array.Empty(), Array.Empty()); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.NotNull(context.ValidationMetadata.ValidationModelName); + Assert.Equal(namingPolicy.ConvertName(propertyName), context.ValidationMetadata.ValidationModelName); + } + + public static TheoryData NamingPolicies + { + get + { + return new TheoryData + { + UpperCaseJsonNamingPolicy.Instance, + JsonNamingPolicy.CamelCase + }; + } + } + + public class UpperCaseJsonNamingPolicy : System.Text.Json.JsonNamingPolicy + { + public static JsonNamingPolicy Instance = new UpperCaseJsonNamingPolicy(); + + public override string ConvertName(string name) + { + return name?.ToUpperInvariant(); + } + } + + public class SampleTestClass + { + public int NoAttributesProperty { get; set; } + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Validation/DefaultComplexObjectValidationStrategyTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Validation/DefaultComplexObjectValidationStrategyTest.cs index a5ac0e66dea5..913adafead44 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Validation/DefaultComplexObjectValidationStrategyTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Validation/DefaultComplexObjectValidationStrategyTest.cs @@ -3,6 +3,8 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + public class DefaultComplexObjectValidationStrategyTest { [Fact] @@ -45,6 +47,46 @@ public void GetChildren_ReturnsExpectedElements() }); } + [Fact] + public void GetChildren_ReturnsExpectedElements_WithValidationModelName() + { + // Arrange + var model = new Person() + { + Age = 23, + Id = 1, + Name = "Joey", + }; + + var metadata = TestModelMetadataProvider.CreateDefaultProvider(new List { new TestValidationModelNameProvider() }).GetMetadataForType(typeof(Person)); + var strategy = DefaultComplexObjectValidationStrategy.Instance; + + // Act + var enumerator = strategy.GetChildren(metadata, "prefix", model); + + // Assert + Assert.Collection( + BufferEntries(enumerator).OrderBy(e => e.Key), + entry => + { + Assert.Equal("prefix.AGE", entry.Key); + Assert.Equal(23, entry.Model); + Assert.Same(metadata.Properties["Age"], entry.Metadata); + }, + entry => + { + Assert.Equal("prefix.ID", entry.Key); + Assert.Equal(1, entry.Model); + Assert.Same(metadata.Properties["Id"], entry.Metadata); + }, + entry => + { + Assert.Equal("prefix.NAME", entry.Key); + Assert.Equal("Joey", entry.Model); + Assert.Same(metadata.Properties["Name"], entry.Metadata); + }); + } + [Fact] public void GetChildren_SetsModelNull_IfContainerNull() { @@ -149,4 +191,10 @@ public LazyPerson(string input) public string Name => _string.Substring(3, 5); } + + private class TestValidationModelNameProvider : IValidationMetadataProvider + { + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + => context.ValidationMetadata.ValidationModelName = context.Key.Name?.ToUpperInvariant(); + } } diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonValidationMetadataProvider.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonValidationMetadataProvider.cs new file mode 100644 index 000000000000..1fe66d72c685 --- /dev/null +++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonValidationMetadataProvider.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson; + +/// +/// An implementation of and for +/// the Newtonsoft.Json attribute classes. +/// +public sealed class NewtonsoftJsonValidationMetadataProvider : IDisplayMetadataProvider, IValidationMetadataProvider +{ + private readonly NamingStrategy _jsonNamingPolicy; + + /// + /// Creates a new with the default + /// + public NewtonsoftJsonValidationMetadataProvider() + : this(new CamelCaseNamingStrategy()) + { } + + /// + /// Initializes a new instance of with an optional + /// + /// The to be used to configure the metadata provider. + public NewtonsoftJsonValidationMetadataProvider(NamingStrategy namingStrategy) + { + if (namingStrategy == null) + { + throw new ArgumentNullException(nameof(namingStrategy)); + } + + _jsonNamingPolicy = namingStrategy; + } + + /// + public void CreateDisplayMetadata(DisplayMetadataProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var propertyName = ReadPropertyNameFrom(context.Attributes); + + if (!string.IsNullOrEmpty(propertyName)) + { + context.DisplayMetadata.DisplayName = () => propertyName; + } + } + + /// + public void CreateValidationMetadata(ValidationMetadataProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var propertyName = ReadPropertyNameFrom(context.Attributes); + + if (string.IsNullOrEmpty(propertyName)) + { + propertyName = _jsonNamingPolicy.GetPropertyName(context.Key.Name!, false); + } + + context.ValidationMetadata.ValidationModelName = propertyName!; + } + + private static string? ReadPropertyNameFrom(IReadOnlyList attributes) + => attributes?.OfType().FirstOrDefault()?.PropertyName; +} diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..bd9df5e8e8a9 100644 --- a/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt @@ -1 +1,6 @@ #nullable enable +Microsoft.AspNetCore.Mvc.NewtonsoftJson.NewtonsoftJsonValidationMetadataProvider +Microsoft.AspNetCore.Mvc.NewtonsoftJson.NewtonsoftJsonValidationMetadataProvider.CreateDisplayMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.DisplayMetadataProviderContext! context) -> void +Microsoft.AspNetCore.Mvc.NewtonsoftJson.NewtonsoftJsonValidationMetadataProvider.CreateValidationMetadata(Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.ValidationMetadataProviderContext! context) -> void +Microsoft.AspNetCore.Mvc.NewtonsoftJson.NewtonsoftJsonValidationMetadataProvider.NewtonsoftJsonValidationMetadataProvider() -> void +Microsoft.AspNetCore.Mvc.NewtonsoftJson.NewtonsoftJsonValidationMetadataProvider.NewtonsoftJsonValidationMetadataProvider(Newtonsoft.Json.Serialization.NamingStrategy! namingStrategy) -> void diff --git a/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonValidationMetadataProviderTest.cs b/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonValidationMetadataProviderTest.cs new file mode 100644 index 000000000000..bdf7ff613981 --- /dev/null +++ b/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonValidationMetadataProviderTest.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson; + +public class NewtonsoftJsonValidationMetadataProviderTest +{ + [Fact] + public void CreateValidationMetadata_SetValidationPropertyName_WithJsonPropertyNameAttribute() + { + var metadataProvider = new NewtonsoftJsonValidationMetadataProvider(); + var propertyName = "sample-data"; + + var key = ModelMetadataIdentity.ForProperty(typeof(SampleTestClass).GetProperty(nameof(SampleTestClass.NoAttributesProperty)), typeof(int), typeof(SampleTestClass)); + var modelAttributes = new ModelAttributes(Array.Empty(), new[] { new JsonPropertyAttribute() { PropertyName = propertyName } }, Array.Empty()); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.NotNull(context.ValidationMetadata.ValidationModelName); + Assert.Equal(propertyName, context.ValidationMetadata.ValidationModelName); + } + + [Fact] + public void CreateValidationMetadata_SetValidationPropertyName_CamelCaseWithDefaultNamingPolicy() + { + var metadataProvider = new NewtonsoftJsonValidationMetadataProvider(); + var propertyName = nameof(SampleTestClass.NoAttributesProperty); + + var key = ModelMetadataIdentity.ForProperty(typeof(SampleTestClass).GetProperty(propertyName), typeof(int), typeof(SampleTestClass)); + var modelAttributes = new ModelAttributes(Array.Empty(), Array.Empty(), Array.Empty()); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.NotNull(context.ValidationMetadata.ValidationModelName); + Assert.Equal(new CamelCaseNamingStrategy().GetPropertyName(propertyName, false), context.ValidationMetadata.ValidationModelName); + } + + [Theory] + [MemberData(nameof(NamingPolicies))] + public void CreateValidationMetadata_SetValidationPropertyName_WithJsonNamingPolicy(NamingStrategy namingStrategy) + { + var metadataProvider = new NewtonsoftJsonValidationMetadataProvider(namingStrategy); + var propertyName = nameof(SampleTestClass.NoAttributesProperty); + + var key = ModelMetadataIdentity.ForProperty(typeof(SampleTestClass).GetProperty(propertyName), typeof(int), typeof(SampleTestClass)); + var modelAttributes = new ModelAttributes(Array.Empty(), Array.Empty(), Array.Empty()); + var context = new ValidationMetadataProviderContext(key, modelAttributes); + + // Act + metadataProvider.CreateValidationMetadata(context); + + // Assert + Assert.NotNull(context.ValidationMetadata.ValidationModelName); + Assert.Equal(namingStrategy.GetPropertyName(propertyName, false), context.ValidationMetadata.ValidationModelName); + } + + public static TheoryData NamingPolicies + { + get + { + return new TheoryData + { + new UpperCaseJsonNamingPolicy(), + new CamelCaseNamingStrategy() + }; + } + } + + public class UpperCaseJsonNamingPolicy : NamingStrategy + { + protected override string ResolvePropertyName(string name) => name?.ToUpperInvariant(); + } + + public class SampleTestClass + { + public int NoAttributesProperty { get; set; } + } +}