Try our conversational search powered by Generative AI!

Martin Pickering
Mar 11, 2014
  9343
(2 votes)

Integrating the EPiServer LocalizationService with MVC Validation Attributes

The code-first content modelling technique made main stream by EPiServer CMS 7 and now almost universal with EPiServer 7.5 and EPiServer Commerce 7 makes extensive use of the DataAnnotations available within ASP.Net. This has enriched and strengthened the Edit Mode experience for our beloved Content Editors and allows Implementers a relatively easy way to customize the Error and Warning Messages issued by EPiServer’s Edit Mode views when an attempt to violate the Content Model is detected.

Simply put, by setting the ErrorMessage property of a Validation Annotation class attached to a Content Property to a string starting with “/”, EPiServer CMS Edit Mode will attempt to use the value as a Localization Service Resource Key during On-Page Edit and All Properties View interactions.

For example,

public class SimplePage : PageData
{
   [Display(Name = "Heading",
      Description = "The Main Heading (h1) for the Page.",
      GroupName = SystemTabNames.Content,
      Order = 10)]
   [Required(ErrorMessage="/contenttypes/SimplePage/headingrequired"]
   public virtual string Heading { get; set; }
}

The thing is though, there is no such integration for the View Models one may create to support a custom application with a need to interact with the Visitor, such as a Cart or Checkout application or indeed a custom logon page. Therefore, here is but one idea as to how this might be achieved. I am sure there are a legion of other ways and I know of at least one other by Vladimir Levchuk.

My solution for integrating the EPiServer Localization Service with MVC View Models and Data Annotations is to:

  • Override the MVC CachedDataAnnotationsModelMetadataProvider to be able to extend the Model Metadata a little bit.
    Basically, detect an instance of an annotation class that has its ErrorMessage property set with a string value starting with "/"; storing in a custom Model Metadata property the value of the resource key.
  • Create or override (as appropriate) Attribute Adapter classes for the desired Annotation Attribute types used by the application's View Models: such as RequiredAttributeAdapter and StringLengthAttributeAdapter. The Attribute Adapter classes look for the Model Metadata custom property created by the Model Metadata Provider and then use the Localization Service to obtain the translated, custom error message value to use during Model Validation, whether that be Server or Client side validation.

So taking, as an example, a fairly typical Logon View Model:

public class LogonViewModel
{
   [Required(AllowEmptyStrings = false, ErrorMessage = "/contenttypes/logonpage/username/required")]
   [EmailAddress(ErrorMessage = "/contenttypes/logonpage/username/mustbeanemail")]
   public string Username { get; set; }
 
   [Required(AllowEmptyStrings = false, ErrorMessage = "/contenttypes/logonpage/password/required")]
   [StringLength(15, MinimumLength = 6, ErrorMessage = "/contenttypes/logonpage/password/lengthproblem")]
   [DataType(DataType.Password)]
   public string Password { get; set; }
 
   [DataType(DataType.Password)]
   [System.Web.Mvc.Compare("Password", ErrorMessage = "/contenttypes/logonpage/confirmpwd/doesnotcompare")]
   public string ConfirmPassword { get; set; }
}

We see that we will need at the very least Attribute Adapters for Required, String Length, Email and Compare Attributes. The great thing about these Attribute Adapters, once written they can be re-used over and over again.

So here are our Attribute Adapters...

public class LocalisableRequiredAnnotationsAdapter: RequiredAttributeAdapter 
{
    public LocalisableRequiredAnnotationsAdapter(ModelMetadata metadata, ControllerContext context, RequiredAttribute attribute) : base(metadata, context, attribute)
    {
        LocalisableAnnotationAdapterInitialiser.Initialise(metadata, attribute);
    }
 
    public static void SelfRegister()
    {
        DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(RequiredAttribute),
            typeof(LocalisableRequiredAnnotationsAdapter));
    }
}
 
public class LocalisableStringLengthAnnotationsAdapter : StringLengthAttributeAdapter
{
    public LocalisableStringLengthAnnotationsAdapter(ModelMetadata metadata, ControllerContext context, StringLengthAttribute attribute) : base(metadata, context, attribute)
    {
        LocalisableAnnotationAdapterInitialiser.Initialise(metadata, attribute);
    }
 
    public static void SelfRegister()
    {
        DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(StringLengthAttribute),
            typeof(LocalisableStringLengthAnnotationsAdapter));
    }
}
 
public class LocalisableEmailAddressAnnotationsAdapter : DataAnnotationsModelValidator<EmailAddressAttribute>
{
    public LocalisableEmailAddressAnnotationsAdapter(ModelMetadata metadata, ControllerContext context, EmailAddressAttribute attribute)
        : base(metadata, context, attribute)
    {
        LocalisableAnnotationAdapterInitialiser.Initialise(metadata, attribute);
    }
 
    public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
    {
        return new[]
        {
            new ModelClientValidationRegexRule(ErrorMessage,
                "^((([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+(\\.([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(\\\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.)+(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.?$")
            {
                ValidationType = "email"
            }
        };
    }
 
    public static void SelfRegister()
    {
        DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(EmailAddressAttribute),
            typeof(LocalisableEmailAddressAnnotationsAdapter));
    }
}
 
public class LocalisableCompareAnnotationsAdapter : CompareAttributeAdapter
{
    public LocalisableCompareAnnotationsAdapter(ModelMetadata metadata, ControllerContext context, System.Web.Mvc.CompareAttribute attribute) : base(metadata, context, attribute)
    {
        LocalisableAnnotationAdapterInitialiser.Initialise(metadata, attribute);
    }
 
    public static void SelfRegister()
    {
        DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(System.Web.Mvc.CompareAttribute),
            typeof(LocalisableCompareAnnotationsAdapter));
    }
}
 
public class CompareAttributeAdapter : DataAnnotationsModelValidator<System.Web.Mvc.CompareAttribute>
{
    public CompareAttributeAdapter(ModelMetadata metadata, ControllerContext context, System.Web.Mvc.CompareAttribute attribute)
        : base(metadata, context, attribute)
    {    }
 
    public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
    {
        return new[] { new ModelClientValidationEqualToRule(ErrorMessage, System.Web.Mvc.CompareAttribute.FormatPropertyForClientValidation(Attribute.OtherProperty)) };
    }
}
 
internal class LocalisableAnnotationAdapterInitialiser
{
    private static readonly LocalizationService LocalizationService = ServiceLocator.Current.GetInstance<LocalizationService>();
 
    public static void Initialise(ModelMetadata metadata, ValidationAttribute attribute)
    {
        object resourceKeyObj;
        string resourceKey;
        if (metadata.AdditionalValues.TryGetValue(attribute.GetHashCode().ToString(CultureInfo.InvariantCulture),
            out resourceKeyObj)
            && !string.IsNullOrWhiteSpace(resourceKey = (string)resourceKeyObj)
            && resourceKey.StartsWith("/"))
        {
            attribute.ErrorMessage = LocalizationService.GetString(resourceKey, resourceKey);
        }
    }
}

And here is our own extended implementation for CachedDataAnnotationsModelMetadataProvider...

[ServiceConfiguration(typeof(ModelMetadataProvider), Lifecycle = ServiceInstanceScope.Singleton)]
public class LocalizableModelMetadataProvider : CachedDataAnnotationsModelMetadataProvider
{
   protected override CachedDataAnnotationsModelMetadata CreateMetadataFromPrototype(CachedDataAnnotationsModelMetadata prototype, Func<object> modelAccessor)
   {
      var result = base.CreateMetadataFromPrototype(prototype, modelAccessor);
      foreach (var additionalValue in prototype.AdditionalValues)
      {
         result.AdditionalValues.Add(additionalValue.Key, additionalValue.Value);
      }
      return result;
   }
 
   protected override CachedDataAnnotationsModelMetadata CreateMetadataPrototype(IEnumerable<Attribute> attributes, Type containerType, Type modelType, string propertyName)
   {
   var theAttributes = attributes as Attribute[] ?? attributes.ToArray();
   var prototype = base.CreateMetadataPrototype(theAttributes, containerType, modelType, propertyName);
   foreach (var a in theAttributes.OfType<ValidationAttribute>()
         .Where(a => !string.IsNullOrWhiteSpace(a.ErrorMessage) 
         && a.ErrorMessage.StartsWith("/")))
      {
         prototype.AdditionalValues.Add(a.GetHashCode().ToString(CultureInfo.InvariantCulture), a.ErrorMessage);
      }
      return prototype;
   }
}

Finally, a small EPiServer Initialization Module to register the Attribute Adapters with the ASP.Net run-time.

[InitializableModule]
[ModuleDependency(typeof (InitializationModule))]
public class DataAnnotationsModelValidatorInitialization : IInitializableModule
{
    private static bool _initialized;
 
    public void Initialize(InitializationEngine context)
    {
        if (_initialized)
        {
            return;
        }
        LocalisableRequiredAnnotationsAdapter.SelfRegister();
        LocalisableStringLengthAnnotationsAdapter.SelfRegister();
        LocalisableEmailAddressAnnotationsAdapter.SelfRegister();
        LocalisableCompareAnnotationsAdapter.SelfRegister();
        _initialized = true;
    }
 
    public void Uninitialize(InitializationEngine context)
    {  }
 
    public void Preload(string[] parameters)
    {  }
}

We're done, except of course to create the Localization Resources, either in the form of an XML File read by the standard XML Localization Provider, or in the form required by whatever other Localization Provider you are using; maybe one similar to that suggested by Jeroen Stemerdink on his Blog.

Mar 11, 2014

Comments

valdis
valdis Mar 14, 2014 09:34 AM

Nice! Maybe to reduce adapter's self-registration repetitive code you may find some ideas in my post :)
http://tech-fellow.net/2013/05/05/localized-episerver-model-validation-attributes/

Please login to comment.
Latest blogs
Optimizely and the never-ending story of the missing globe!

I've worked with Optimizely CMS for 14 years, and there are two things I'm obsessed with: Link validation and the globe that keeps disappearing on...

Tomas Hensrud Gulla | Apr 18, 2024 | Syndicated blog

Visitor Groups Usage Report For Optimizely CMS 12

This add-on offers detailed information on how visitor groups are used and how effective they are within Optimizely CMS. Editors can monitor and...

Adnan Zameer | Apr 18, 2024 | Syndicated blog

Azure AI Language – Abstractive Summarisation in Optimizely CMS

In this article, I show how the abstraction summarisation feature provided by the Azure AI Language platform, can be used within Optimizely CMS to...

Anil Patel | Apr 18, 2024 | Syndicated blog

Fix your Search & Navigation (Find) indexing job, please

Once upon a time, a colleague asked me to look into a customer database with weird spikes in database log usage. (You might start to wonder why I a...

Quan Mai | Apr 17, 2024 | Syndicated blog

The A/A Test: What You Need to Know

Sure, we all know what an A/B test can do. But what is an A/A test? How is it different? With an A/B test, we know that we can take a webpage (our...

Lindsey Rogers | Apr 15, 2024

.Net Core Timezone ID's Windows vs Linux

Hey all, First post here and I would like to talk about Timezone ID's and How Windows and Linux systems use different IDs. We currently run a .NET...

sheider | Apr 15, 2024