Support overrides for client-side validation messages

- #2969
- `RemoteAttribute` did not support `IStringLocalizer` overrides
 - use same `MvcDataAnnotationsLocalizationOptions` property as for other `ValidationAttribute`s
- error message `NumericClientModelValidator` added could not be overridden
 - not related to `IStringLocalizer` because users have no way to set the resource lookup key
 - extend `IModelBindingMessageProvider` to add the necessary `Func<,>`
- also correct problem using resources with `RemoteAttribute` and add lots of tests
This commit is contained in:
Doug Bunting 2016-01-28 22:26:38 -08:00
parent d6843b5a9d
commit 26a14a25ab
18 changed files with 631 additions and 158 deletions

View File

@ -51,5 +51,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
/// </summary>
/// <value>Default <see cref="string"/> is "The value '{0}' is invalid.".</value>
Func<string, string> ValueIsInvalidAccessor { get; }
/// <summary>
/// Error message HTML and tag helpers add for client-side validation of numeric formats. Visible in the
/// browser if the field for a <c>float</c> property (for example) does not have a correctly-formatted value.
/// </summary>
/// <value>Default <see cref="string"/> is "The field {0} must be a number.".</value>
Func<string, string> ValueMustBeANumberAccessor { get; }
}
}

View File

@ -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());

View File

@ -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,
};
}
}

View File

@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
private Func<string, string, string> _attemptedValueIsInvalidAccessor;
private Func<string, string> _unknownValueIsInvalidAccessor;
private Func<string, string> _valueIsInvalidAccessor;
private Func<string, string> _valueMustBeANumberAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="ModelBindingMessageProvider"/> class.
@ -42,6 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
AttemptedValueIsInvalidAccessor = originalProvider.AttemptedValueIsInvalidAccessor;
UnknownValueIsInvalidAccessor = originalProvider.UnknownValueIsInvalidAccessor;
ValueIsInvalidAccessor = originalProvider.ValueIsInvalidAccessor;
ValueMustBeANumberAccessor = originalProvider.ValueMustBeANumberAccessor;
}
/// <inheritdoc/>
@ -151,5 +153,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
_valueIsInvalidAccessor = value;
}
}
/// <inheritdoc/>
public Func<string, string> ValueMustBeANumberAccessor
{
get
{
return _valueMustBeANumberAccessor;
}
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
_valueMustBeANumberAccessor = value;
}
}
}
}

View File

@ -1002,38 +1002,6 @@ namespace Microsoft.AspNetCore.Mvc.Core
return GetString("UrlNotLocal");
}
/// <summary>
/// The stream must support reading.
/// </summary>
internal static string HttpRequestStreamReader_StreamNotReadable
{
get { return GetString("HttpRequestStreamReader_StreamNotReadable"); }
}
/// <summary>
/// The stream must support reading.
/// </summary>
internal static string FormatHttpRequestStreamReader_StreamNotReadable()
{
return GetString("HttpRequestStreamReader_StreamNotReadable");
}
/// <summary>
/// The stream must support writing.
/// </summary>
internal static string HttpResponseStreamWriter_StreamNotWritable
{
get { return GetString("HttpResponseStreamWriter_StreamNotWritable"); }
}
/// <summary>
/// The stream must support writing.
/// </summary>
internal static string FormatHttpResponseStreamWriter_StreamNotWritable()
{
return GetString("HttpResponseStreamWriter_StreamNotWritable");
}
/// <summary>
/// The argument '{0}' is invalid. Empty or null formats are not supported.
/// </summary>
@ -1114,6 +1082,22 @@ namespace Microsoft.AspNetCore.Mvc.Core
return string.Format(CultureInfo.CurrentCulture, GetString("HtmlGeneration_ValueIsInvalid"), p0);
}
/// <summary>
/// The field {0} must be a number.
/// </summary>
internal static string HtmlGeneration_ValueMustBeNumber
{
get { return GetString("HtmlGeneration_ValueMustBeNumber"); }
}
/// <summary>
/// The field {0} must be a number.
/// </summary>
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);

View File

@ -328,4 +328,7 @@
<data name="HtmlGeneration_ValueIsInvalid" xml:space="preserve">
<value>The value '{0}' is invalid.</value>
</data>
<data name="HtmlGeneration_ValueMustBeNumber" xml:space="preserve">
<value>The field {0} must be a number.</value>
</data>
</root>

View File

@ -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());
}
}
}

View File

@ -42,22 +42,6 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations
return GetString("ArgumentCannotBeNullOrEmpty");
}
/// <summary>
/// The field {0} must be a number.
/// </summary>
internal static string NumericClientModelValidator_FieldMustBeNumber
{
get { return GetString("NumericClientModelValidator_FieldMustBeNumber"); }
}
/// <summary>
/// The field {0} must be a number.
/// </summary>
internal static string FormatNumericClientModelValidator_FieldMustBeNumber(object p0)
{
return string.Format(CultureInfo.CurrentCulture, GetString("NumericClientModelValidator_FieldMustBeNumber"), p0);
}
/// <summary>
/// The '{0}' property of '{1}' must not be null.
/// </summary>

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@ -123,9 +123,6 @@
<data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve">
<value>Value cannot be null or empty.</value>
</data>
<data name="NumericClientModelValidator_FieldMustBeNumber" xml:space="preserve">
<value>The field {0} must be a number.</value>
</data>
<data name="PropertyOfTypeCannotBeNull" xml:space="preserve">
<value>The '{0}' property of '{1}' must not be null.</value>
</data>

View File

@ -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;
/// <summary>
/// Initializes a new instance of the <see cref="RemoteAttribute"/> class.
@ -33,7 +37,7 @@ namespace Microsoft.AspNetCore.Mvc
/// Intended for subclasses that support URL generation with no route, action, or controller names.
/// </remarks>
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<IOptions<MvcDataAnnotationsLocalizationOptions>>();
var factory = services.GetService<IStringLocalizerFactory>();
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);
}
}
}

View File

@ -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 });

View File

@ -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,
};
}

View File

@ -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.",
};
}

View File

@ -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()

View File

@ -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.",
};
}

View File

@ -58,6 +58,22 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test
return GetString("DisplayAttribute_Name");
}
/// <summary>
/// Error about '{0}' from resources.
/// </summary>
internal static string RemoteAttribute_Error
{
get { return GetString("RemoteAttribute_Error"); }
}
/// <summary>
/// Error about '{0}' from resources.
/// </summary>
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);

View File

@ -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<string> 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<IOptions<MvcDataAnnotationsLocalizationOptions>>();
var localizer = new Mock<IStringLocalizer>(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<IStringLocalizer>(MockBehavior.Strict);
localizer
.Setup(l => l["Error about '{0}' from override.", "Length"])
.Returns(localizedString)
.Verifiable();
var options = context.ActionContext.HttpContext.RequestServices
.GetRequiredService<IOptions<MvcDataAnnotationsLocalizationOptions>>();
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<IStringLocalizer>(MockBehavior.Strict);
var options = context.ActionContext.HttpContext.RequestServices
.GetRequiredService<IOptions<MvcDataAnnotationsLocalizationOptions>>();
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<IStringLocalizer>(MockBehavior.Strict);
localizer
.Setup(l => l["Error about '{0}' from override.", "Display Length"])
.Returns(localizedString)
.Verifiable();
var options = context.ActionContext.HttpContext.RequestServices
.GetRequiredService<IOptions<MvcDataAnnotationsLocalizationOptions>>();
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<IUrlHelperFactory>();
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<IStringLocalizerFactory>(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<IUrlHelperFactory>(MockBehavior.Strict);
serviceCollection.AddSingleton<IUrlHelperFactory>(factory.Object);
var serviceProvider = serviceCollection.BuildServiceProvider();
var actionContext = GetActionContext(serviceProvider, routeData: null);
factory
.Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>()))
.Setup(f => f.GetUrlHelper(actionContext))
.Returns(urlHelper);
var serviceCollection = GetServiceCollection();
serviceCollection.AddSingleton<IUrlHelperFactory>(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<IUrlHelperFactory>();
var urlHelper = new UrlHelper(actionContext);
var factory = new Mock<IUrlHelperFactory>(MockBehavior.Strict);
factory
.Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>()))
.Setup(f => f.GetUrlHelper(actionContext))
.Returns(urlHelper);
// Make an IUrlHelperFactory available through the ActionContext.
serviceCollection.AddSingleton<IUrlHelperFactory>(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<IApplicationBuilder>();
var app = new Mock<IApplicationBuilder>(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<ILoggerFactory>(new NullLoggerFactory());
serviceCollection.AddSingleton<UrlEncoder>(new UrlTestEncoder());
var routeOptions = new RouteOptions();
var accessor = new Mock<IOptions<RouteOptions>>();
accessor
.SetupGet(options => options.Value)
.Returns(routeOptions);
serviceCollection.AddSingleton<IOptions<RouteOptions>>(accessor.Object);
serviceCollection.AddOptions();
serviceCollection.AddRouting();
serviceCollection.AddSingleton<IInlineConstraintResolver>(
new DefaultInlineConstraintResolver(accessor.Object));
provider => new DefaultInlineConstraintResolver(provider.GetRequiredService<IOptions<RouteOptions>>()));
if (localizerFactory != null)
{
serviceCollection.AddSingleton<IStringLocalizerFactory>(localizerFactory);
}
return serviceCollection;
}

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@ -126,4 +126,7 @@
<data name="DisplayAttribute_Name" xml:space="preserve">
<value>name from resources</value>
</data>
<data name="RemoteAttribute_Error" xml:space="preserve">
<value>Error about '{0}' from resources.</value>
</data>
</root>