From 7eb39c59ccad13ce514d3784c839b88c74bf39ed Mon Sep 17 00:00:00 2001 From: Doug Bunting Date: Fri, 2 Sep 2016 16:11:59 -0700 Subject: [PATCH] Add `IValidationAttributeProvider` to make `DefaultHtmlGenerator.AddValidationAttributes()` available - #5028 - helpers similar to our HTML or tag helpers can use the new singleton to examine validation attributes - in the most common case, helpers add validation attributes to a `TagBuilder` but that is not required - separating the `ValidationAttributesProvider` from `DefaultHtmlGenerator` avoids creating two instances of that singleton - would be even uglier to require callers to cast an `IHtmlGenerator` to `IValidationAttributeProvider` --- ...MvcViewFeaturesMvcCoreBuilderExtensions.cs | 4 +- .../ViewFeatures/DefaultHtmlGenerator.cs | 106 ++++++----- .../IValidationAttributeProvider.cs | 35 ++++ .../ValidationAttributeProvider.cs | 111 +++++++++++ .../ViewFeatures/DefaultHtmlGeneratorTest.cs | 6 +- .../ValidationAttributeProviderTest.cs | 172 ++++++++++++++++++ 6 files changed, 385 insertions(+), 49 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IValidationAttributeProvider.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ValidationAttributeProvider.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ValidationAttributeProviderTest.cs diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs index 86c5692e3c..951c30a11e 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs @@ -6,7 +6,6 @@ using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.Rendering; @@ -117,6 +116,7 @@ internal static void AddViewServices(IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); // // JSON Helper @@ -132,7 +132,7 @@ internal static void AddViewServices(IServiceCollection services) // // View Components // - + // These do caching so they should stay singleton services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs index b63e755699..b0a6970d06 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs @@ -33,18 +33,17 @@ public class DefaultHtmlGenerator : IHtmlGenerator new[] { "text", "search", "url", "tel", "email", "password", "number" }; private readonly IAntiforgery _antiforgery; - private readonly IClientModelValidatorProvider _clientModelValidatorProvider; private readonly IModelMetadataProvider _metadataProvider; private readonly IUrlHelperFactory _urlHelperFactory; private readonly HtmlEncoder _htmlEncoder; - private readonly ClientValidatorCache _clientValidatorCache; + private readonly IValidationAttributeProvider _validationAttributeProvider; /// /// Initializes a new instance of the class. /// /// The instance which is used to generate antiforgery /// tokens. - /// The accessor for . + /// The accessor for . /// The . /// The . /// The . @@ -56,7 +55,57 @@ public DefaultHtmlGenerator( IModelMetadataProvider metadataProvider, IUrlHelperFactory urlHelperFactory, HtmlEncoder htmlEncoder, - ClientValidatorCache clientValidatorCache) + ClientValidatorCache clientValidatorCache) : this( + antiforgery, + optionsAccessor, + metadataProvider, + urlHelperFactory, + htmlEncoder, + clientValidatorCache, + () => new ValidationAttributeProvider(optionsAccessor, metadataProvider, clientValidatorCache)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The instance which is used to generate antiforgery + /// tokens. + /// The accessor for . + /// The . + /// The . + /// The . + /// The that provides + /// a list of s. + /// The . + public DefaultHtmlGenerator( + IAntiforgery antiforgery, + IOptions optionsAccessor, + IModelMetadataProvider metadataProvider, + IUrlHelperFactory urlHelperFactory, + HtmlEncoder htmlEncoder, + ClientValidatorCache clientValidatorCache, + IValidationAttributeProvider validationAttributeProvider) : this( + antiforgery, + optionsAccessor, + metadataProvider, + urlHelperFactory, + htmlEncoder, + clientValidatorCache, + () => validationAttributeProvider) + { + } + + // All parameter names must match the public constructor just above. + // Note validationAttributeProvider is not evaluated until after all arguments have been null-checked. + private DefaultHtmlGenerator( + IAntiforgery antiforgery, + IOptions optionsAccessor, + IModelMetadataProvider metadataProvider, + IUrlHelperFactory urlHelperFactory, + HtmlEncoder htmlEncoder, + ClientValidatorCache clientValidatorCache, + Func validationAttributeProvider) { if (antiforgery == null) { @@ -88,13 +137,16 @@ public DefaultHtmlGenerator( throw new ArgumentNullException(nameof(clientValidatorCache)); } + if (validationAttributeProvider == null) + { + throw new ArgumentNullException(nameof(validationAttributeProvider)); + } + _antiforgery = antiforgery; - var clientValidatorProviders = optionsAccessor.Value.ClientModelValidatorProviders; - _clientModelValidatorProvider = new CompositeClientModelValidatorProvider(clientValidatorProviders); _metadataProvider = metadataProvider; _urlHelperFactory = urlHelperFactory; _htmlEncoder = htmlEncoder; - _clientValidatorCache = clientValidatorCache; + _validationAttributeProvider = validationAttributeProvider(); // Underscores are fine characters in id's. IdAttributeDotReplacement = optionsAccessor.Value.HtmlHelperOptions.IdAttributeDotReplacement; @@ -1324,41 +1376,11 @@ protected virtual void AddValidationAttributes( ModelExplorer modelExplorer, string expression) { - // Only render attributes if client-side validation is enabled, and then only if we've - // never rendered validation for a field with this name in this form. - var formContext = viewContext.ClientValidationEnabled ? viewContext.FormContext : null; - if (formContext == null) - { - return; - } - - var fullName = GetFullHtmlFieldName(viewContext, expression); - if (formContext.RenderedField(fullName)) - { - return; - } - - formContext.RenderedField(fullName, true); - - modelExplorer = modelExplorer ?? - ExpressionMetadataProvider.FromStringExpression(expression, viewContext.ViewData, _metadataProvider); - - - var validators = _clientValidatorCache.GetValidators(modelExplorer.Metadata, _clientModelValidatorProvider); - if (validators.Count > 0) - { - var validationContext = new ClientModelValidationContext( - viewContext, - modelExplorer.Metadata, - _metadataProvider, - tagBuilder.Attributes); - - for (var i = 0; i < validators.Count; i++) - { - var validator = validators[i]; - validator.AddValidation(validationContext); - } - } + _validationAttributeProvider.AddValidationAttributes( + viewContext, + modelExplorer, + expression, + tagBuilder.Attributes); } private static Enum ConvertEnumFromInteger(object value, Type targetType) diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IValidationAttributeProvider.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IValidationAttributeProvider.cs new file mode 100644 index 0000000000..821c6e1b98 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IValidationAttributeProvider.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + /// + /// Contract for a service providing validation attributes for expressions. + /// + public interface IValidationAttributeProvider + { + /// + /// Adds validation attributes to the if client validation is enabled. + /// + /// A instance for the current scope. + /// The for the . + /// Expression name, relative to the current model. + /// + /// The to receive the validation attributes. Maps the validation + /// attribute names to their values. Values must be HTML encoded before they are written + /// to an HTML document or response. + /// + /// + /// Adds nothing to if client-side validation is disabled or if attributes have + /// already been generated for the in the current <form>. + /// + void AddValidationAttributes( + ViewContext viewContext, + ModelExplorer modelExplorer, + string expression, + IDictionary attributes); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ValidationAttributeProvider.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ValidationAttributeProvider.cs new file mode 100644 index 0000000000..48fa6bcc2d --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/ValidationAttributeProvider.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + /// + /// Default implementation of . + /// + public class ValidationAttributeProvider : IValidationAttributeProvider + { + private readonly IModelMetadataProvider _metadataProvider; + private readonly ClientValidatorCache _clientValidatorCache; + private readonly IClientModelValidatorProvider _clientModelValidatorProvider; + + /// + /// Initializes a new instance. + /// + /// The accessor for . + /// The . + /// The that provides + /// a list of s. + public ValidationAttributeProvider( + IOptions optionsAccessor, + IModelMetadataProvider metadataProvider, + ClientValidatorCache clientValidatorCache) + { + if (optionsAccessor == null) + { + throw new ArgumentNullException(nameof(optionsAccessor)); + } + + if (metadataProvider == null) + { + throw new ArgumentNullException(nameof(metadataProvider)); + } + + if (clientValidatorCache == null) + { + throw new ArgumentNullException(nameof(clientValidatorCache)); + } + + _clientValidatorCache = clientValidatorCache; + _metadataProvider = metadataProvider; + + var clientValidatorProviders = optionsAccessor.Value.ClientModelValidatorProviders; + _clientModelValidatorProvider = new CompositeClientModelValidatorProvider(clientValidatorProviders); + } + + /// + public virtual void AddValidationAttributes( + ViewContext viewContext, + ModelExplorer modelExplorer, + string expression, + IDictionary attributes) + { + if (viewContext == null) + { + throw new ArgumentNullException(nameof(viewContext)); + } + + if (attributes == null) + { + throw new ArgumentNullException(nameof(attributes)); + } + + // Only render attributes if client-side validation is enabled, and then only if we've + // never rendered validation for a field with this name in this form. + var formContext = viewContext.ClientValidationEnabled ? viewContext.FormContext : null; + if (formContext == null) + { + return; + } + + var fullName = viewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(expression); + if (formContext.RenderedField(fullName)) + { + return; + } + + formContext.RenderedField(fullName, true); + + modelExplorer = modelExplorer ?? + ExpressionMetadataProvider.FromStringExpression(expression, viewContext.ViewData, _metadataProvider); + + var validators = _clientValidatorCache.GetValidators(modelExplorer.Metadata, _clientModelValidatorProvider); + if (validators.Count > 0) + { + var validationContext = new ClientModelValidationContext( + viewContext, + modelExplorer.Metadata, + _metadataProvider, + attributes); + + for (var i = 0; i < validators.Count; i++) + { + var validator = validators[i]; + validator.AddValidation(validationContext); + } + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs index 9469f15d6a..eaa3dc1e60 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs @@ -681,6 +681,7 @@ private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvid { var mvcViewOptionsAccessor = new Mock>(); mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions()); + var htmlEncoder = Mock.Of(); var antiforgery = new Mock(); antiforgery @@ -690,11 +691,6 @@ private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvid return new AntiforgeryTokenSet("requestToken", "cookieToken", "formFieldName", "headerName"); }); - var optionsAccessor = new Mock>(); - optionsAccessor - .SetupGet(o => o.Value) - .Returns(new MvcOptions()); - return new DefaultHtmlGenerator( antiforgery.Object, mvcViewOptionsAccessor.Object, diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ValidationAttributeProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ValidationAttributeProviderTest.cs new file mode 100644 index 0000000000..3dcd2cd34d --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/ValidationAttributeProviderTest.cs @@ -0,0 +1,172 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.DataAnnotations.Internal; +using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + public class ValidationAttributeProviderTest + { + [Fact] + [ReplaceCulture] + public void AddValidationAttributes_AddsAttributes() + { + // Arrange + var expectedMessage = $"The field {nameof(Model.HasValidatorsProperty)} must be a number."; + var metadataProvider = new EmptyModelMetadataProvider(); + var attributeProvider = GetAttributeProvider(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var attributes = new SortedDictionary(StringComparer.Ordinal); + var modelExplorer = metadataProvider + .GetModelExplorerForType(typeof(Model), model: null) + .GetExplorerForProperty(nameof(Model.HasValidatorsProperty)); + + // Act + attributeProvider.AddValidationAttributes( + viewContext, + modelExplorer, + nameof(Model.HasValidatorsProperty), + attributes); + + // Assert + Assert.Collection( + 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] + public void AddValidationAttributes_AddsNothing_IfClientSideValidationDisabled() + { + // Arrange + var metadataProvider = new EmptyModelMetadataProvider(); + var attributeProvider = GetAttributeProvider(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + viewContext.ClientValidationEnabled = false; + + var attributes = new SortedDictionary(StringComparer.Ordinal); + var modelExplorer = metadataProvider + .GetModelExplorerForType(typeof(Model), model: null) + .GetExplorerForProperty(nameof(Model.HasValidatorsProperty)); + + // Act + attributeProvider.AddValidationAttributes( + viewContext, + modelExplorer, + nameof(Model.HasValidatorsProperty), + attributes); + + // Assert + Assert.Empty(attributes); + } + + [Fact] + public void AddValidationAttributes_AddsNothing_IfPropertyAlreadyRendered() + { + // Arrange + var metadataProvider = new EmptyModelMetadataProvider(); + var attributeProvider = GetAttributeProvider(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + viewContext.FormContext.RenderedField(nameof(Model.HasValidatorsProperty), value: true); + + var attributes = new SortedDictionary(StringComparer.Ordinal); + var modelExplorer = metadataProvider + .GetModelExplorerForType(typeof(Model), model: null) + .GetExplorerForProperty(nameof(Model.HasValidatorsProperty)); + + // Act + attributeProvider.AddValidationAttributes( + viewContext, + modelExplorer, + nameof(Model.HasValidatorsProperty), + attributes); + + // Assert + Assert.Empty(attributes); + } + + [Fact] + public void AddValidationAttributes_AddsNothing_IfPropertyHasNoValidators() + { + // Arrange + var metadataProvider = new EmptyModelMetadataProvider(); + var attributeProvider = GetAttributeProvider(metadataProvider); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + var attributes = new SortedDictionary(StringComparer.Ordinal); + var modelExplorer = metadataProvider + .GetModelExplorerForType(typeof(Model), model: null) + .GetExplorerForProperty(nameof(Model.Property)); + + // Act + attributeProvider.AddValidationAttributes( + viewContext, + modelExplorer, + nameof(Model.Property), + attributes); + + // Assert + Assert.Empty(attributes); + } + + private static ViewContext GetViewContext(TModel model, IModelMetadataProvider metadataProvider) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var viewData = new ViewDataDictionary(metadataProvider, actionContext.ModelState) + { + Model = model, + }; + + return new ViewContext( + actionContext, + Mock.Of(), + viewData, + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); + } + + private static IValidationAttributeProvider GetAttributeProvider(IModelMetadataProvider metadataProvider) + { + // Add validation properties for float, double and decimal properties. Ignore everything else. + var mvcViewOptions = new MvcViewOptions(); + mvcViewOptions.ClientModelValidatorProviders.Add(new NumericClientModelValidatorProvider()); + + var mvcViewOptionsAccessor = new Mock>(); + mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(mvcViewOptions); + + return new ValidationAttributeProvider( + mvcViewOptionsAccessor.Object, + metadataProvider, + new ClientValidatorCache()); + } + + private class Model + { + public double HasValidatorsProperty { get; set; } + + public string Property { get; set; } + } + } +}