Monday, January 16, 2017

RequiredIf Not RegEx - Conditional Validation Attribute using MVC / Web API

This topic illustrates how to extend ValidationAttribute to enforce customized validation of checking if a provided field's value is matching to a pattern (regular expression) or not when dependent field's value is not matching to a specific value condition.

Scenario: Address.Phone should accept only numbers between 0-9 when Address.Country's value is not "USA".

We can achieve this by following below steps, and the illustration is developed using MVC 5, Web API 2, EF 6 and Mock

As a first step, Create a new class with name "RequiredIfRegExValidator" (preferably in a common location of the solution ie. either in Model or a Common project, as applied) and copy below code:
public class RequiredIfNotRegExValidator : ValidationAttribute
{
private RequiredAttribute _innerAttribute = new RequiredAttribute();
public string DependentProperty { get; set; }
public object TargetValue { get; set; }

public RequiredIfNotRegExValidator(string dependentProperty, object targetValue)
{
this.DependentProperty = dependentProperty;
this.TargetValue = targetValue;
}

protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
// get a reference to the property this validation depends upon
var containerType = validationContext.ObjectInstance.GetType();
var field = containerType.GetProperty(this.DependentProperty);

if (field != null)
{
var phoneNumber = Convert.ToString(value, CultureInfo.CurrentCulture);

// get the value of the dependent property
object dependentValue = field.GetValue(validationContext.ObjectInstance, null);

// trim spaces and convert dependent value to uppercase to support case senstive comparison
if (dependentValue != null && dependentValue is string)
{
dependentValue = (dependentValue as string).Trim();
dependentValue as string).Length == 0 ? null : (dependentValue as string).ToUpper();
}

// trim spaces and convert TargetValue to uppercase to support case senstive comparison
if (TargetValue != null && TargetValue is string)
{
TargetValue = (TargetValue as string).Trim();
TargetValue = (TargetValue as string).Length == 0 ? null : (TargetValue as string).ToUpper();
}

// compare the value against the target value
if ((dependentValue == null && TargetValue.Equals("") ||
(dependentValue == null && !TargetValue.Equals("") ||
(dependentValue != null && !dependentValue.Equals(this.TargetValue)))))
{
// try validating this field
if (!_innerAttribute.IsValid(value))
{
     var match = _regex.Match(phoneNumber);
     if (!match.Success)
           // validation failed - return an error
           return new ValidationResult(FormatErrorMessage(validationContext.DisplayName), new[] { validationContext.MemberName });
}
}
}
// validation success - return success
return ValidatioResult.Success;
}
}
We need to create or make changes to Model(s) and Controller(s) as below to implement the created custom validation.

POCO / Model: Create or Modify Address class to apply the custom validation on City property as below to define City is required when selected Country is USA.
public class Address {
public int AddressId {get; set; }

public string Line1 { get; set; }

public string Line2 { get; set; }

public string City { get; set; }

public string State { get; set; }

public string Zip { get; set; }

public IEnumerable Country { get; set; }

[RequiredIfRegExValidator(@"^\d{10}$", "Country", "USA", ErrorMessage = "Phone Requires Numbers between 0-9")]
public string Phone { get; set; }
}
Controller: The controller for Address entity with a POST method will look like below.

Here, before persisting the changes in database, I am forcing process to re-validate Modal State by calling "this.Validate()", which will help the process to identify and throw an error upfront instead of making a call to DB.
public class AddressController : ApiController
{
[ResponseType(typeof(Address))]
public IHttpActionResult PostAddress(Address address)
{
// use below to force validation before doing anything
this.Validate(address);

if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
db.Address.Add(address);
db.SaveChanges();

return CreatedAtRoute("DefaultApi", new { id = address.AddressId }, address);
}
}
Testing: Below TestMethods will help testing the post method to check for the expected validation error:

Here, The first test method will represent a case forcing this.Validate() method and second without forcing this.Validate() in controller's POST method.
// Forcing this.Validate() - this case Controller will thow the error
[TestMethod]
public void RequiredPhoneinNumbersWhenCountryIsNotUSATest()
{
Address address = new Address { Line1 = "200 E Main St", Line2 = "Apt B", State = "VA", Country = "USA", Phone="732654ABC" };
AddressController addressController = new AddressController();
AddressController.Request = new HttpRequestMessage();
AddressController.Request.Properties["MS_HttpConfiguration"] = new HttpConfiguration();

addressController.PostAddress(address);

Assert.IsFalse(addressController.ModelState.IsValid);
Assert.IsTrue(addressController.ModelState.Count == 1, "Phone Requires Numbers between 0-9");
}
// Without forcing this.Validate() - this case DB will throw the error
[TestMethod]
public void RequiredPhoneinNumbersWhenCountryIsNotUSATest()
{
Address address = new Address { Line1 = "200 E Main St", Line2 = "Apt B", State = "VA", Country = "USA", Phone="732654ABC" };
try
{
AddressController addressController = new AddressController();
AddressController.Request = new HttpRequestMessage();
AddressController.Request.Properties["MS_HttpConfiguration"] = new HttpConfiguration();

addressController.PostAddress(address);
}
catch (System.Data.Entity.Validation.DbEntityValidationException dbEx)
{
foreach (var validationErrors in dbEx.EntityValidationErrors)
{
foreach (var validationError in validationErrors.ValidationErrors)
{
Assert.AreEqual("Phone Requires Numbers between 0-9", validationError.ErrorMessage);
}
}
}
}
With this I am concluding the illustration. Feel free to share your feedback.

Happy Programming !!!

No comments:

Post a Comment