Using ASP.NET 5 Tag Helpers and the Bing Translator API

Update: In the latest version of ASP.NET 5, the GetChildContextAsync, that was previously available in the TagHelperContext class, is now in TagHelperOutput.

Introduction

If you’ve been following my posts you probably know that I have been a critic of MVC because of its poor support of reuse. Until now, the two main mechanisms for reuse – partial views and helper extensions – had some problems:

  • Partial views cannot be reused across projects/assemblies;
  • Helper methods cannot be easily extended.

Fortunately, ASP.NET 5 offers (good) solutions for these problems, in the form of Tag Helpers and View Components! This time, I’ll focus on tag helpers.

Tag Helpers

A tag helper behaves somewhat like a server-side control in ASP.NET Web Forms, without the event lifecycle. It sits on a view and can take parameters from it, resulting on the generation of HTML (of course, can have other side effects as well).

Tag helper can either be declared as new tags, ones that do not exist in HTML, such as <animation>, <drop-panel>, etc, or can intercept any tag, matching some conditions:

  • Having some pre-defined tag;
  • Being declared inside a specific tag;
  • Having some attributes defined;
  • Having a well-known structure, like, self-closing or without ending tag.

A number of these conditions can be specified, for example:

  • All IMG tags;
  • All A tags having an attribute of action-name;
  • All SPAN tags having both translate and from-language attributes;
  • All COMPONENT tags declared inside a COMPONENTS tag.

ASP.NET 5 includes a number of them:

  • AnchorTagHelper: generates links to action methods in controllers;
  • CacheTagHelper: caches its content for a number of seconds;
  • EnvironmentTagHelper: renders its content conditionally, depending on the current execution environment;
  • FormTagHelper: posts to an action method of a controller;
  • ImageTagHelper: adds a suffix to an image URL, to control caching of the file;
  • InputTagHelper
  • LabelTagHelper
  • LinkTagHelper
  • OptionTagHelper
  • ScriptTagHelper
  • SelectTagHelper
  • TextAreaTagHelper
  • ValidationMessageTagHelper: outputs model validation messages;
  • ValidationSummaryTagHelper: outputs a validation summary.

In order to use tag helpers, we need to add a reference to the Microsoft.AspNet.Mvc.TagHelpers Nuget package in Project.json (at the time this post was written, the last version was 6.0.0-beta8):

"dependencies": {
    ...
    "Microsoft.AspNet.Mvc.TagHelpers": "6.0.0-beta8"
}

Enough talk, let’s see an example of the TagHelper class with some HtmlTargetElement attributes (no API documentation available yet):

[HtmlTargetElement("p", Attributes = "translate, to-language")]
[HtmlTargetElement("span", Attributes = "translate, to-language")]
public sealed class TranslateTagHelper : TagHelper
{
}

This tag helper will intercept the following tag declarations, inside a view:

<p translate="true" to-language="pt">This is some text meant for translation</p>
<span translate="true" to-language="pt">Same here</span>

Tag helpers need to be declared in a view before they can be used. They can come from any assembly:

@addTagHelper "*, MyNamespace"

Properties declared on the markup will be automatically mapped to properties on the TagHelper class. Special names, such as those having , will require an HtmlAttributeName attribute:

[HtmlAttributeName("to-language")]
public string ToLanguage { get; set; }

If, on the other hand, we do not want such a mapping, all we have to do is apply an HtmlAttributeNotBound attribute:

[HtmlAttributeNotBound]
public string SomeParameter { get; set; }

The TagHelper class defines two overridable methods:

  • Process: where the logic is declared, executes synchronously;
  • ProcessAsync: same as above, but executes asynchronously.

We only need to override one. In it, we have access to the passed attributes, the content declared inside the tag and its execution context:

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
    var content = await context.GetChildContentAsync();
    var text = content.GetContent();
 
    //do something with content
 
    content.TagName = "DIV";
    content = output.Content.Clear();
    content = output.Content.Append("HTML content");
}

You can see that I am redefining the output tag, in this case, to DIV, and, also, I am clearing all content and replacing it with my own.

Now, let’s see a full example!

Content Translation

Microsoft makes available for developers the Bing Translator API. It allows, at no cost (for a reasonable number of requests) to perform translations of text through a REST interface (several API bindings exist that encapsulate it). Let’s implement a tag helper that will automatically translate any text contained inside of it.

In order to use this API, you first need to register at the Microsoft Azure Marketplace. Then, we need to create a Marketplace application. After we do that, we need to generate an authorization key. There are several tools that can do this, in my case, I use Postmap, a Google Chrome extension that allows the crafting and execution of REST requests.

The request for generating an authorization key goes like this:

POST https://datamarket.accesscontrol.windows.net/v2/OAuth2-13

grant_type: client_credentials

client_id: <client id>

client_secret: <client secret>

scope: http://api.microsofttranslator.com

The parameters <client id> and <client secret> are obtained from our registered applications’ page and grant_type, client_id, client_secret and scope are request headers. The response should look like this:

{
   "token_type": "
http://schemas.xmlsoap.org/ws/2009/11/swt-token-profile-1.0",
   "access_token": "<access token>",
   "expires_in": "600",
   "scope": "
http://api.microsofttranslator.com"
}

Here, what interests us is the <access token> value. This is what we’ll use to authorize a translation request. The value for expires_in is also important, because it contains the amount of time the access token is valid.

A translation request should look like this:

GET http://api.microsofttranslator.com/V2/Ajax.svc/Translate?to=<target language>&text=<text to translate>&from=<source language>

Authorization: Bearer <access token>

The <target language> and <text> parameters are mandatory, but the <source language> one isn’t; if it isn’t supplied, Bing Translator will do its best to infer the language of the <text>. Authorization should be sent as a request header.

Let’s define an interface for representing translation services:

public interface ITranslationService
{
    Task<string> TranslateAsync(string text, string to, string @from = null);
}

Based on what I said, a Bing Translator implementation could look like this:

public sealed class BingTranslationService : ITranslationService
{
    public const string Url = "http://api.microsofttranslator.com/V2/Ajax.svc/Translate?text={0}&to={1}&from={2}";
 
    public string AuthKey { get; set; }
 
    public async Task<string> TranslateAsync(string text, string to, string @from = null)
    {
        using (var client = new HttpClient())
        {
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", this.AuthKey);
 
            var result = await client.GetStringAsync(new Uri(string.Format(Url, WebUtility.UrlEncode(text), to, from)));
 
            return result;
        }
    }
}

Let’s register this service in ConfigureServices:

services.AddSingleton<ITranslationService>((sp) => new BingTranslationService { AuthKey = "<access token>" });

Of course, do replace <access token> by a proper one, otherwise your calls will always fail.

Caching

In order to make our solution more scalable, we will cache the translated results, this way, we avoid unnecessary – and costly – network calls. ASP.NET 5 offers two caching contracts, IMemoryCache and IDistributedCache. For the sake of simplicity, let’s focus on IMemoryCache, On the ConfigureServices method of the Startup class let’s add the caching services:

services.AddCaching();

And now let’s see it all together.

Putting it All Together

The final tag helper looks like this:

[HtmlTargetElement("p", Attributes = "translate, to-language")]
[HtmlTargetElement("span", Attributes = "translate, to-language")]
public sealed class TranslateTagHelper : TagHelper
{
    private readonly ITranslationService svc;
    private readonly IMemoryCache cache;
 
    public TranslateTagHelper(ITranslationService svc, IMemoryCache cache)
    {
        if (svc == null)
        {
            throw new ArgumentNullException(nameof(svc));
        }
 
        this.svc = svc;
        this.cache = cache;
    }
 
    [HtmlAttributeName("from-language")]
    public string FromLanguage { get; set; }
 
    [HtmlAttributeName("to-language")]
    public string ToLanguage { get; set; }
 
    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        if (string.IsNullOrWhiteSpace(this.ToLanguage) == true)
        {
            throw new InvalidOperationException("Missing target language.");
        }
 
        var content = await context.GetChildContentAsync();
        var text = content.GetContent();
 
        if (string.IsNullOrWhiteSpace(text) == true)
        {
            return;
        }
 
        var cacheKey = $"{this.FromLanguage}:{text.ToLowerInvariant().Trim()}:{this.ToLanguage}";
        var response = string.Empty;
 
        this.cache?.TryGetValue(cacheKey, out response);
 
        if (string.IsNullOrWhiteSpace(response) == true)
        {
            response = await svc.TranslateAsync(text, this.ToLanguage, this.FromLanguage);
            response = response.Trim('"');
 
            cache?.Set(cacheKey, response);
        }
 
        content = output.Content.Clear();
        content = output.Content.Append(response);
    }
}

It tries to obtain the translated context from the cache – if it is registered – otherwise, it will issue a translation request. If it succeeds, it stores the translation in the cache. The cache key is a combination of the text (in lowercase), the source and destination languages. The only strictly required service is an implementation of our ITranslationService, the cache is optional. In order use the tag in a view, all we need is:

@addTagHelper "*, MyNamespace"
 
<p translate="yes" to-language="pt">This is some translatable content</p>

Conclusion

I hope I managed to convince you of the power of tag helpers. They are a powerful mechanism and a welcome addition to ASP.NET MVC. Looking forward to hearing what you can do with them!

                             

1 Comment

Add a Comment

As it will appear on the website

Not displayed

Your website