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> /// </summary>
/// <value>Default <see cref="string"/> is "The value '{0}' is invalid.".</value> /// <value>Default <see cref="string"/> is "The value '{0}' is invalid.".</value>
Func<string, string> ValueIsInvalidAccessor { get; } 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.AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid;
messageProvider.UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid; messageProvider.UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid;
messageProvider.ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid; messageProvider.ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid;
messageProvider.ValueMustBeANumberAccessor = Resources.FormatHtmlGeneration_ValueMustBeNumber;
// Set up ModelBinding // Set up ModelBinding
options.ModelBinders.Add(new BinderTypeBasedModelBinder()); options.ModelBinders.Add(new BinderTypeBasedModelBinder());

View File

@ -44,6 +44,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding
AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid, AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid,
UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid, UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid,
ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid, 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, string> _attemptedValueIsInvalidAccessor;
private Func<string, string> _unknownValueIsInvalidAccessor; private Func<string, string> _unknownValueIsInvalidAccessor;
private Func<string, string> _valueIsInvalidAccessor; private Func<string, string> _valueIsInvalidAccessor;
private Func<string, string> _valueMustBeANumberAccessor;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ModelBindingMessageProvider"/> class. /// Initializes a new instance of the <see cref="ModelBindingMessageProvider"/> class.
@ -42,6 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
AttemptedValueIsInvalidAccessor = originalProvider.AttemptedValueIsInvalidAccessor; AttemptedValueIsInvalidAccessor = originalProvider.AttemptedValueIsInvalidAccessor;
UnknownValueIsInvalidAccessor = originalProvider.UnknownValueIsInvalidAccessor; UnknownValueIsInvalidAccessor = originalProvider.UnknownValueIsInvalidAccessor;
ValueIsInvalidAccessor = originalProvider.ValueIsInvalidAccessor; ValueIsInvalidAccessor = originalProvider.ValueIsInvalidAccessor;
ValueMustBeANumberAccessor = originalProvider.ValueMustBeANumberAccessor;
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -151,5 +153,23 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata
_valueIsInvalidAccessor = value; _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"); 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> /// <summary>
/// The argument '{0}' is invalid. Empty or null formats are not supported. /// The argument '{0}' is invalid. Empty or null formats are not supported.
/// </summary> /// </summary>
@ -1114,6 +1082,22 @@ namespace Microsoft.AspNetCore.Mvc.Core
return string.Format(CultureInfo.CurrentCulture, GetString("HtmlGeneration_ValueIsInvalid"), p0); 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) private static string GetString(string name, params string[] formatterNames)
{ {
var value = _resourceManager.GetString(name); var value = _resourceManager.GetString(name);

View File

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

View File

@ -44,7 +44,8 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal
throw new ArgumentNullException(nameof(modelMetadata)); 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"); 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> /// <summary>
/// The '{0}' property of '{1}' must not be null. /// The '{0}' property of '{1}' must not be null.
/// </summary> /// </summary>

View File

@ -123,9 +123,6 @@
<data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve"> <data name="ArgumentCannotBeNullOrEmpty" xml:space="preserve">
<value>Value cannot be null or empty.</value> <value>Value cannot be null or empty.</value>
</data> </data>
<data name="NumericClientModelValidator_FieldMustBeNumber" xml:space="preserve">
<value>The field {0} must be a number.</value>
</data>
<data name="PropertyOfTypeCannotBeNull" xml:space="preserve"> <data name="PropertyOfTypeCannotBeNull" xml:space="preserve">
<value>The '{0}' property of '{1}' must not be null.</value> <value>The '{0}' property of '{1}' must not be null.</value>
</data> </data>

View File

@ -6,12 +6,14 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc namespace Microsoft.AspNetCore.Mvc
{ {
@ -25,6 +27,8 @@ namespace Microsoft.AspNetCore.Mvc
{ {
private string _additionalFields = string.Empty; private string _additionalFields = string.Empty;
private string[] _additionalFieldsSplit = new string[0]; private string[] _additionalFieldsSplit = new string[0];
private bool _checkedForLocalizer;
private IStringLocalizer _stringLocalizer;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="RemoteAttribute"/> class. /// 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. /// Intended for subclasses that support URL generation with no route, action, or controller names.
/// </remarks> /// </remarks>
protected RemoteAttribute() protected RemoteAttribute()
: base(Resources.RemoteAttribute_RemoteValidationFailed) : base(errorMessageAccessor: () => Resources.RemoteAttribute_RemoteValidationFailed)
{ {
RouteData = new RouteValueDictionary(); RouteData = new RouteValueDictionary();
} }
@ -241,8 +245,10 @@ namespace Microsoft.AspNetCore.Mvc
MergeAttribute(context.Attributes, "data-val", "true"); 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", errorMessage);
MergeAttribute(context.Attributes, "data-val-remote-url", GetUrl(context)); MergeAttribute(context.Attributes, "data-val-remote-url", GetUrl(context));
if (!string.IsNullOrEmpty(HttpMethod)) if (!string.IsNullOrEmpty(HttpMethod))
@ -272,10 +278,45 @@ namespace Microsoft.AspNetCore.Mvc
return new string[0]; return new string[0];
} }
var split = original.Split(',') var split = original
.Select(piece => piece.Trim()) .Split(',')
.Where(trimmed => !string.IsNullOrEmpty(trimmed)); .Select(piece => piece.Trim())
.Where(trimmed => !string.IsNullOrEmpty(trimmed));
return split; 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", (value, name) => "Unexpected InvalidValueWithKnownAttemptedValueAccessor use",
UnknownValueIsInvalidAccessor = name => $"Hmm, the supplied value is not valid for { name }.", UnknownValueIsInvalidAccessor = name => $"Hmm, the supplied value is not valid for { name }.",
ValueIsInvalidAccessor = value => "Unexpected InvalidValueWithUnknownModelErrorAccessor use", ValueIsInvalidAccessor = value => "Unexpected InvalidValueWithUnknownModelErrorAccessor use",
ValueMustBeANumberAccessor = name => "Unexpected ValueMustBeANumberAccessor use",
}; };
var bindingMetadataProvider = new DefaultBindingMetadataProvider(messageProvider); var bindingMetadataProvider = new DefaultBindingMetadataProvider(messageProvider);
var compositeProvider = new DefaultCompositeMetadataDetailsProvider(new[] { bindingMetadataProvider }); 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 }.", (value, name) => $"Hmm, the value '{ value }' is not valid for { name }.",
UnknownValueIsInvalidAccessor = name => "Unexpected InvalidValueWithUnknownAttemptedValueAccessor use", UnknownValueIsInvalidAccessor = name => "Unexpected InvalidValueWithUnknownAttemptedValueAccessor use",
ValueIsInvalidAccessor = value => "Unexpected InvalidValueWithUnknownModelErrorAccessor use", ValueIsInvalidAccessor = value => "Unexpected InvalidValueWithUnknownModelErrorAccessor use",
ValueMustBeANumberAccessor = name => "Unexpected ValueMustBeANumberAccessor use",
}; };
var bindingMetadataProvider = new DefaultBindingMetadataProvider(messageProvider); var bindingMetadataProvider = new DefaultBindingMetadataProvider(messageProvider);
var compositeProvider = new DefaultCompositeMetadataDetailsProvider(new[] { bindingMetadataProvider }); var compositeProvider = new DefaultCompositeMetadataDetailsProvider(new[] { bindingMetadataProvider });

View File

@ -527,6 +527,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid, AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid,
UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid, UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid,
ValueIsInvalidAccessor = Resources.FormatHtmlGeneration_ValueIsInvalid, 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 }.", (value, name) => $"The value '{ value }' is not valid for { name }.",
UnknownValueIsInvalidAccessor = name => $"The supplied value is invalid for { name }.", UnknownValueIsInvalidAccessor = name => $"The supplied value is invalid for { name }.",
ValueIsInvalidAccessor = value => $"The value '{ value }' is invalid.", 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); }); 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] [Fact]
[ReplaceCulture] [ReplaceCulture]
public void AddValidation_DoesNotTrounceExistingAttributes() 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 }.", AttemptedValueIsInvalidAccessor = (value, name) => $"The value '{ value }' is not valid for { name }.",
UnknownValueIsInvalidAccessor = name => $"The supplied value is invalid for { name }.", UnknownValueIsInvalidAccessor = name => $"The supplied value is invalid for { name }.",
ValueIsInvalidAccessor = value => $"The value '{ value }' is invalid.", 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"); 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) private static string GetString(string name, params string[] formatterNames)
{ {
var value = _resourceManager.GetString(name); var value = _resourceManager.GetString(name);

View File

@ -6,18 +6,22 @@ using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.WebEncoders.Testing; using Microsoft.Extensions.WebEncoders.Testing;
using Moq; using Moq;
using Xunit; using Xunit;
using Resources = Microsoft.AspNetCore.Mvc.ViewFeatures.Test.Resources;
namespace Microsoft.AspNetCore.Mvc namespace Microsoft.AspNetCore.Mvc
{ {
@ -26,7 +30,7 @@ namespace Microsoft.AspNetCore.Mvc
private static readonly IModelMetadataProvider _metadataProvider = new EmptyModelMetadataProvider(); private static readonly IModelMetadataProvider _metadataProvider = new EmptyModelMetadataProvider();
private static readonly ModelMetadata _metadata = _metadataProvider.GetMetadataForProperty( private static readonly ModelMetadata _metadata = _metadataProvider.GetMetadataForProperty(
typeof(string), typeof(string),
"Length"); nameof(string.Length));
public static TheoryData<string> SomeNames public static TheoryData<string> SomeNames
{ {
@ -59,8 +63,8 @@ namespace Microsoft.AspNetCore.Mvc
public void IsValidAlwaysReturnsTrue() public void IsValidAlwaysReturnsTrue()
{ {
// Act & Assert // Act & Assert
Assert.True(new RemoteAttribute("RouteName", "ParameterName").IsValid(null)); Assert.True(new RemoteAttribute("RouteName", "ParameterName").IsValid(value: null));
Assert.True(new RemoteAttribute("ActionName", "ControllerName", "ParameterName").IsValid(null)); Assert.True(new RemoteAttribute("ActionName", "ControllerName", "ParameterName").IsValid(value: null));
} }
[Fact] [Fact]
@ -155,6 +159,70 @@ namespace Microsoft.AspNetCore.Mvc
Assert.Null(attribute.RouteName); 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] [Theory]
[MemberData(nameof(NullOrEmptyNames))] [MemberData(nameof(NullOrEmptyNames))]
public void FormatAdditionalFieldsForClientValidation_WithInvalidPropertyName_Throws(string property) public void FormatAdditionalFieldsForClientValidation_WithInvalidPropertyName_Throws(string property)
@ -197,6 +265,7 @@ namespace Microsoft.AspNetCore.Mvc
} }
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithRoute_CallsUrlHelperWithExpectedValues() public void AddValidation_WithRoute_CallsUrlHelperWithExpectedValues()
{ {
// Arrange // Arrange
@ -222,6 +291,7 @@ namespace Microsoft.AspNetCore.Mvc
} }
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionController_CallsUrlHelperWithExpectedValues() public void AddValidation_WithActionController_CallsUrlHelperWithExpectedValues()
{ {
// Arrange // Arrange
@ -248,6 +318,7 @@ namespace Microsoft.AspNetCore.Mvc
} }
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionController_PropertiesSet_CallsUrlHelperWithExpectedValues() public void AddValidation_WithActionController_PropertiesSet_CallsUrlHelperWithExpectedValues()
{ {
// Arrange // Arrange
@ -283,6 +354,294 @@ namespace Microsoft.AspNetCore.Mvc
} }
[Fact] [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() public void AddValidation_WithActionControllerArea_CallsUrlHelperWithExpectedValues()
{ {
// Arrange // Arrange
@ -319,6 +678,7 @@ namespace Microsoft.AspNetCore.Mvc
// Root area is current in this case. // Root area is current in this case.
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionController_FindsControllerInCurrentArea() public void AddValidation_WithActionController_FindsControllerInCurrentArea()
{ {
// Arrange // Arrange
@ -343,6 +703,7 @@ namespace Microsoft.AspNetCore.Mvc
// Test area is current in this case. // Test area is current in this case.
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionControllerInArea_FindsControllerInCurrentArea() public void AddValidation_WithActionControllerInArea_FindsControllerInCurrentArea()
{ {
// Arrange // Arrange
@ -368,6 +729,7 @@ namespace Microsoft.AspNetCore.Mvc
// Explicit reference to the (current) root area. // Explicit reference to the (current) root area.
[Theory] [Theory]
[MemberData(nameof(NullOrEmptyNames))] [MemberData(nameof(NullOrEmptyNames))]
[ReplaceCulture]
public void AddValidation_WithActionControllerArea_FindsControllerInRootArea(string areaName) public void AddValidation_WithActionControllerArea_FindsControllerInRootArea(string areaName)
{ {
// Arrange // Arrange
@ -393,6 +755,7 @@ namespace Microsoft.AspNetCore.Mvc
// Test area is current in this case. // Test area is current in this case.
[Theory] [Theory]
[MemberData(nameof(NullOrEmptyNames))] [MemberData(nameof(NullOrEmptyNames))]
[ReplaceCulture]
public void AddValidation_WithActionControllerAreaInArea_FindsControllerInRootArea(string areaName) public void AddValidation_WithActionControllerAreaInArea_FindsControllerInRootArea(string areaName)
{ {
// Arrange // Arrange
@ -417,6 +780,7 @@ namespace Microsoft.AspNetCore.Mvc
// Root area is current in this case. // Root area is current in this case.
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionControllerArea_FindsControllerInNamedArea() public void AddValidation_WithActionControllerArea_FindsControllerInNamedArea()
{ {
// Arrange // Arrange
@ -441,6 +805,7 @@ namespace Microsoft.AspNetCore.Mvc
// Explicit reference to the current (Test) area. // Explicit reference to the current (Test) area.
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionControllerAreaInArea_FindsControllerInNamedArea() public void AddValidation_WithActionControllerAreaInArea_FindsControllerInNamedArea()
{ {
// Arrange // Arrange
@ -465,6 +830,7 @@ namespace Microsoft.AspNetCore.Mvc
// Test area is current in this case. // Test area is current in this case.
[Fact] [Fact]
[ReplaceCulture]
public void AddValidation_WithActionControllerAreaInArea_FindsControllerInDifferentArea() public void AddValidation_WithActionControllerAreaInArea_FindsControllerInDifferentArea()
{ {
// Arrange // Arrange
@ -518,35 +884,59 @@ namespace Microsoft.AspNetCore.Mvc
kvp => { Assert.Equal("data-val-remote-url", kvp.Key); Assert.Equal("original", kvp.Value); }); 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 factory
.Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>())) .Setup(f => f.GetUrlHelper(actionContext))
.Returns(urlHelper); .Returns(urlHelper);
var serviceCollection = GetServiceCollection(); var metadata = _metadata;
serviceCollection.AddSingleton<IUrlHelperFactory>(factory.Object); if (metadataProvider == null)
var serviceProvider = serviceCollection.BuildServiceProvider();
var actionContext = new ActionContext()
{ {
HttpContext = new DefaultHttpContext() metadataProvider = _metadataProvider;
{ }
RequestServices = serviceProvider, else
}, {
}; metadata = metadataProvider.GetMetadataForProperty(typeof(string), nameof(string.Length));
}
return new ClientModelValidationContext( return new ClientModelValidationContext(
actionContext, actionContext,
_metadata, metadata,
_metadataProvider, metadataProvider,
new AttributeDictionary()); new AttributeDictionary());
} }
private static ClientModelValidationContext GetValidationContextWithArea(string currentArea) private static ClientModelValidationContext GetValidationContextWithArea(string currentArea)
{ {
var serviceCollection = GetServiceCollection(); var serviceCollection = GetServiceCollection(localizerFactory: null);
var serviceProvider = serviceCollection.BuildServiceProvider(); var serviceProvider = serviceCollection.BuildServiceProvider();
var routeCollection = GetRouteCollectionWithArea(serviceProvider); var routeCollection = GetRouteCollectionWithArea(serviceProvider);
var routeData = new RouteData var routeData = new RouteData
@ -566,24 +956,18 @@ namespace Microsoft.AspNetCore.Mvc
routeData.Values["area"] = currentArea; routeData.Values["area"] = currentArea;
} }
var context = GetActionContext(serviceProvider, routeData); var actionContext = GetActionContext(serviceProvider, routeData);
var urlHelper = new UrlHelper(context); var urlHelper = new UrlHelper(actionContext);
var factory = new Mock<IUrlHelperFactory>(); var factory = new Mock<IUrlHelperFactory>(MockBehavior.Strict);
factory factory
.Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>())) .Setup(f => f.GetUrlHelper(actionContext))
.Returns(urlHelper); .Returns(urlHelper);
// Make an IUrlHelperFactory available through the ActionContext.
serviceCollection.AddSingleton<IUrlHelperFactory>(factory.Object); serviceCollection.AddSingleton<IUrlHelperFactory>(factory.Object);
serviceProvider = serviceCollection.BuildServiceProvider(); serviceProvider = serviceCollection.BuildServiceProvider();
actionContext.HttpContext.RequestServices = serviceProvider;
var actionContext = new ActionContext()
{
HttpContext = new DefaultHttpContext()
{
RequestServices = serviceProvider,
},
};
return new ClientModelValidationContext( return new ClientModelValidationContext(
actionContext, actionContext,
@ -615,7 +999,7 @@ namespace Microsoft.AspNetCore.Mvc
private static RouteBuilder GetRouteBuilder(IServiceProvider serviceProvider) private static RouteBuilder GetRouteBuilder(IServiceProvider serviceProvider)
{ {
var app = new Mock<IApplicationBuilder>(); var app = new Mock<IApplicationBuilder>(MockBehavior.Strict);
app app
.SetupGet(a => a.ApplicationServices) .SetupGet(a => a.ApplicationServices)
.Returns(serviceProvider); .Returns(serviceProvider);
@ -631,9 +1015,7 @@ namespace Microsoft.AspNetCore.Mvc
return builder; return builder;
} }
private static ActionContext GetActionContext( private static ActionContext GetActionContext(IServiceProvider serviceProvider, RouteData routeData)
IServiceProvider serviceProvider,
RouteData routeData = null)
{ {
// Set IServiceProvider properties because TemplateRoute gets services (e.g. an ILoggerFactory instance) // Set IServiceProvider properties because TemplateRoute gets services (e.g. an ILoggerFactory instance)
// through the HttpContext. // through the HttpContext.
@ -653,23 +1035,22 @@ namespace Microsoft.AspNetCore.Mvc
return new ActionContext(httpContext, routeData, new ActionDescriptor()); return new ActionContext(httpContext, routeData, new ActionDescriptor());
} }
private static ServiceCollection GetServiceCollection() private static ServiceCollection GetServiceCollection(IStringLocalizerFactory localizerFactory)
{ {
var serviceCollection = new ServiceCollection(); var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<ILoggerFactory>(new NullLoggerFactory()); serviceCollection.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
serviceCollection.AddSingleton<UrlEncoder>(new UrlTestEncoder()); serviceCollection.AddSingleton<UrlEncoder>(new UrlTestEncoder());
var routeOptions = new RouteOptions(); serviceCollection.AddOptions();
var accessor = new Mock<IOptions<RouteOptions>>();
accessor
.SetupGet(options => options.Value)
.Returns(routeOptions);
serviceCollection.AddSingleton<IOptions<RouteOptions>>(accessor.Object);
serviceCollection.AddRouting(); serviceCollection.AddRouting();
serviceCollection.AddSingleton<IInlineConstraintResolver>( serviceCollection.AddSingleton<IInlineConstraintResolver>(
new DefaultInlineConstraintResolver(accessor.Object)); provider => new DefaultInlineConstraintResolver(provider.GetRequiredService<IOptions<RouteOptions>>()));
if (localizerFactory != null)
{
serviceCollection.AddSingleton<IStringLocalizerFactory>(localizerFactory);
}
return serviceCollection; return serviceCollection;
} }

View File

@ -126,4 +126,7 @@
<data name="DisplayAttribute_Name" xml:space="preserve"> <data name="DisplayAttribute_Name" xml:space="preserve">
<value>name from resources</value> <value>name from resources</value>
</data> </data>
<data name="RemoteAttribute_Error" xml:space="preserve">
<value>Error about '{0}' from resources.</value>
</data>
</root> </root>