CompositeValidationAttribute

- Add abstract CompositeValidationAttribute.
- Change DataAnnotationsMetadataProvider.CreateValidationMetadata to
populate ValidatorMetadata with validation attributes from CompositeValidationAttribute.
This commit is contained in:
Alexej Timonin 2018-07-16 12:42:17 +02:00 committed by Pranav K
parent 5c488bf09c
commit d4beab5d09
No known key found for this signature in database
GPG Key ID: 1963DA6D96C3057A
11 changed files with 325 additions and 3 deletions

View File

@ -317,15 +317,30 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal
throw new ArgumentNullException(nameof(context));
}
var attributes = new List<object>(context.Attributes.Count);
for (var i = 0; i < context.Attributes.Count; i++)
{
var attribute = context.Attributes[i];
if (attribute is ValidationProviderAttribute validationProviderAttribute)
{
attributes.AddRange(validationProviderAttribute.GetValidationAttributes());
}
else
{
attributes.Add(attribute);
}
}
// RequiredAttribute marks a property as required by validation - this means that it
// must have a non-null value on the model during validation.
var requiredAttribute = context.Attributes.OfType<RequiredAttribute>().FirstOrDefault();
var requiredAttribute = attributes.OfType<RequiredAttribute>().FirstOrDefault();
if (requiredAttribute != null)
{
context.ValidationMetadata.IsRequired = true;
}
foreach (var attribute in context.Attributes.OfType<ValidationAttribute>())
foreach (var attribute in attributes.OfType<ValidationAttribute>())
{
// If another provider has already added this attribute, do not repeat it.
// This will prevent attributes like RemoteAttribute (which implement ValidationAttribute and

View File

@ -0,0 +1,22 @@
// 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.ComponentModel.DataAnnotations;
namespace Microsoft.AspNetCore.Mvc.DataAnnotations
{
/// <summary>
/// Abstract class for grouping attributes of type <see cref="ValidationAttribute"/> into
/// one <see cref="Attribute"/>
/// </summary>
public abstract class ValidationProviderAttribute : Attribute
{
/// <summary>
/// Gets <see cref="ValidationAttribute" /> instances associated with this attribute.
/// </summary>
/// <returns>Sequence of <see cref="ValidationAttribute" /> associated with this attribute.</returns>
public abstract IEnumerable<ValidationAttribute> GetValidationAttributes();
}
}

View File

@ -1241,6 +1241,38 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal
Assert.Equal(initialValue, context.ValidationMetadata.IsRequired);
}
[Fact]
public void CreateValidationMetadata_WillAddValidationAttributes_From_ValidationProviderAttribute()
{
// Arrange
var provider = new DataAnnotationsMetadataProvider(
Options.Create(new MvcDataAnnotationsLocalizationOptions()),
stringLocalizerFactory: null);
var validationProviderAttribute = new FooCompositeValidationAttribute(
attributes: new List<ValidationAttribute>
{
new RequiredAttribute(),
new StringLengthAttribute(5)
});
var attributes = new Attribute[] { new EmailAddressAttribute(), validationProviderAttribute };
var key = ModelMetadataIdentity.ForProperty(typeof(string), "Length", typeof(string));
var context = new ValidationMetadataProviderContext(key, GetModelAttributes(new object[0], attributes));
// Act
provider.CreateValidationMetadata(context);
// Assert
var expected = new List<object>
{
new EmailAddressAttribute(),
new RequiredAttribute(),
new StringLengthAttribute(5)
};
Assert.Equal(expected, actual: context.ValidationMetadata.ValidatorMetadata);
}
// [Required] has no effect on IsBindingRequired
[Theory]
[InlineData(true)]
@ -1545,5 +1577,20 @@ namespace Microsoft.AspNetCore.Mvc.DataAnnotations.Internal
public string Name { get; private set; }
}
private class FooCompositeValidationAttribute : ValidationProviderAttribute
{
private IEnumerable<ValidationAttribute> _attributes;
public FooCompositeValidationAttribute(IEnumerable<ValidationAttribute> attributes)
{
_attributes = attributes;
}
public override IEnumerable<ValidationAttribute> GetValidationAttributes()
{
return _attributes;
}
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@ -613,6 +614,51 @@ Products: Music Systems, Televisions (3)";
Assert.Empty(content.TextContent);
}
[Fact]
public async Task ValidationProviderAttribute_ValidationTagHelpers_GeneratesExpectedDataAttributes()
{
// Act
var document = await Client.GetHtmlDocumentAsync("HtmlGeneration_Home/ValidationProviderAttribute");
// Assert
var firstName = document.RequiredQuerySelector("#FirstName");
Assert.Equal("true", firstName.GetAttribute("data-val"));
Assert.Equal("The FirstName field is required.", firstName.GetAttribute("data-val-required"));
Assert.Equal("The field FirstName must be a string with a maximum length of 5.", firstName.GetAttribute("data-val-length"));
Assert.Equal("5", firstName.GetAttribute("data-val-length-max"));
Assert.Equal("The field FirstName must match the regular expression '[A-Za-z]*'.", firstName.GetAttribute("data-val-regex"));
Assert.Equal("[A-Za-z]*", firstName.GetAttribute("data-val-regex-pattern"));
var lastName = document.RequiredQuerySelector("#LastName");
Assert.Equal("true", lastName.GetAttribute("data-val"));
Assert.Equal("The LastName field is required.", lastName.GetAttribute("data-val-required"));
Assert.Equal("The field LastName must be a string with a maximum length of 6.", lastName.GetAttribute("data-val-length"));
Assert.Equal("6", lastName.GetAttribute("data-val-length-max"));
Assert.False(lastName.HasAttribute("data-val-regex"));
}
[Fact]
public async Task ValidationProviderAttribute_ValidationTagHelpers_GeneratesExpectedSpansAndDivsOnValidationError()
{
// Arrange
var request = new HttpRequestMessage(HttpMethod.Post, "HtmlGeneration_Home/ValidationProviderAttribute");
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "FirstName", "TestFirstName" },
});
// Act
var response = await Client.SendAsync(request);
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var document = await response.GetHtmlDocumentAsync();
Assert.Collection(
document.QuerySelectorAll("div.validation-summary-errors ul li"),
item => Assert.Equal("The field FirstName must be a string with a maximum length of 5.", item.TextContent),
item => Assert.Equal("The LastName field is required.", item.TextContent));
}
private static HttpRequestMessage RequestWithLocale(string url, string locale)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);

View File

@ -18,6 +18,11 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await client.GetAsync(requestUri);
await AssertStatusCodeAsync(response, HttpStatusCode.OK);
return await GetHtmlDocumentAsync(response);
}
public static async Task<IHtmlDocument> GetHtmlDocumentAsync(this HttpResponseMessage response)
{
var content = await response.Content.ReadAsStringAsync();
var parser = new HtmlParser();
var document = parser.Parse(content);

View File

@ -7,7 +7,6 @@ using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Testing;
using Microsoft.AspNetCore.Testing.xunit;
using Newtonsoft.Json;
using Xunit;
@ -182,5 +181,67 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("xyz", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task ValidationProviderAttribute_WillValidateObject()
{
// Arrange
var invalidRequestData = "{\"FirstName\":\"TestName123\", \"LastName\": \"Test\"}";
var content = new StringContent(invalidRequestData, Encoding.UTF8, "application/json");
var expectedErrorMessage =
"{\"FirstName\":[\"The field FirstName must match the regular expression '[A-Za-z]*'.\"," +
"\"The field FirstName must be a string with a maximum length of 5.\"]}";
// Act
var response = await Client.PostAsync(
"http://localhost/Validation/ValidationProviderAttribute", content);
// Assert
Assert.Equal(expected: StatusCodes.Status400BadRequest, actual: (int)response.StatusCode);
var responseContent = await response.Content.ReadAsStringAsync();
Assert.Equal(expectedErrorMessage, actual: responseContent);
}
[Fact]
public async Task ValidationProviderAttribute_DoesNotInterfere_WithOtherValidationAttributes()
{
// Arrange
var invalidRequestData = "{\"FirstName\":\"Test\", \"LastName\": \"Testsson\"}";
var content = new StringContent(invalidRequestData, Encoding.UTF8, "application/json");
var expectedErrorMessage =
"{\"LastName\":[\"The field LastName must be a string with a maximum length of 5.\"]}";
// Act
var response = await Client.PostAsync(
"http://localhost/Validation/ValidationProviderAttribute", content);
// Assert
Assert.Equal(expected: StatusCodes.Status400BadRequest, actual: (int)response.StatusCode);
var responseContent = await response.Content.ReadAsStringAsync();
Assert.Equal(expectedErrorMessage, actual: responseContent);
}
[Fact]
public async Task ValidationProviderAttribute_RequiredAttributeErrorMessage_WillComeFirst()
{
// Arrange
var invalidRequestData = "{\"FirstName\":\"Testname\", \"LastName\": \"\"}";
var content = new StringContent(invalidRequestData, Encoding.UTF8, "application/json");
var expectedError =
"{\"LastName\":[\"The LastName field is required.\"]," +
"\"FirstName\":[\"The field FirstName must be a string with a maximum length of 5.\"]}";
// Act
var response = await Client.PostAsync(
"http://localhost/Validation/ValidationProviderAttribute", content);
// Assert
Assert.Equal(expected: StatusCodes.Status400BadRequest, actual: (int)response.StatusCode);
var responseContent = await response.Content.ReadAsStringAsync();
Assert.Equal(expectedError, actual: responseContent);
}
}
}

View File

@ -67,5 +67,16 @@ namespace FormatterWebSite
{
return Json(simpleTypePropertiesModel);
}
[HttpPost]
public IActionResult ValidationProviderAttribute([FromBody] ValidationProviderAttributeModel validationProviderAttributeModel)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
return Ok();
}
}
}

View File

@ -0,0 +1,43 @@
// 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 System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
namespace FormatterWebSite
{
public class ValidationProviderAttributeModel
{
[FirstName]
public string FirstName { get; set; }
[StringLength(maximumLength: 5)]
[LastName]
public string LastName { get; set; }
}
public class FirstNameAttribute : ValidationProviderAttribute
{
public override IEnumerable<ValidationAttribute> GetValidationAttributes()
{
return new List<ValidationAttribute>
{
new RequiredAttribute(),
new RegularExpressionAttribute(pattern: "[A-Za-z]*"),
new StringLengthAttribute(maximumLength: 5)
};
}
}
public class LastNameAttribute : ValidationProviderAttribute
{
public override IEnumerable<ValidationAttribute> GetValidationAttributes()
{
return new List<ValidationAttribute>
{
new RequiredAttribute()
};
}
}
}

View File

@ -238,6 +238,11 @@ namespace HtmlGenerationWebSite.Controllers
return View();
}
public IActionResult ValidationProviderAttribute() => View();
[HttpPost]
public IActionResult ValidationProviderAttribute(ValidationProviderAttributeModel model) => View(model);
public IActionResult PartialTagHelperWithoutModel() => View();
public IActionResult StatusMessage() => View(new StatusMessageModel { Message = "Some status message"});

View File

@ -0,0 +1,43 @@
// 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 System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
namespace HtmlGenerationWebSite.Models
{
public class ValidationProviderAttributeModel
{
[FirstName]
public string FirstName { get; set; }
[StringLength(maximumLength: 6)]
[LastName]
public string LastName { get; set; }
}
public class FirstNameAttribute : ValidationProviderAttribute
{
public override IEnumerable<ValidationAttribute> GetValidationAttributes()
{
return new List<ValidationAttribute>
{
new RequiredAttribute(),
new RegularExpressionAttribute(pattern: "[A-Za-z]*"),
new StringLengthAttribute(maximumLength: 5)
};
}
}
public class LastNameAttribute : ValidationProviderAttribute
{
public override IEnumerable<ValidationAttribute> GetValidationAttributes()
{
return new List<ValidationAttribute>
{
new RequiredAttribute()
};
}
}
}

View File

@ -0,0 +1,24 @@
@model HtmlGenerationWebSite.Models.ValidationProviderAttributeModel
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<html>
<body>
<form asp-controller="HtmlGeneration_ValidationProviderAttribute" asp-action="Index">
<div>
<label asp-for="FirstName"></label>
<input asp-for="FirstName" type="text" class="form-control" />
<span asp-validation-for="FirstName"></span>
</div>
<div>
<label asp-for="LastName"></label>
<input asp-for="LastName" type="text" class="form-control" />
<span asp-validation-for="LastName"></span>
</div>
<div asp-validation-summary="All"></div>
<div asp-validation-summary="ModelOnly"></div>
<input type="submit" />
</form>
</body>
</html>