Templating in .NET

Tags: .net, templating, dotliquid

Hardcoding strings is one of the biggest crimes in programming. Seriously, you should never do this. Just imagine a situation, when it's Friday evening, you're under pressure and you're forced to release a new version of your app to the server. You were supposed to just add new email notification with hardcoded email signature. All tests pass, green lights, go, go go.

But. Your tired finger slipped from one letter to another and instead of:

Best regards,

Tucker Company

You end up with... well, you can imagine the fatal result.

This stuff almost happened to me some time ago and I think it was a sign to change something in my life.

And so I turned my head to dynamic content generation with the help of templating engines. This way, I don't need to store all the strings in the code and string.Format them. Instead, I can just store them in DB and then create some GUI tool so that even the client can edit everything by himself.

DotLiquid

This is a .NET version of Ruby's Liquid Markup and a few years ago, this was my first choice. They don't event have much of their own docs as they just refer to Ruby's ancestor.

I won't get into details, the quickstart is on their website, so just to give you some glimpse of the usage:

string template = "Dear {{name}}, Thank you for purchasing {{product}}.";
var compiledTemplate = DotLiquid.Template.Parse(template);
string finalContent = compiledTemplate.Render(
    DotLiquid.Hash.FromAnonymousObject(new { name = "John Wick", product = "Glock 18" }));

The result:

Dear John Wick, Thank you for purchasing Glock 18.

It's pretty easy to get start with it. DotLiquid has some nice features like filters and supports basic tags like if, for etc so you can have very simple logic inside your templates, which in my opinion is really, really good.

But there're two things that annoy me just a little.

1. Too much security can sometimes be pain in the arse

I know what you're thinking, but hear me out first.

When you're creating an application, that only your client or you will administer, I think there are times you can get away with just a little loosen security policy.

The thing with DotLiquid is that you can't always just pass an arbitrary object to the template. Something like this:

var order = new Order() { Id = 42, TransactionId = Guid.NewGuid() };
template = "Order id {{Id}}, Transaction {{TransactionId}}.";
compiledTemplate = DotLiquid.Template.Parse(template);
finalContent = compiledTemplate.Render(DotLiquid.Hash.FromAnonymousObject(order));

will work and the result will be:

Order id 42, Transaction 876d5315-b922-4420-87b8-660030cd8446.

But in the real life when you need to use more objects in one template, then you'll probably end up with something like this:

template = "User {{buyer.Name}} placed order id {{placedOrder.Id}}, transaction {{placedOrder.TransactionId}}.";
var user = new User() { Name = "John McClane" };
var order = new Order() { Id = 42, TransactionId = Guid.NewGuid() };
compiledTemplate = DotLiquid.Template.Parse(template);
finalContent = compiledTemplate.Render(
    DotLiquid.Hash.FromAnonymousObject( new { buyer = user, placedOrder = order } ));

which won't work anymore:

Liquid syntax error: Object 'DotNetTemplates.User' is invalid because it is neither a built-in type nor implements ILiquidizable...

The reason is that the object's type has to be "whitelisted" first. It's explained here and here.

You have some options to whitelist a class, of which this one is the least intrusive:

DotLiquid.Template.RegisterSafeType(typeof(Order), 
    typeof(Order).GetProperties().Select(x => x.Name).ToArray());
DotLiquid.Template.RegisterSafeType(typeof(User), 
    typeof(User).GetProperties().Select(x => x.Name).ToArray());

compiledTemplate = DotLiquid.Template.Parse(template);
finalContent = compiledTemplate.Render(
    DotLiquid.Hash.FromAnonymousObject(new { buyer = user, placedOrder = order }));

Now, when you invoke the same code as above, you'll get this:

User John McClane placed order id 42, transaction Missing property. Did you mean 'transaction_id'?.

So we're almost there. DotLiquid by default uses different naming convention, so instead of CamelCase it expects under_score_convention (I think it's Ruby's convention), but this can be easily fixed by setting:

DotLiquid.Template.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention();

before rendering a template. This should work, at least in theory, but apparently, something is still not quite right:

User John McClane placed order id 42, transaction .

It looks like there's something wrong with this convention, that's also described here, so you should either go back to Ruby's convention (yuck!) or do one trick with DropProxy class:

public class DotLiquidDropProxy : DotLiquid.DropProxy
{
    public DotLiquidDropProxy(object obj)
        : base(obj, obj.GetType()
            .GetMembers(BindingFlags.Instance | BindingFlags.Public)
            .Select(y => y.Name)
            .ToArray())
    {

    }
}
//...
DotLiquid.Template.RegisterSafeType(typeof(Order), 
    obj => new DotLiquidDropProxy(obj) );
DotLiquid.Template.RegisterSafeType(typeof(User),
    obj => new DotLiquidDropProxy(obj));
//...
var order = new Order() { Id = 42, TransactionId = Guid.NewGuid() };
var user = new User() { Name = "John McClane" };

compiledTemplate = DotLiquid.Template.Parse(template);
finalContent = compiledTemplate.Render(
    DotLiquid.Hash.FromAnonymousObject(new { buyer = user, placedOrder = order }));

Finally! We have our properly generated content:

User John McClane placed order id 42, transaction 59be611c-bd08-47a1-a166-899f5c08f5f4.

To sum it up: it doesn't really matter which convention you'll choose. In the end, DotLiquid's strict security policy will probably force you to just register all of your Domain/VM/Model classes as SafeTypes (with or without the help of DotLiquidDropProxy class depending on your naming convention) so you can just use them directly in your templates, without having to create yet another layer of mapping just for templates. Sure, sometimes it might be a good idea, but in many cases, it'll probably be unnecessary overcomplication.

2. Can't traverse properties of nested objects

This was an issue a few years ago when v. 1.7.0 of DotLiquid was released. You couldn't have something like that:

public class Address
{
    public string AddressLine { get; set; }
}
public class User
{
    public string Name { get; set; }
    public Address Address { get; set; }
}
//...
DotLiquid.Template.RegisterSafeType(typeof(Address), obj => new DotLiquidDropProxy(obj));
DotLiquid.Template.RegisterSafeType(typeof(User), obj => new DotLiquidDropProxy(obj));
//...
var address = new Address() { AddressLine = "Astana, Kazakhstan" };
var user = new User() { Name = "John McClane", Address = address };

template = "User {{buyer.Name}} address {{buyer.Address.AddressLine}}.";
compiledTemplate = DotLiquid.Template.Parse(template);
finalContent = compiledTemplate.Render(
    DotLiquid.Hash.FromAnonymousObject(new { buyer = user }));

because properties traversing wasn't supported yet. But now, in version 2.0.64, you get what you expected:

User John McClane address Astana, Kazakhstan.

Also, there was some a problem when displaying Guid as it wasn't treated as other primitive types, so you had to convert it to string manually. But it's also already solved.

To be honest, the second issue was the reason I switched to another templating engine (I'll probably write about it soon), but since it's already solved, then I might get back to DotLiquid.

I published above test codes on my GitHub, so you can check it out if you like.

Read also

ASP.NET MVC pretty URLs on steroids

As you may know, there's a little problem with the attribute routing in ASP.NET MVC. But now there's an easy way to fix it.

Data export tools for .NET

How to export data from .NET to PDFs, Word/Exel documents or CSVs.

Changing public API of your .NET assembly

Thinking about changing you API? Think hard before doing it.

Comments