diff --git a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs index 81e3c22594..3a3bf23e12 100644 --- a/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/Metadata/IModelBindingMessageProvider.cs @@ -51,5 +51,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// /// Default is "The value '{0}' is invalid.". Func ValueIsInvalidAccessor { get; } + + /// + /// Error message HTML and tag helpers add for client-side validation of numeric formats. Visible in the + /// browser if the field for a float property (for example) does not have a correctly-formatted value. + /// + /// Default is "The field {0} must be a number.". + Func ValueMustBeANumberAccessor { get; } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs index b73a8797c4..2cda9e1203 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcCoreMvcOptionsSetup.cs @@ -34,6 +34,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal messageProvider.AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid; messageProvider.UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid; messageProvider.ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid; + messageProvider.ValueMustBeANumberAccessor = Resources.FormatHtmlGeneration_ValueMustBeNumber; // Set up ModelBinding options.ModelBinders.Add(new BinderTypeBasedModelBinder()); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/EmptyModelMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/EmptyModelMetadataProvider.cs index 9a9405fc87..1f5bef2ad1 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/EmptyModelMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/EmptyModelMetadataProvider.cs @@ -44,6 +44,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid, UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid, ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid, + ValueMustBeANumberAccessor = Resources.FormatHtmlGeneration_ValueMustBeNumber, }; } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs index 9803048088..4f1b67f65c 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Metadata/ModelBindingMessageProvider.cs @@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata private Func _attemptedValueIsInvalidAccessor; private Func _unknownValueIsInvalidAccessor; private Func _valueIsInvalidAccessor; + private Func _valueMustBeANumberAccessor; /// /// Initializes a new instance of the class. @@ -42,6 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata AttemptedValueIsInvalidAccessor = originalProvider.AttemptedValueIsInvalidAccessor; UnknownValueIsInvalidAccessor = originalProvider.UnknownValueIsInvalidAccessor; ValueIsInvalidAccessor = originalProvider.ValueIsInvalidAccessor; + ValueMustBeANumberAccessor = originalProvider.ValueMustBeANumberAccessor; } /// @@ -151,5 +153,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata _valueIsInvalidAccessor = value; } } + + /// + public Func ValueMustBeANumberAccessor + { + get + { + return _valueMustBeANumberAccessor; + } + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _valueMustBeANumberAccessor = value; + } + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs index a5c4c6821a..65d7e0b324 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs @@ -1002,38 +1002,6 @@ namespace Microsoft.AspNetCore.Mvc.Core return GetString("UrlNotLocal"); } - /// - /// The stream must support reading. - /// - internal static string HttpRequestStreamReader_StreamNotReadable - { - get { return GetString("HttpRequestStreamReader_StreamNotReadable"); } - } - - /// - /// The stream must support reading. - /// - internal static string FormatHttpRequestStreamReader_StreamNotReadable() - { - return GetString("HttpRequestStreamReader_StreamNotReadable"); - } - - /// - /// The stream must support writing. - /// - internal static string HttpResponseStreamWriter_StreamNotWritable - { - get { return GetString("HttpResponseStreamWriter_StreamNotWritable"); } - } - - /// - /// The stream must support writing. - /// - internal static string FormatHttpResponseStreamWriter_StreamNotWritable() - { - return GetString("HttpResponseStreamWriter_StreamNotWritable"); - } - /// /// The argument '{0}' is invalid. Empty or null formats are not supported. /// @@ -1114,6 +1082,22 @@ namespace Microsoft.AspNetCore.Mvc.Core return string.Format(CultureInfo.CurrentCulture, GetString("HtmlGeneration_ValueIsInvalid"), p0); } + /// + /// The field {0} must be a number. + /// + internal static string HtmlGeneration_ValueMustBeNumber + { + get { return GetString("HtmlGeneration_ValueMustBeNumber"); } + } + + /// + /// The field {0} must be a number. + /// + internal static string FormatHtmlGeneration_ValueMustBeNumber(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("HtmlGeneration_ValueMustBeNumber"), p0); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx index 7a5bf847d5..9bb383b191 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx @@ -328,4 +328,7 @@ The value '{0}' is invalid. + + The field {0} must be a number. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/NumericClientModelValidator.cs b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/NumericClientModelValidator.cs index c54463cde4..5e9c455278 100644 --- a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/NumericClientModelValidator.cs +++ b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Internal/NumericClientModelValidator.cs @@ -44,7 +44,8 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal throw new ArgumentNullException(nameof(modelMetadata)); } - return Resources.FormatNumericClientModelValidator_FieldMustBeNumber(modelMetadata.GetDisplayName()); + return modelMetadata.ModelBindingMessageProvider.ValueMustBeANumberAccessor( + modelMetadata.GetDisplayName()); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/Resources.Designer.cs index 5458bf8898..e392622b86 100644 --- a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Properties/Resources.Designer.cs @@ -42,22 +42,6 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations return GetString("ArgumentCannotBeNullOrEmpty"); } - /// - /// The field {0} must be a number. - /// - internal static string NumericClientModelValidator_FieldMustBeNumber - { - get { return GetString("NumericClientModelValidator_FieldMustBeNumber"); } - } - - /// - /// The field {0} must be a number. - /// - internal static string FormatNumericClientModelValidator_FieldMustBeNumber(object p0) - { - return string.Format(CultureInfo.CurrentCulture, GetString("NumericClientModelValidator_FieldMustBeNumber"), p0); - } - /// /// The '{0}' property of '{1}' must not be null. /// diff --git a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Resources.resx b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Resources.resx index 7d6a755eae..1c0fc25774 100644 --- a/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.DataAnnotations/Resources.resx @@ -1,17 +1,17 @@  - @@ -123,9 +123,6 @@ Value cannot be null or empty. - - The field {0} must be a number. - The '{0}' property of '{1}' must not be null. diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/RemoteAttribute.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/RemoteAttribute.cs index 2aac80b597..9ab924468b 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/RemoteAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/RemoteAttribute.cs @@ -6,12 +6,14 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc { @@ -25,6 +27,8 @@ namespace Microsoft.AspNetCore.Mvc { private string _additionalFields = string.Empty; private string[] _additionalFieldsSplit = new string[0]; + private bool _checkedForLocalizer; + private IStringLocalizer _stringLocalizer; /// /// Initializes a new instance of the class. @@ -33,7 +37,7 @@ namespace Microsoft.AspNetCore.Mvc /// Intended for subclasses that support URL generation with no route, action, or controller names. /// protected RemoteAttribute() - : base(Resources.RemoteAttribute_RemoteValidationFailed) + : base(errorMessageAccessor: () => Resources.RemoteAttribute_RemoteValidationFailed) { RouteData = new RouteValueDictionary(); } @@ -241,8 +245,10 @@ namespace Microsoft.AspNetCore.Mvc MergeAttribute(context.Attributes, "data-val", "true"); - var errorMessage = FormatErrorMessage(context.ModelMetadata.GetDisplayName()); + CheckForLocalizer(context); + var errorMessage = GetErrorMessage(context.ModelMetadata.GetDisplayName()); MergeAttribute(context.Attributes, "data-val-remote", errorMessage); + MergeAttribute(context.Attributes, "data-val-remote-url", GetUrl(context)); if (!string.IsNullOrEmpty(HttpMethod)) @@ -272,10 +278,45 @@ namespace Microsoft.AspNetCore.Mvc return new string[0]; } - var split = original.Split(',') - .Select(piece => piece.Trim()) - .Where(trimmed => !string.IsNullOrEmpty(trimmed)); + var split = original + .Split(',') + .Select(piece => piece.Trim()) + .Where(trimmed => !string.IsNullOrEmpty(trimmed)); + return split; } + + private void CheckForLocalizer(ClientModelValidationContext context) + { + if (!_checkedForLocalizer) + { + _checkedForLocalizer = true; + + var services = context.ActionContext.HttpContext.RequestServices; + var options = services.GetRequiredService>(); + var factory = services.GetService(); + + var provider = options.Value.DataAnnotationLocalizerProvider; + if (factory != null && provider != null) + { + _stringLocalizer = provider( + context.ModelMetadata.ContainerType ?? context.ModelMetadata.ModelType, + factory); + } + } + } + + private string GetErrorMessage(string displayName) + { + if (_stringLocalizer != null && + !string.IsNullOrEmpty(ErrorMessage) && + string.IsNullOrEmpty(ErrorMessageResourceName) && + ErrorMessageResourceType == null) + { + return _stringLocalizer[ErrorMessage, displayName]; + } + + return FormatErrorMessage(displayName); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelStateDictionaryTest.cs b/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelStateDictionaryTest.cs index 8cdae006ec..c507471ca7 100644 --- a/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelStateDictionaryTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelStateDictionaryTest.cs @@ -754,6 +754,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding (value, name) => "Unexpected InvalidValueWithKnownAttemptedValueAccessor use", UnknownValueIsInvalidAccessor = name => $"Hmm, the supplied value is not valid for { name }.", ValueIsInvalidAccessor = value => "Unexpected InvalidValueWithUnknownModelErrorAccessor use", + ValueMustBeANumberAccessor = name => "Unexpected ValueMustBeANumberAccessor use", }; var bindingMetadataProvider = new DefaultBindingMetadataProvider(messageProvider); var compositeProvider = new DefaultCompositeMetadataDetailsProvider(new[] { bindingMetadataProvider }); @@ -805,6 +806,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding (value, name) => $"Hmm, the value '{ value }' is not valid for { name }.", UnknownValueIsInvalidAccessor = name => "Unexpected InvalidValueWithUnknownAttemptedValueAccessor use", ValueIsInvalidAccessor = value => "Unexpected InvalidValueWithUnknownModelErrorAccessor use", + ValueMustBeANumberAccessor = name => "Unexpected ValueMustBeANumberAccessor use", }; var bindingMetadataProvider = new DefaultBindingMetadataProvider(messageProvider); var compositeProvider = new DefaultCompositeMetadataDetailsProvider(new[] { bindingMetadataProvider }); diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultBindingMetadataProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultBindingMetadataProviderTest.cs index 58b89cbf05..4390a3f99c 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultBindingMetadataProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/DefaultBindingMetadataProviderTest.cs @@ -527,6 +527,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid, UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid, ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid, + ValueMustBeANumberAccessor = Resources.FormatHtmlGeneration_ValueMustBeNumber, }; } diff --git a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs index 7ce894697a..4f49c0d808 100644 --- a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/ModelMetadataProviderTest.cs @@ -1067,6 +1067,7 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal (value, name) => $"The value '{ value }' is not valid for { name }.", UnknownValueIsInvalidAccessor = name => $"The supplied value is invalid for { name }.", ValueIsInvalidAccessor = value => $"The value '{ value }' is invalid.", + ValueMustBeANumberAccessor = name => $"The field { name } must be a number.", }; } diff --git a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs index 2e25a42e0f..348f7d63ab 100644 --- a/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.DataAnnotations.Test/Internal/NumericClientModelValidatorTest.cs @@ -37,6 +37,35 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal kvp => { Assert.Equal("data-val-number", kvp.Key); Assert.Equal(expectedMessage, kvp.Value); }); } + [Fact] + public void AddValidation_CorrectValidationTypeAndOverriddenErrorMessage() + { + // Arrange + var expectedMessage = "Error message about 'DisplayId' from override."; + var provider = new TestModelMetadataProvider(); + provider + .ForProperty(typeof(TypeWithNumericProperty), nameof(TypeWithNumericProperty.Id)) + .BindingDetails(d => d.ModelBindingMessageProvider.ValueMustBeANumberAccessor = + name => $"Error message about '{ name }' from override."); + var metadata = provider.GetMetadataForProperty( + typeof(TypeWithNumericProperty), + nameof(TypeWithNumericProperty.Id)); + + var adapter = new NumericClientModelValidator(); + + var actionContext = new ActionContext(); + var context = new ClientModelValidationContext(actionContext, metadata, provider, new AttributeDictionary()); + + // Act + adapter.AddValidation(context); + + // Assert + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => { Assert.Equal("data-val-number", kvp.Key); Assert.Equal(expectedMessage, kvp.Value); }); + } + [Fact] [ReplaceCulture] public void AddValidation_DoesNotTrounceExistingAttributes() diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs index 5ee3f16c12..3249a45931 100644 --- a/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs +++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/TestModelMetadataProvider.cs @@ -116,6 +116,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding AttemptedValueIsInvalidAccessor = (value, name) => $"The value '{ value }' is not valid for { name }.", UnknownValueIsInvalidAccessor = name => $"The supplied value is invalid for { name }.", ValueIsInvalidAccessor = value => $"The value '{ value }' is invalid.", + ValueMustBeANumberAccessor = name => $"The field { name } must be a number.", }; } diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Properties/Resources.Designer.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Properties/Resources.Designer.cs index 39ba258d38..cfa3a7c9af 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Properties/Resources.Designer.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Properties/Resources.Designer.cs @@ -58,6 +58,22 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test return GetString("DisplayAttribute_Name"); } + /// + /// Error about '{0}' from resources. + /// + internal static string RemoteAttribute_Error + { + get { return GetString("RemoteAttribute_Error"); } + } + + /// + /// Error about '{0}' from resources. + /// + internal static string FormatRemoteAttribute_Error(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("RemoteAttribute_Error"), p0); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs index 9c702a1186..b9fabc1c8f 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/RemoteAttributeTest.cs @@ -6,18 +6,22 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Microsoft.Extensions.WebEncoders.Testing; using Moq; using Xunit; +using Resources = Microsoft.AspNetCore.Mvc.ViewFeatures.Test.Resources; namespace Microsoft.AspNetCore.Mvc { @@ -26,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc private static readonly IModelMetadataProvider _metadataProvider = new EmptyModelMetadataProvider(); private static readonly ModelMetadata _metadata = _metadataProvider.GetMetadataForProperty( typeof(string), - "Length"); + nameof(string.Length)); public static TheoryData SomeNames { @@ -59,8 +63,8 @@ namespace Microsoft.AspNetCore.Mvc public void IsValidAlwaysReturnsTrue() { // Act & Assert - Assert.True(new RemoteAttribute("RouteName", "ParameterName").IsValid(null)); - Assert.True(new RemoteAttribute("ActionName", "ControllerName", "ParameterName").IsValid(null)); + Assert.True(new RemoteAttribute("RouteName", "ParameterName").IsValid(value: null)); + Assert.True(new RemoteAttribute("ActionName", "ControllerName", "ParameterName").IsValid(value: null)); } [Fact] @@ -155,6 +159,70 @@ namespace Microsoft.AspNetCore.Mvc Assert.Null(attribute.RouteName); } + [Fact] + public void ErrorMessageProperties_HaveExpectedDefaultValues() + { + // Arrange & Act + var attribute = new RemoteAttribute("Action", "Controller"); + + // Assert + Assert.Null(attribute.ErrorMessage); + Assert.Null(attribute.ErrorMessageResourceName); + Assert.Null(attribute.ErrorMessageResourceType); + } + + [Fact] + [ReplaceCulture] + public void FormatErrorMessage_ReturnsDefaultErrorMessage() + { + // Arrange + // See ViewFeatures.Resources.RemoteAttribute_RemoteValidationFailed. + var expected = "'Property1' is invalid."; + var attribute = new RemoteAttribute("Action", "Controller"); + + // Act + var message = attribute.FormatErrorMessage("Property1"); + + // Assert + Assert.Equal(expected, message); + } + + [Fact] + public void FormatErrorMessage_UsesOverriddenErrorMessage() + { + // Arrange + var expected = "Error about 'Property1' from override."; + var attribute = new RemoteAttribute("Action", "Controller") + { + ErrorMessage = "Error about '{0}' from override.", + }; + + // Act + var message = attribute.FormatErrorMessage("Property1"); + + // Assert + Assert.Equal(expected, message); + } + + [Fact] + [ReplaceCulture] + public void FormatErrorMessage_UsesErrorMessageFromResource() + { + // Arrange + var expected = "Error about 'Property1' from resources."; + var attribute = new RemoteAttribute("Action", "Controller") + { + ErrorMessageResourceName = nameof(Resources.RemoteAttribute_Error), + ErrorMessageResourceType = typeof(Resources), + }; + + // Act + var message = attribute.FormatErrorMessage("Property1"); + + // Assert + Assert.Equal(expected, message); + } + [Theory] [MemberData(nameof(NullOrEmptyNames))] public void FormatAdditionalFieldsForClientValidation_WithInvalidPropertyName_Throws(string property) @@ -197,6 +265,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] + [ReplaceCulture] public void AddValidation_WithRoute_CallsUrlHelperWithExpectedValues() { // Arrange @@ -222,6 +291,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] + [ReplaceCulture] public void AddValidation_WithActionController_CallsUrlHelperWithExpectedValues() { // Arrange @@ -248,6 +318,7 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] + [ReplaceCulture] public void AddValidation_WithActionController_PropertiesSet_CallsUrlHelperWithExpectedValues() { // Arrange @@ -283,6 +354,294 @@ namespace Microsoft.AspNetCore.Mvc } [Fact] + public void AddValidation_WithErrorMessage_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Length' from override."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessage = "Error about '{0}' from override.", + }; + var url = "/Controller/Action"; + var context = GetValidationContext(url); + + // Act + attribute.AddValidation(context); + + // Assert + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + public void AddValidation_WithErrorMessageAndLocalizerFactory_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Length' from override."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessage = "Error about '{0}' from override.", + }; + var url = "/Controller/Action"; + var context = GetValidationContextWithLocalizerFactory(url); + + // Act + attribute.AddValidation(context); + + // Assert + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + // IStringLocalizerFactory existence alone is insufficient to change error message. + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + public void AddValidation_WithErrorMessageAndLocalizerProvider_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Length' from override."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessage = "Error about '{0}' from override.", + }; + var url = "/Controller/Action"; + var context = GetValidationContext(url); + + var options = context.ActionContext.HttpContext.RequestServices + .GetRequiredService>(); + var localizer = new Mock(MockBehavior.Strict); + options.Value.DataAnnotationLocalizerProvider = (type, factory) => localizer.Object; + + // Act + attribute.AddValidation(context); + + // Assert + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + // Non-null DataAnnotationLocalizerProvider alone is insufficient to change error message. + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + public void AddValidation_WithErrorMessageLocalizerFactoryAndLocalizerProvider_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Length' from localizer."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessage = "Error about '{0}' from override.", + }; + var url = "/Controller/Action"; + var context = GetValidationContextWithLocalizerFactory(url); + + var localizedString = new LocalizedString("Fred", expected); + var localizer = new Mock(MockBehavior.Strict); + localizer + .Setup(l => l["Error about '{0}' from override.", "Length"]) + .Returns(localizedString) + .Verifiable(); + var options = context.ActionContext.HttpContext.RequestServices + .GetRequiredService>(); + options.Value.DataAnnotationLocalizerProvider = (type, factory) => localizer.Object; + + // Act + attribute.AddValidation(context); + + // Assert + localizer.VerifyAll(); + + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + [ReplaceCulture] + public void AddValidation_WithErrorResourcesLocalizerFactoryAndLocalizerProvider_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Length' from resources."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessageResourceName = nameof(Resources.RemoteAttribute_Error), + ErrorMessageResourceType = typeof(Resources), + }; + var url = "/Controller/Action"; + var context = GetValidationContextWithLocalizerFactory(url); + + var localizer = new Mock(MockBehavior.Strict); + var options = context.ActionContext.HttpContext.RequestServices + .GetRequiredService>(); + options.Value.DataAnnotationLocalizerProvider = (type, factory) => localizer.Object; + + // Act + attribute.AddValidation(context); + + // Assert + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + // Configuring the attribute using ErrorMessageResource* trumps available IStringLocalizer etc. + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + public void AddValidation_WithErrorMessageAndDisplayName_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Display Length' from override."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessage = "Error about '{0}' from override.", + }; + + var url = "/Controller/Action"; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForProperty(typeof(string), nameof(string.Length)) + .DisplayDetails(d => d.DisplayName = () => "Display Length"); + var context = GetValidationContext(url, metadataProvider); + + // Act + attribute.AddValidation(context); + + // Assert + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + public void AddValidation_WithErrorMessageLocalizerFactoryLocalizerProviderAndDisplayName_SetsAttributesAsExpected() + { + // Arrange + var expected = "Error about 'Length' from localizer."; + var attribute = new RemoteAttribute("Action", "Controller") + { + HttpMethod = "POST", + ErrorMessage = "Error about '{0}' from override.", + }; + + var url = "/Controller/Action"; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForProperty(typeof(string), nameof(string.Length)) + .DisplayDetails(d => d.DisplayName = () => "Display Length"); + var context = GetValidationContextWithLocalizerFactory(url, metadataProvider); + + var localizedString = new LocalizedString("Fred", expected); + var localizer = new Mock(MockBehavior.Strict); + localizer + .Setup(l => l["Error about '{0}' from override.", "Display Length"]) + .Returns(localizedString) + .Verifiable(); + var options = context.ActionContext.HttpContext.RequestServices + .GetRequiredService>(); + options.Value.DataAnnotationLocalizerProvider = (type, factory) => localizer.Object; + + // Act + attribute.AddValidation(context); + + // Assert + localizer.VerifyAll(); + + Assert.Collection( + context.Attributes, + kvp => { Assert.Equal("data-val", kvp.Key); Assert.Equal("true", kvp.Value); }, + kvp => + { + Assert.Equal("data-val-remote", kvp.Key); + Assert.Equal(expected, kvp.Value); + }, + kvp => + { + Assert.Equal("data-val-remote-additionalfields", kvp.Key); + Assert.Equal("*.Length", kvp.Value); + }, + kvp => { Assert.Equal("data-val-remote-type", kvp.Key); Assert.Equal("POST", kvp.Value); }, + kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal(url, kvp.Value); }); + } + + [Fact] + [ReplaceCulture] public void AddValidation_WithActionControllerArea_CallsUrlHelperWithExpectedValues() { // Arrange @@ -319,6 +678,7 @@ namespace Microsoft.AspNetCore.Mvc // Root area is current in this case. [Fact] + [ReplaceCulture] public void AddValidation_WithActionController_FindsControllerInCurrentArea() { // Arrange @@ -343,6 +703,7 @@ namespace Microsoft.AspNetCore.Mvc // Test area is current in this case. [Fact] + [ReplaceCulture] public void AddValidation_WithActionControllerInArea_FindsControllerInCurrentArea() { // Arrange @@ -368,6 +729,7 @@ namespace Microsoft.AspNetCore.Mvc // Explicit reference to the (current) root area. [Theory] [MemberData(nameof(NullOrEmptyNames))] + [ReplaceCulture] public void AddValidation_WithActionControllerArea_FindsControllerInRootArea(string areaName) { // Arrange @@ -393,6 +755,7 @@ namespace Microsoft.AspNetCore.Mvc // Test area is current in this case. [Theory] [MemberData(nameof(NullOrEmptyNames))] + [ReplaceCulture] public void AddValidation_WithActionControllerAreaInArea_FindsControllerInRootArea(string areaName) { // Arrange @@ -417,6 +780,7 @@ namespace Microsoft.AspNetCore.Mvc // Root area is current in this case. [Fact] + [ReplaceCulture] public void AddValidation_WithActionControllerArea_FindsControllerInNamedArea() { // Arrange @@ -441,6 +805,7 @@ namespace Microsoft.AspNetCore.Mvc // Explicit reference to the current (Test) area. [Fact] + [ReplaceCulture] public void AddValidation_WithActionControllerAreaInArea_FindsControllerInNamedArea() { // Arrange @@ -465,6 +830,7 @@ namespace Microsoft.AspNetCore.Mvc // Test area is current in this case. [Fact] + [ReplaceCulture] public void AddValidation_WithActionControllerAreaInArea_FindsControllerInDifferentArea() { // Arrange @@ -518,35 +884,59 @@ namespace Microsoft.AspNetCore.Mvc kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal("original", kvp.Value); }); } - private static ClientModelValidationContext GetValidationContext(IUrlHelper urlHelper) + private static ClientModelValidationContext GetValidationContext( + string url, + IModelMetadataProvider metadataProvider = null) { - var factory = new Mock(); + var urlHelper = new MockUrlHelper(url, routeName: null); + return GetValidationContext(urlHelper, localizerFactory: null, metadataProvider: metadataProvider); + } + + private static ClientModelValidationContext GetValidationContextWithLocalizerFactory( + string url, + IModelMetadataProvider metadataProvider = null) + { + var urlHelper = new MockUrlHelper(url, routeName: null); + var localizerFactory = new Mock(MockBehavior.Strict); + return GetValidationContext(urlHelper, localizerFactory.Object, metadataProvider); + } + + private static ClientModelValidationContext GetValidationContext( + IUrlHelper urlHelper, + IStringLocalizerFactory localizerFactory = null, + IModelMetadataProvider metadataProvider = null) + { + var serviceCollection = GetServiceCollection(localizerFactory); + var factory = new Mock(MockBehavior.Strict); + serviceCollection.AddSingleton(factory.Object); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + var actionContext = GetActionContext(serviceProvider, routeData: null); + factory - .Setup(f => f.GetUrlHelper(It.IsAny())) + .Setup(f => f.GetUrlHelper(actionContext)) .Returns(urlHelper); - var serviceCollection = GetServiceCollection(); - serviceCollection.AddSingleton(factory.Object); - var serviceProvider = serviceCollection.BuildServiceProvider(); - - var actionContext = new ActionContext() + var metadata = _metadata; + if (metadataProvider == null) { - HttpContext = new DefaultHttpContext() - { - RequestServices = serviceProvider, - }, - }; + metadataProvider = _metadataProvider; + } + else + { + metadata = metadataProvider.GetMetadataForProperty(typeof(string), nameof(string.Length)); + } return new ClientModelValidationContext( actionContext, - _metadata, - _metadataProvider, + metadata, + metadataProvider, new AttributeDictionary()); } private static ClientModelValidationContext GetValidationContextWithArea(string currentArea) { - var serviceCollection = GetServiceCollection(); + var serviceCollection = GetServiceCollection(localizerFactory: null); var serviceProvider = serviceCollection.BuildServiceProvider(); var routeCollection = GetRouteCollectionWithArea(serviceProvider); var routeData = new RouteData @@ -566,24 +956,18 @@ namespace Microsoft.AspNetCore.Mvc routeData.Values["area"] = currentArea; } - var context = GetActionContext(serviceProvider, routeData); + var actionContext = GetActionContext(serviceProvider, routeData); - var urlHelper = new UrlHelper(context); - var factory = new Mock(); + var urlHelper = new UrlHelper(actionContext); + var factory = new Mock(MockBehavior.Strict); factory - .Setup(f => f.GetUrlHelper(It.IsAny())) + .Setup(f => f.GetUrlHelper(actionContext)) .Returns(urlHelper); + // Make an IUrlHelperFactory available through the ActionContext. serviceCollection.AddSingleton(factory.Object); serviceProvider = serviceCollection.BuildServiceProvider(); - - var actionContext = new ActionContext() - { - HttpContext = new DefaultHttpContext() - { - RequestServices = serviceProvider, - }, - }; + actionContext.HttpContext.RequestServices = serviceProvider; return new ClientModelValidationContext( actionContext, @@ -615,7 +999,7 @@ namespace Microsoft.AspNetCore.Mvc private static RouteBuilder GetRouteBuilder(IServiceProvider serviceProvider) { - var app = new Mock(); + var app = new Mock(MockBehavior.Strict); app .SetupGet(a => a.ApplicationServices) .Returns(serviceProvider); @@ -631,9 +1015,7 @@ namespace Microsoft.AspNetCore.Mvc return builder; } - private static ActionContext GetActionContext( - IServiceProvider serviceProvider, - RouteData routeData = null) + private static ActionContext GetActionContext(IServiceProvider serviceProvider, RouteData routeData) { // Set IServiceProvider properties because TemplateRoute gets services (e.g. an ILoggerFactory instance) // through the HttpContext. @@ -653,23 +1035,22 @@ namespace Microsoft.AspNetCore.Mvc return new ActionContext(httpContext, routeData, new ActionDescriptor()); } - private static ServiceCollection GetServiceCollection() + private static ServiceCollection GetServiceCollection(IStringLocalizerFactory localizerFactory) { var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(new NullLoggerFactory()); serviceCollection.AddSingleton(new UrlTestEncoder()); - var routeOptions = new RouteOptions(); - var accessor = new Mock>(); - accessor - .SetupGet(options => options.Value) - .Returns(routeOptions); - - serviceCollection.AddSingleton>(accessor.Object); + serviceCollection.AddOptions(); serviceCollection.AddRouting(); serviceCollection.AddSingleton( - new DefaultInlineConstraintResolver(accessor.Object)); + provider => new DefaultInlineConstraintResolver(provider.GetRequiredService>())); + + if (localizerFactory != null) + { + serviceCollection.AddSingleton(localizerFactory); + } return serviceCollection; } diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Resources.resx b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Resources.resx index 9ddc5418f7..aee496cb91 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Resources.resx +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Resources.resx @@ -1,17 +1,17 @@  - @@ -126,4 +126,7 @@ name from resources + + Error about '{0}' from resources. + \ No newline at end of file