Wednesday 2 December 2015

MVC Validation with Sitecore

MVC comes with a validation framework that supports both server-side and client-side validation with very little coding.  You can still use this framework with Sitecore MVC if you wish, but it breaks a few fundamental aspects of content management systems, namely that the error messages you use won't be content editable as they are embedded in your code.  This article shows how we can use the built-in MVC framework and also allow the messages to be content manageable, and also multi-lingual.

One thing I have to point out about this solution is that it isn't perfect, there are some compromises you might have to make, but I'll discuss those later on.

In a traditional MVC site you would use the validation framework like this;

public class Address
{
    [Required(ErrorMessage="Please supply an address")]
    public string AddressLine1{ get; set; }
}

And in your view;

@model Address
@using (Html.BeginForm())
{
    <p>
        @Html.TextAreaFor(m => m.AddressLine1)
        @Html.ValidationMessageFor(m => m.AddressLine1)
    </p>
    <p>
        <input type="submit" value="Submit" />
    </p>
}

Create Sitecore items

In order to use the same framework but get the error messages from Sitecore we're going to create our own validation attributes.  Before we start writing code we'll have to create templates and items that let us store the messages in Sitecore.  We'll be validating an address in this example so create a template called "Address Validation" and create a field for each property you'll want to validate.


Create a folder somewhere that you'll store your validation items and create an item called Address Validation based on the Address Validation template.



Required field attribute usage

Below is how the attribute we're about to write is used.  This should give you an idea about what we're trying to achieve.

public class Address : ICloneable
{
    [SCRequired("Address Validation", "Address 1", DefaultErrorMessage = "Address line 1 is required")]
    public string AddressLine1 { get; set; }

    // rest of properties
}

The first parameter is the name of the Sitecore item that holds the validation messages, the second parameter is the name of the field on the item that relates to this property, and as a belt-and-braces measure you can provide a hard-coded message for the events where the validation item hasn't been published or some other mishap.

Note the first parameter is just the name of the Sitecore item, not its entire path; the items have to be inside a known folder and we'll work out the full path in the attribute itself, or you can also use the GUID of the item instead, but I find using the name reads better, even if it doesn't perform as well.

Required field attribute code

The code for the SCRequired attribute is below.  It uses a ValidationHelper class that I'll document later, but this is the basics of how the attributes work.

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;

namespace MyNamespace.Validation
{
    public class SCRequiredAttribute : RequiredAttribute, IClientValidatable
    {
        private static object lockObject = new object();
        private string path;
        private string fieldName;
        private Dictionary<string, string> errorMessages = new Dictionary<string, string>();

        public string DefaultErrorMessage { get; set; }

        public SCRequiredAttribute(string Item, string fieldName)
        {
            // The path is the GUID of the item that holds the validation messages
            this.path = ValidationHelper.GetPath(Item);

            // The fieldname is the name of the Sitecore field on the "path" item that relates
            // to this property
            this.fieldName = fieldName;
        }

        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            // Get the error message for this property in the current language
            ErrorMessage = ValidationHelper.GetErrorMessage(errorMessages, path, fieldName, DefaultErrorMessage, lockObject);

            // use the base IsValid property to do the work
            return base.IsValid(value, validationContext);
        }

        public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
        {
            // This code is required to support client validation
            var modelClientValidationRule = new ModelClientValidationRule
            {
                ValidationType = "required",
                ErrorMessage = ValidationHelper.GetErrorMessage(errorMessages, path, fieldName, DefaultErrorMessage, lockObject)
            };

            modelClientValidationRule.ValidationParameters.Add("param", metadata.PropertyName);
            return new List<ModelClientValidationRule> { modelClientValidationRule };
        }
    }
}

We inherit from the existing Required attribute, and the IsValid method is called when validation occurs.  The .net framework creates an instance of your attribute class for each property it is attached to, and keeps the class in-memory rather than creating it each time it is needed.  The reason we get the error message each time in the IsValid method is so the site can react to different languages.  If we set the error message when the class was created then we're stuck with that message in that language for all users of the site.  By setting the message each time IsValid is called users get a language-appropriate message.  In order to help with the performance of this, the messages are stored in the errorMessages Dictionary where we use the language name as the key.  The fact that we cache these messages means the error messages don't react to updates in Sitecore - if you changed the text of the message you won't see that on-site until you restart IIS.  If that is a compromise you're not willing to make then you could do away with the caching of messages and read the message each time, or a half-way house would be to store the messages in the .net Cache with an expiry time of an hour meaning that the message will be re-read every hour.

Here is the code for ValidationHelper

using Sitecore.Data.Items;
using System;
using System.Collections.Generic;

namespace MyNamespace.Validation
{
    internal static class ValidationHelper
    {
        /// <summary>
        /// Gets the location of the validation item.  If <paramref name="item"/> is a GUID then that is returned
        /// otherwise the full path to the item is retruned
        /// </summary>
        /// <param name="item">The GUID or name of the validation item</param>
        public static string GetPath(string item)
        {
            string path;
            Guid guid;

            if (Guid.TryParse(item, out guid))
            {
                path = guid.ToString();
            }
            else
            {
                // This is the location of the folder you store your validation items in
                // I'm hard-coding this for simplicity, in reality you should store this in the config settings
                string root = "/sitecore/Content/Validation Settings";

                path = string.Concat(Sitecore.StringUtil.EnsurePostfix('/', root), item);
                //path = string.Concat(Sitecore.StringUtil.EnsurePostfix('/', Settings.SiteSettings.ValidationPath), item);
            }

            return path;
        }

        public static string GetErrorMessage(Dictionary<string, string> errorMessages, string path, string fieldName, string defaultErrorMessage, object lockObject)
        {
            string lang = Sitecore.Context.Language.Name;

            if (!errorMessages.ContainsKey(lang))
            {
                lock (lockObject)
                {
                    if (!errorMessages.ContainsKey(lang))
                    {
                        string message = null;

                        Item item = Sitecore.Context.Database.GetItem(path, Sitecore.Context.Language);
                        if (item != null && item.Versions.Count > 0)
                        {
                            message = item[fieldName];
                        }

                        if (string.IsNullOrWhiteSpace(message))
                        {
                            message = defaultErrorMessage;
                        }

                        errorMessages.Add(lang, message);
                    }
                }
            }

            return errorMessages[lang];
        }
    }
}

Email address validation

The above was an example of extending the required field validator, and here is the code for an email address validator.  The usage is the same as the required attribute, and it uses the same ValidationHelper.

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Net.Mail;
using System.Web.Mvc;

namespace MyNamespace.Validation
{
    public class SCEmailAddressAttribute : ValidationAttribute, IClientValidatable
    {
        private static object lockObject = new object();
        private string path;
        private string fieldName;
        private Dictionary<string, string> errorMessages = new Dictionary<string, string>();

        public string DefaultErrorMessage { get; set; }

        public SCEmailAddressAttribute(string Item, string fieldName)
        {
            this.fieldName = fieldName;
            this.path = ValidationHelper.GetPath(Item);
        }

        public override bool IsValid(object value)
        {
            string email = value == null ? string.Empty : value.ToString();

            bool valid = true;

            try
            {
                MailAddress ma = new MailAddress(email);
            }
            catch
            {
                valid = false;
            }

            if (valid)
            {
                return true;
            }

            ErrorMessage = ValidationHelper.GetErrorMessage(errorMessages, path, fieldName, DefaultErrorMessage, lockObject);
            return false;
        }

        public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
        {
            var modelClientValidationRule = new ModelClientValidationRule
            {
                ValidationType = "email",
                ErrorMessage = ValidationHelper.GetErrorMessage(errorMessages, path, fieldName, DefaultErrorMessage, lockObject)
            }

            modelClientValidationRule.ValidationParameters.Add("param", metadata.PropertyName);
            return new List<ModelClientValidationRule> { modelClientValidationRule };
        }
    }
}

Conclusion

Extending the built-in validation attributes is a handy way of leveraging the MVC validation framework, however there are potential downsides depending on how you choose to cache the error messages.

1 comment: