Wednesday, November 10, 2010

Creating compile time supported URLs

COVERAGE: ASP.Net C#, Microsoft AntiXss, LINQ, CuttingEdge Conditions.

When it comes to ASP.Net and linking to other pages it's a very lose system. Which is strange when you think about it. We're working in assemblies here, pages are classes and classes can have 'relations' with other classes and be supported by the compiler. Yet there are a lot of NavigateUrl's being filled with strings from either code or markup. Brrr.
When I first started working with ASP.Net in C# (I used to VB) I noticed a pleasant automation when I created new pages. The namespace was being generated from the folder structure. Ding! There we go. With some reflection and there we are. So how does it work. Simple, here is a Page class that has a static method called GetOpenUrl(). It will return the virtual path to the page.

Using the PagePathBuilder


public partial class OrdersList : System.Web.UI.Page
{
    public static string GetOpenUrl()
    {
        return PagePathBuilder.CreateInstance<OrdersList>().ToString();
        // Calling this like: OrdersList.GetOpenUrl();
        // Returns: ~/Backend/OrdersList.aspx
    }
}

Notice that the CreateInstance is given the object as where you are in. But that's not all, we can start to enforce correct data types for parameters and validate them before even calling the page. Like so:

SECURITY: Only validating here is a bad security practice. The page itself still has to validate the received values.

public partial class EditOrder : System.Web.UI.Page
{
    public static string GetOpenUrl(int orderId)
    {
        return PagePathBuilder.CreateInstance<EditOrder>("orderid", orderId).ToString();
        // Calling this like: EditOrder.GetOpenUrl(1);
        // Returns: ~/Backend/EditOrder.aspx?orderid=1
    }
}

The example above shows the shortcutted version to call when only one parameter is needed. That's most of the time, so I decided to make it an overload for less and readable user code.

Here's the way when making an URL with multiple parameters:
public partial class EditOrder : System.Web.UI.Page
{
    public static string GetOpenUrl(int orderId)
    {
        var builder = PagePathBuilder.CreateInstance<EditOrder>();
        builder.AddParameter("orderid", orderId);
        builder.AddParameter("printVersion", true);
        return builder.ToString();
        // Calling this like: EditOrder.GetOpenUrl(1);
        // Returns: ~/Backend/EditOrder.aspx?orderid=1&printVersion=true
    }
}


The PagePathBuilder source

And here's the magic behind all of it: (Sorry, IE breaks the lines, Firefox doesn't and is a little better to read)
namespace SomeCompany.Code.Helpers
{
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    using System.Linq;
    using System.Text;
    using System.Web;
    using System.Web.UI;
    using CuttingEdge.Conditions;
    using Microsoft.Security.Application;

    /// <summary> Assists in building a path to a page with parameters. </summary>
    /// <example><![CDATA[
    /// Usage:
    ///        var builder = PagePathBuilder.CreateInstance(typeof(UserEdit));
    ///        builder.AddParameter("userid", 20);
    ///        builder.AddParameter("returnurl", "Users.aspx?selecteduserid=20");
    ///        EditUserHyperLink.NavigateUrl = builder.GetSafeUrl();
    /// Note:
    ///        The userid (20) is being hardtyped for this example.
    /// 
    /// Result:
    ///        UserEdit.aspx?userid=20&returnurl=Users.aspx%3fselecteduserid%3d20
    ///
    /// ]]></example>
    /// <remarks> Querystring values are encoded with the Anti XSS library from Microsoft. </remarks>
    public sealed class PagePathBuilder
    {
        /// Copyright Paul Appeldoorn, Devshed.nl 2010. http://blog.devshed.nl ///

        private readonly string pageName;
 
        // This is the root namespace of your webforms project.
        private const string rootNamespacePrefix = "SomeCompany.Product.WebSite.";

        private readonly List<KeyValuePair<string, string>> parameters;

        /// <summary> Initializes a new instance of the <see cref="PagePathBuilder"/>. </summary>
        /// <param name="pageName">The name of the page, including extension. </param>
        private PagePathBuilder(string pageName)
        {
            Condition.Requires(pageName, "pageName").IsNotNullOrEmpty();

            this.pageName = pageName;
            this.parameters = new List<KeyValuePair<string, string>>();
        }

        /// <summary> Adds a parameter to the page being called. </summary>
        /// <param name="name"> Name of the parameter. </param>
        /// <param name="value"> Value of the parameter. </param>
        public void AddParameter(string name, object value)
        {
            Condition.Requires(name, "name").IsNotNullOrEmpty();
            Condition.Requires(value, "value").IsNotNull();

            ValidateExistenceOfNewParameter(this.parameters, name);
    
            string cultureInvariantValue = GetCultureInvariantValue(value);
            this.parameters.Add(new KeyValuePair<string, string>(name, cultureInvariantValue));

            return this; // Return this instance for fluent code.
        }

        /// <summary> Builds and returns the actual url. </summary>
        /// <returns> The page name with encoded parameters. </returns>
        public string GetSafeUrl()
        {
            var finalUrl = new StringBuilder(this.pageName);

            if (this.parameters.Count > 0)
            {
                AppendParameters(finalUrl, this.parameters);
            }

            return finalUrl.ToString();
        }


        /// <summary> Returns a <see cref="System.String"/> that represents this instance. </summary>
        /// <returns> A <see cref="System.String"/> that represents this instance. </returns>
        public override string ToString()
        {
            return this.GetSafeUrl();
        }

        /// <summary> Creates a new instance of <see cref="PagePathBuilder"/>. </summary>
        /// <param name="pageName">The initial name of the page to start with (may include relative path). </param>
        /// <returns> A new instance of <see cref="PagePathBuilder"/>. </returns>
        public static PagePathBuilder CreateInstance(string pageName)
        {
            return new PagePathBuilder(pageName);
        }

        /// <summary> Creates the instance of the <see cref="PagePathBuilder"/> bases on the namespace and 
        /// class name. </summary>
        /// <remarks> Note that this methods depends on the matching of the namespace and class name to the 
        /// real physical path. StyleCop will enforce namespaces to match the physical path. </remarks>
        /// <param name="pageClass">The page class.</param>
        /// <returns> An initialized instance of the <see cref="PagePathBuilder"/> object ready to use. </returns>
        public static PagePathBuilder CreateInstance<T>() where T : IHttpHandler
        {
            Type page = typeof(T);
            string name = page.FullName;

            if (name.StartsWith(rootNamespacePrefix))
            {
                //// Cut off the prefix and replace dots by slashes:
                string fullPathToFile = name.Substring(rootNamespacePrefix.Length).Replace(".", "/");
                string fullPagePath = string.Format("~/{0}.{1}", fullPathToFile, GetExtension(page));

                return new PagePathBuilder(fullPagePath);
            }

            throw new InvalidOperationException("Wrong namespace '" + name + "'.");
        }

        /// <summary> Creates an instance using a custom path, in case the namespace is different to the 
        /// folder structure. Caution advised, consider changing the one of them so they match. </summary>
        /// <typeparam name="T"> Type of page to be linked to. </typeparam>
        /// <param name="basePath">The base path.</param>
        /// <returns></returns>
        public static PagePathBuilder CreateInstance<T>(string basePath) where T : IHttpHandler
        {
            Condition.Requires(basePath, "basePath").IsNotNullOrEmpty();
            Condition.Requires(basePath, "basePath").StartsWith("~/");

            while(!basePath.EndsWith("/"))
            {
                basePath = basePath.Substring(basePath.Length - 1 - 1);
            }

            Type page = typeof(T);
        
            string fileName = string.Format("{0}.{1}", page.Name, GetExtension(page));
            string fullPath = string.Format("{0}/{1}", basePath, fileName);
            return new PagePathBuilder(fullPath);
            
        }

        /// <summary> Creates an instance with an initial parameter and value, because it's common for
        /// most pages that require parameters. </summary>
        /// <typeparam name="T"> Type of page to be linked to. </typeparam>
        /// <param name="parameter"> Name of the parameter. </param>
        /// <param name="value"> Value to pass (culture invariant). </param>
        /// <returns> An initialized instance of the <see cref="PagePathBuilder"/> object ready to use. </returns>
        public static PagePathBuilder CreateInstance<T>(string parameter, object value)
                    where T : IHttpHandler
        {
            Condition.Requires(parameter, "basePath").IsNotNullOrEmpty();
            Condition.Requires(value, "basePath").IsNotNull();
            
            var instance = CreateInstance<T>();
            instance.AddParameter(parameter, value);
            return instance;
        }

        private static void ValidateExistenceOfNewParameter(
            List<KeyValuePair<string, string>> parameters, string name)
        {
            bool itemAlreadyExists =
                (from parameter in parameters
                 where parameter.Key.ToLower() == name.ToLower()
                 select parameter).Count() > 0;

            if(itemAlreadyExists)
            {
                throw new InvalidOperationException(
                    "The parameter name already exists in the collection.");
            }
        }

        private static string GetCultureInvariantValue(object value)
        {
            if(value is IFormattable)
            {
                return ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture);
            }

            return value.ToString();
        }

        private static void AppendParameters(StringBuilder finalUrl,
            IEnumerable<KeyValuePair<string, string>> parameters)
        {
            var parameterIterator = parameters.GetEnumerator();
            parameterIterator.MoveNext();
            finalUrl.Append(GrabAndCreateSafeParameter("?{0}={1}", parameterIterator));

            while(parameterIterator.MoveNext())
            {
                finalUrl.Append(GrabAndCreateSafeParameter("&{0}={1}", parameterIterator));
            }
        }

        private static string GrabAndCreateSafeParameter(
            string formatString, IEnumerator<KeyValuePair<string, string>> parameterIterator)
        {
            var parameter = parameterIterator.Current;
            return string.Format(formatString, parameter.Key, AntiXss.UrlEncode(parameter.Value));
        }

        private static string GetExtension(Type T)
        {
            if(T.IsSubclassOf(typeof(Page))) 
            {
                return "aspx"; 
            }

            return "ashx";
        }
    }
}

Notice the rootNamespacePrefix constant. It needs to be set correctly to your root namespace of your webapplication. Alternatively you could extract this away into a config file. Given values, when possible, will be added to the querystring as culture invariant values.

As you can see in the last function, there is support for ASPX pages and ASHX handlers.

LINQ

LINQ is needed, but not required. You could rewrite the statements easily to classic loops if you need to.

Microsoft AntiXss Library

The AntiXss library from Microsoft provides some good helpers to encode strings for HTML output or path parameters. Also not required, but recommended though.

Cutting Edge Conditions

This framework provides some neat and easy way to validate input variables. Highly recommend to use. See: http://conditions.codeplex.com/
Also, not required, recommended and rewritable if you have to.

Conclusion

Although this is not solving the problem on a full scale, it does serve as a great substitute. You have to keep your namespaces in-sync with your folders, a no-brainer in Visual Studio and when it's wrong (e.g. copy-pasting) you'll know.

It will at least assist you in giving more compile-time support for creating, hence it will be more clear to other developers how a page expects to be called. It makes creating and changing URLs less prone to errors than the old string.Format() calls or concatenated strings.

Pitfalls

Some folder names confuse the compiler and unexplainable things will happen. Names like System or some other reserved names can spoil the fun. Keep this in mind, it could happen to you and may not even be specific to this solution.

No comments:

Post a Comment