Extending Tag Helpers in ASP.NET Core Applications

A few days ago, I was answering questions on Stack Overflow when I came across one that I thought might be relevant to share with others that might have a similar issue. The issue itself revolved around Tag Helpers, and in particular, getting data attributes found on models to properly generate the appropriate attributes when rendered.

This post will introduce how to easily extend an existing tag helper for a common use-case. It will primarily focus around the use of the Input Tag Helper, the [MaxLength] annotation commonly found on string properties on a model, and how to get that to properly render the corresponding client-side maxlength attribute on the element.

The Problem with Some Tag Helpers

At present, tag helpers are extremely powerful and do a great job at figuring out which properties should and should not be rendered for a given class. Consider the following example:

[Display(Name = "Foo")]
[Required]
[MaxLength(8, ErrorMessage = "Foo cannot be longer than 8 characters.")]
public string Foo { get; set; }

If you wanted to use a tag helper, you might define this as follows within your View:

<input asp-for="Foo" data-val="true" />

The asp-for attribute is arguably one of the most common tag helpers that you'll encounter, and its primary purpose is to handle binding a specific property to the element that it decorates.

This works very similar to the Html.TextBoxFor() helper that you should be accustomed to. It should read the attributes that it can off of the property itself and then apply the appropriate attributes. The data-val attribute on the helper is simply to indicate that we want to use client-side validation (e.g. jQuery Validation, etc.) for this particular item.

So let's see what gets rendered:

<input data-val="true" 
       type="text" 
       data-val-maxlength="Foo cannot be longer than 8 characters" 
       data-val-maxlength-max="8"
       data-val-required="The Foo field is required." 
       id="Foo" 
       name="Foo" 
       value="" />

While the tag helper did its best, it seemed to have missed the crucial maxlength attribute that we need to enforce length validation. Let's see how we can go about extending it to support this.

Extending the Input Tag Helper

To tackle this problem, we are going to extend the existing asp-for attribute of the input tag helper and use a bit of reflection to read the [MaxLength] annotation and render it as necessary.

Let's create a new class called MaxLengthTagHelper which looks like the following:

namespace YourProject.TagHelpers
{
    [HtmlTargetElement("input", Attributes = "asp-for")]
    public class MaxLengthTagHelper : TagHelper
    {
        public override int Order { get; } = int.MaxValue;

        [HtmlAttributeName("asp-for")]
        public ModelExpression For { get; set; }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            base.Process(context, output);

            // Process only if 'maxlength' attribute is not present already
            if (context.AllAttributes["maxlength"] == null) 
            {
                // Attempt to check for a MaxLength annotation
                var maxLength = GetMaxLength(For.ModelExplorer.Metadata.ValidatorMetadata);
                if (maxLength > 0)
                {
                    output.Attributes.Add("maxlength", maxLength);
                }
            }
        }

        private static int GetMaxLength(IReadOnlyList<object> validatorMetadata)
        {
            for (var i = 0; i < validatorMetadata.Count; i++)
            {
                if (validatorMetadata[i] is StringLengthAttribute stringLengthAttribute && stringLengthAttribute.MaximumLength > 0)
                {
                    return stringLengthAttribute.MaximumLength;
                }

                if (validatorMetadata[i] is MaxLengthAttribute maxLengthAttribute && maxLengthAttribute.Length > 0)
                {
                    return maxLengthAttribute.Length;
                }
            }
            return 0;
        }
    }
}

As you can see within the Process() method, we are going to pass in the available metadata for the property and iterate through it to see if we can find the an existing MaxLengthAttribute item (if one isn't already defined):

// Process only if 'maxlength' attribute is not present already
if (context.AllAttributes["maxlength"] == null) 
{
       // Attempt to check for a MaxLength annotation
       var maxLength = GetMaxLength(For.ModelExplorer.Metadata.ValidatorMetadata);
       if (maxLength > 0)
       {
              output.Attributes.Add("maxlength", maxLength);
       }
}

You could easily extend this to check the metadata for other common data annotations and use the same basic idea to add custom attributes of your own. But for now, we will move on to actually using this particular one.

Using the Extended Tag Helper

In order to have access to this tag helper within our view, we will need to actually import it. This can generally be done within the _ViewImports.cshtml file, which is commonly found in most default project templates and defines any assemblies and namespaces that the views will need to access.

You'll just need to add the appropriate namespace where you defined the class and you should be good to go:

@using YourProject
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, YourProject

After adding that, you won't need to change your code at all and you should see that the previous code will now render as follows:

<input data-val="true" 
       type="text" 
       data-val-maxlength="Foo cannot be longer than 8 characters." 
       data-val-maxlength-max="8" 
       data-val-required="The Foo field is required." 
       id="Foo" 
       name="Foo" 
       value="" 
       maxlength="8" />

As you can see, the maxlength attribute was added to the element as expected and should now enforce the same length constraints that are present on the model within the browser.

Extending It Further

Now since you are already including the maximum length a string can be, we will probably want to include the minimum as well. This can be done by the addition of a GetMinLength() method call and including it within the Process() method to ensure that any attributes found are rendered to the element:

public override void Process(TagHelperContext context, TagHelperOutput output)
{
    // Previous code here omitted for brevity

    if (context.AllAttributes["minlength"] == null)
    {
        // Attempt to check for a MaxLength annotation
        var minLength = GetMinLength(For.ModelExplorer.Metadata.ValidatorMetadata);
        if (minLength >= 0)
        {
            output.Attributes.Add("minlength", minLength);
        }
    }
}

private static int GetMinLength(IReadOnlyList<object> validatorMetadata)
{
    for (var i = 0; i < validatorMetadata.Count; i++)
    {
        if (validatorMetadata[i] is StringLengthAttribute stringLengthAttribute && stringLengthAttribute.MinimumLength > 0)
        {
            return stringLengthAttribute.MaximumLength;
        }

        if (validatorMetadata[i] is MinLengthAttribute minLengthAttribute && minLengthAttribute.Length > 0)
        {
                return minLengthAttribute.Length;
        }
    }
    return 0;
}

Now if we were to decorate the property using any combination of the [MinLength] and [MaxLength] annotations:

[MinLength(2, ErrorMessage = "Foo must be at least 2 characters.")]
[MaxLength(8, ErrorMessage = "Foo cannot be longer than 8 characters.")]
public string Foo { get; set; }

Or the [StringLength] annotation, which can be used to set both a minimum and maximum string length size:

[StringLength(2, ErrorMessage = "Foo must be between 2 and 8 characters.")]
public string Foo { get; set; }

We should see that both the minlength and maxlength attributes are present on the rendered input element as expected:

<input data-val="true" 
       type="text" 
       data-val-maxlength="Foo cannot be longer than 8 characters." 
       data-val-maxlength-max="8" 
       data-val-required="The Foo field is required." 
       id="Foo" 
       name="Foo" 
       value="" 
       maxlength="8"
       minlength="2" />

Scratching the Surface

This really just scratches the surface is the most basic of use cases. Tag helpers can handle implementing nearly any types of functionality, from performing simple annotation / attribute additions to complex data binding scenarios. This particular example simply extended an already existing tag helper, but you can just as easily create your own and do whatever you see fit.

If you are interested in learning more about tag helpers and some of the things that they are capable of, I'd encourage you to check out some of the following resources: