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