Build Providers: Strongly typed page urls in ASP.NET

posted on 3rd Feb 2008

I'm a fan of strong typing in .NET as you gain compile time validation of code, Visual Studio Intellisense support, not to mention less mucking around with type conversion or dealing with (usually inconsistently) entering strings for page names and configuration setting keys inline. Visual Studio provides intellisense and some degree of validation for pages (i.e. urls) within a project when using Design Mode, but using those urls in code, e.g..

Response.Redirect("~/MyDir/MyPage.aspx");

you're out of luck as there isn't any validation unless you write it yourself.

Perhaps a better syntax is to access urls within your project from a property somewhere, like so:

Response.Redirect(MyDir_MyPage);

This way, we reference a static variable or constant to retrieve the url we want to redirect the browser to. If the variable is spelt wrong, the project won't compile and we can fix the error. Being a member within the project means we're provided with intellisense support as well.

However, maintaining variables or constants for your pages can be tedious. If you change the name of a page from "MyPage.aspx" to "MyMovedPage.aspx" the actual value of the variable  "MyDir_MyPage" would also need to be updated. Once again, unless you have some automated tests to validate the page exists, the project will compile, but the if you forgot to update the reference, you'll get the wrong url.

Using a Build Provider to deal with changing file names

To get around these limitations, and to help lessen the maintenance within your project, we can use a build provider to generate a static class containing all the page's in our project. Once that's done, we get:

  • Intellisense - no more typing page address strings in code.
  • Compile time support - if a page is renamed, the build provider updates the generated class, and changes the name of the variable. Since the variable you referenced in your project no longer exists, you'll get compile errors wherever the variable is used. It's a simple hop, skip and a jump to go through and correct those errors.

So now we'll be able to use the following code, and know that if we change something, we'll get notified when when we build if something is amiss.

Response.Redirect(Href.MyDir.MyPage);

The code for the build provider generates the following pseudo code:

  • create a static root class called "Href"
  • all pages within the root of the project will be exposed as string constants
  • recurse subfolders, adding nested/inner classes and members to represent the hierarchy of the project

We'll look at the code for the build provider a little later, first lets look at what it will generate.

It's all Constants and Nested Classes

Using the previous example, the code the build provider generates looks something along the lines of...

public static class Href
{
	// ... repeat, one constant for each
	// file in the project root folder
	public const string Default = "~/";

	// ... repeat, one "nested class" for
	// each folder in the project root folder
	public static class MyDir
	{
		// ... repeat, one constant for each
		// file in the folder: /MyDir
		public const string MyPage = "~/mydir/mypage.aspx";
	}
}

Next: Setting up the build provider and configuration options.

Build providers must be compiled and referenced in the /bin folder of your project. You can't simply add the code for the build provider to the App_Code folder, as it will give a "Could not load type ''MyBuildProvider" error.

Once we've added the assembly reference to our project, we can add the relevant configuration information to the web.config.

<system.web>
	<compilation>
		<buildProviders>
			<add extension=".hrefs" type="CSharpVitamins.Compilation.HrefBuildProvider, CSharpVitamins"/>
		</buildProviders>
	</compilation>
</system.web>

Here I've chosen to associate files with the ".hrefs" extension with my HrefBuildProvider. You could easily choose another extension, as it's simply a mapping for a file extension found within the App_Code folder and the build provider.

Now we can create a file with that extension in the App_Code folder (let's use "Site.hrefs" for now, but you could use any name. You could even have multiple .hrefs files with different class names, one for aspx pages, another for your images, javascript and css files :), and add the following configuration - then tailor to suit your preferences.

<?xml version="1.0" encoding="utf-8" ?>
<settings
	namespace=""
	className="Href"
	maxDepth="100"
	lowercaseUrls="true"
	includeExtension="false"
	makePartial="false">

	<files include="\.(as[pcmh]x|html?)$" exclude="" />
	<folders include="" exclude="App_|Bin" />
</settings>

Settings

namespace The namespace the generated class will be placed within. Leave blank to add to the global namespace.

className The actual class name that forms the stub of all urls. Defaults to "Href".

maxDepth The maximum depth of recursion for processing. Enter a value of 1 for root files only, or higher to include subfolders. Defaults to 100.

lowercaseUrls When true, forces the values of urls to lowercase. Handy of you want to have to pages named in PascalCase but want urls all lowercase. Property names follow the same casing as the file name. Defaults to false.

includeExtension When true, the extension is appended to the property name separated by an underscore. Defaults to false.

makePartial When true, the root class uses the partial modifier, enabling you to add additional members through a non-auto-generated class file.

files Contains two regular expression patterns, once each for files to include and exclude. Include defaults to "\.aspx$", Exclude defaults to empty (none are explicitly excluded).

folders Same as files, but applied to folders. Include default to empty (all are included), Exclude defaults to "App_|Bin".

Apart from the juicy code, which is coming next, that's it. Now you'll have intellisense for your file locations and compile time checking. It's worth noting that the generated class from the build provider will regenerate when the .hrefs file is modified, or you rebuild. If you want to see the generated class (it's stored in the "Temporary ASP.NET Files" folder), I'd suggest simply to right click on "Href" from your code, and choose, "Go to definition" - it will bring up the temporary file for you to peruse.

Source code for the HrefBuildProvider

Rather than walking through the step by step of creating a build provider, I'll just point you to a couple of other articles on the subject if you wish to learn more, then I can just display the fully commented source code for the class below :)

You can download the source code for the HrefBuildProvider or look at the class definition below.

using System;
using System.CodeDom;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Web.Compilation;
using System.Web.Hosting;
using System.Xml;

namespace CSharpVitamins.Compilation
{
	/// <summary>
	/// Generates a static class for urls within an ASP.NET project
	/// </summary>
	public class HrefBuildProvider : BuildProvider
	{
		#region Fields

		/// <summary>
		/// Path to the web root for determining urls within the site
		/// </summary>
		string _basePath;
		/// <summary>
		/// Whether to convert urls to lowercase - based on your preference
		/// </summary>
		bool _useLowerCaseUrls;
		/// <summary>
		/// Whether to include the extension of the file in the name
		/// of the member
		/// </summary>
		bool _includeExtension;
		/// <summary>
		/// Whether to create the root class as 'partial', allowing
		/// additional members to be added to the resultant class.
		/// </summary>
		bool _makePartial;
		/// <summary>
		/// Maximum recurring depth for the project - 1 for top level,
		/// 2 for first level of subfolders etc...
		/// </summary>
		int _maxDepth = 100;
		/// <summary>
		/// Namespace the generated class will be placed within.
		/// Leave empty to add to the global namespace e.g. global::Href
		/// </summary>
		string _namespace;
		/// <summary>
		/// The name of the main class e.g. Href
		/// </summary>
		string _className;

		/// <summary>
		/// Current depth of recursion
		/// </summary>
		int _depth = 0;
		/// <summary>
		/// Predicate to determine if a file should be included
		/// </summary>
		Predicate<FileSystemInfo> isValidFile;
		/// <summary>
		/// Predicate to determine if a folder should be included
		/// </summary>
		Predicate<FileSystemInfo> isValidFolder;

		#endregion

		#region GenerateCode

		/// <summary>
		/// Initialises settings from the BuildProvider file, and
		/// generates the appropriate code.
		/// </summary>
		/// <param name="assemblyBuilder"></param>
		public override void GenerateCode(AssemblyBuilder assemblyBuilder)
		{
			// init settings from xml source file
			init();

			// create root class e.g. global::Href
			CodeTypeDeclaration root = createStaticClass(_className, _makePartial);
			addSummary(root, "Provides access to urls within the project.");
			build(new DirectoryInfo(_basePath), root);

			CodeNamespace ns = new CodeNamespace();
			ns.Types.Add(root);

			// leave blank for global::namespace
			if (!string.IsNullOrEmpty(_namespace))
				ns.Name = _namespace;

			CodeCompileUnit unit = new CodeCompileUnit();
			unit.Namespaces.Add(ns);
			assemblyBuilder.AddCodeCompileUnit(this, unit);
		}

		#endregion

		#region build

		/// <summary>
		/// Iterates over the given directory, adding the files as members to
		/// the parent type, then recurse down the folder tree until the max
		/// depth is reached.
		/// </summary>
		/// <param name="dir">The directory to process</param>
		/// <param name="parent">Parent class to add members too.</param>
		void build(DirectoryInfo dir, CodeTypeDeclaration parent)
		{
			if (_depth >= _maxDepth)
				return;

			++_depth;

			/// 'members' keeps a record of the number of times a
			/// member name is repeated within the parent type/class.
			/// Pass this to 'ensureUniqueMemberName' to get the name
			/// with an index number appended to the end e.g. the second
			/// occurrence of "MyProperty" becomes "MyProperty1"
			Dictionary<string, int> members = new Dictionary<string, int>();

			///
			/// process files:
			/// iterate over files and add the member
			/// public const string MyfileName = "~/myfileName.aspx";
			///
			FileInfo[] files = dir.GetFiles();
			foreach (FileInfo file in files)
			{
				if (isValidFile(file))
				{
					CodeMemberField field = new CodeMemberField();
					field.Name = getName(file);
					field.Type = new CodeTypeReference(typeof(string));
					field.Attributes = MemberAttributes.Public | MemberAttributes.Const;
					field.InitExpression = getInitExpression(getUrl(file));
					addSummary(field, getUrl(file, false));
					ensureUniqueMemberName(members, field);

					parent.Members.Add(field);
				}
			}

			///
			/// process subfolders:
			/// iterate over folders and add a nested class
			///
			DirectoryInfo[] subfolders = dir.GetDirectories();
			foreach (DirectoryInfo folder in subfolders)
			{
				if (isValidFolder(folder))
				{
					CodeTypeDeclaration nested = createStaticClass(getName(folder), false);
					addSummary(nested, "Provides access to urls under: {0}", getUrl(folder, false).TrimStart('~'));
					ensureUniqueMemberName(members, nested);
					build(folder, nested);

					/// .ctor will have already been added to members
					/// so only add to parent if there are additional
					/// members present
					if (nested.Members.Count > 1)
						parent.Members.Add(nested);
				}
			}

			--_depth;
		}

		#endregion

		#region init

		/// <summary>
		/// Initialises settings from the config file.
		/// </summary>
		/// <example>
		///
		/// Sample configuration - all nodes and elements are optional, but the file
		/// needs a root node to load as an xml document.
		/// <?xml version="1.0" encoding="utf-8" ?>
		/// <settings
		///    namespace=""
		///    className="Href"
		///    maxDepth="100"
		///    lowercaseUrls="true"
		///    includeExtension="false">
		///
		///    <files include="\.(aspx|html)$" exclude="\.htm$" />
		///    <folders include="" exclude="App_|Bin|Templates" />
		///  </settings>
		///
		/// </example>
		void init()
		{
			_basePath = HostingEnvironment.ApplicationPhysicalPath;

			XmlDocument xml = new XmlDocument();
			using (Stream stream = VirtualPathProvider.OpenFile(base.VirtualPath))
				xml.Load(stream);

			/// code below uses syntax,
			///      xml["elementName"] ?? xml.CreateElement( "elementName" );
			/// as a lazy initialisation technique, rather than checking for
			/// the node and performing alternate initialisation - this keeps
			/// it within the same reading context.

			///
			/// general settings
			///
			XmlElement settings = xml["settings"] ?? xml.CreateElement("settings");
			_maxDepth = int.Parse(getAttributeValue(settings, "maxDepth", "100"));
			_useLowerCaseUrls = bool.Parse(getAttributeValue(settings, "lowercaseUrls", "false"));
			_includeExtension = bool.Parse(getAttributeValue(settings, "includeExtension", "false"));
			_makePartial = bool.Parse(getAttributeValue(settings, "makePartial", "false"));
			_namespace = getAttributeValue(settings, "namespace");
			_className = getAttributeValue(settings, "className", "Href");

			///
			/// files settings
			///
			XmlElement files = settings["files"] ?? xml.CreateElement("files");
			isValidFile = createFilter(
				getAttributeValue(files, "include", @"\.aspx$"),
				getAttributeValue(files, "exclude")
				);

			///
			/// folders settings
			///
			XmlElement folders = settings["folders"] ?? xml.CreateElement("folders");
			isValidFolder = createFilter(
				getAttributeValue(folders, "include"),
				getAttributeValue(folders, "exclude", "App_|Bin")
				);
		}

		#endregion

		#region getAttributeValue

		/// <summary>
		///
		/// </summary>
		/// <param name="parent"></param>
		/// <param name="name"></param>
		/// <param name="default"></param>
		/// <returns></returns>
		static string getAttributeValue(XmlElement parent, string name, string @default)
		{
			XmlAttribute attribute = parent.Attributes[name];
			return null == attribute ? @default : attribute.Value.Trim();
		}

		/// <summary>
		///
		/// </summary>
		/// <param name="parent"></param>
		/// <param name="name"></param>
		/// <returns></returns>
		static string getAttributeValue(XmlElement parent, string name)
		{
			return getAttributeValue(parent, name, null);
		}

		#endregion

		#region createFilter

		/// <summary>
		/// Creates a Predicate for filtering based on the given regex patterns
		/// </summary>
		/// <param name="includePattern"></param>
		/// <param name="excludePattern"></param>
		/// <returns></returns>
		static Predicate<FileSystemInfo> createFilter(string includePattern, string excludePattern)
		{
			Regex include = null, exclude = null;

			if (!string.IsNullOrEmpty(includePattern))
				include = new Regex(includePattern,
					RegexOptions.Compiled | RegexOptions.IgnoreCase);

			if (!string.IsNullOrEmpty(excludePattern))
				exclude = new Regex(excludePattern,
					RegexOptions.Compiled | RegexOptions.IgnoreCase);

			return createFilter(include, exclude);
		}

		/// <summary>
		/// Creates a Predicate for filtering based on the given regex
		/// </summary>
		/// <param name="include"></param>
		/// <param name="exclude"></param>
		/// <returns></returns>
		static Predicate<FileSystemInfo> createFilter(Regex include, Regex exclude)
		{
			if (null != include && null != exclude)
				return delegate(FileSystemInfo info) { return include.IsMatch(info.FullName) && !exclude.IsMatch(info.FullName); };
			else if (null != include)
				return delegate(FileSystemInfo info) { return include.IsMatch(info.FullName); };
			else if (null != exclude)
				return delegate(FileSystemInfo info) { return !exclude.IsMatch(info.FullName); };
			else
				return delegate(FileSystemInfo info) { return true; };
		}

		#endregion

		#region getInitExpression

		/// <summary>
		/// Returns the given value surrounded with quotes.
		/// </summary>
		/// <param name="value"></param>
		/// <returns></returns>
		static CodeSnippetExpression getInitExpression(string value)
		{
			return new CodeSnippetExpression(string.Concat("\"", value, "\""));
		}

		#endregion

		#region getName

		/// <summary>
		/// Gets the normalised/escaped member name for the given file
		/// </summary>
		/// <param name="file"></param>
		/// <returns></returns>
		string getName(FileSystemInfo file)
		{
			string name = Path.GetFileNameWithoutExtension(file.Name);

			if (_includeExtension)
				name += file.Extension.Replace('.', '_');

			name = Regex.Replace(name, @"[^a-z0-9_]*", string.Empty,
				RegexOptions.Compiled | RegexOptions.IgnoreCase);

			// Ensure pascal casing of the name - not really required
			//name = TextHelper.PascalCase( name );

			// Escape names not starting with a letter
			if (name.Length > 0 && !char.IsLetter(name[0]))
				name = "_" + name;

			// Escape C# keywords
			// included in separate library, omitted here for clarity
			//name = CodeHelper.CSharp.EscapeWord(name);

			return name;
		}

		#endregion

		#region getUrl

		/// <summary>
		/// Gets the url of the file, relative to the app root
		/// </summary>
		/// <param name="file"></param>
		/// <returns></returns>
		string getUrl(FileSystemInfo file)
		{
			return getUrl(file, true);
		}

		/// <summary>
		/// Gets the url of the file, relative to the app root
		/// </summary>
		/// <param name="file"></param>
		/// <param name="allowNormalisation">When true, allows lowercasing of the
		/// url and stripping of default.aspx</param>
		/// <returns></returns>
		string getUrl(FileSystemInfo file, bool allowNormalisation)
		{
			string url = file.FullName.Substring(_basePath.Length);

			if (allowNormalisation)
			{
				if (_useLowerCaseUrls)
					url = url.ToLower();

				if (url.EndsWith("default.aspx", StringComparison.OrdinalIgnoreCase))
					url = url.Substring(0, url.Length - 12);
			}

			return string.Concat("~/", url.Replace("\\", "/"));
		}

		#endregion

		#region addSummary

		/// <summary>
		/// Adds a summary doc comment to the type's comment collection
		/// </summary>
		/// <param name="type"></param>
		/// <param name="format"></param>
		/// <param name="args"></param>
		void addSummary(CodeTypeMember type, string format, params object[] args)
		{
			addSummary(type.Comments, format, args);
		}

		/// <summary>
		/// Adds a summary doc comment to the collection
		/// </summary>
		/// <param name="comments"></param>
		/// <param name="format"></param>
		/// <param name="args"></param>
		void addSummary(CodeCommentStatementCollection comments, string format, params object[] args)
		{
			comments.Add(new CodeCommentStatement("<summary>", true));
			comments.Add(new CodeCommentStatement(string.Format(format, args), true));
			comments.Add(new CodeCommentStatement("</summary>", true));
		}

		#endregion

		#region createStaticClass

		/// <summary>
		/// Creates a static class type with the given name
		/// </summary>
		/// <param name="name"></param>
		/// <param name="partial"></param>
		/// <returns></returns>
		static CodeTypeDeclaration createStaticClass(string name, bool partial)
		{
			CodeTypeDeclaration type = new CodeTypeDeclaration(name);
			type.TypeAttributes |= System.Reflection.TypeAttributes.Sealed;
			type.Attributes = MemberAttributes.Public | MemberAttributes.Static;
			type.IsClass = true;
			type.IsPartial = partial;

			CodeConstructor ctor = new CodeConstructor();
			ctor.Attributes = MemberAttributes.Private;
			type.Members.Add(ctor);

			return type;
		}

		#endregion

		#region ensureUniqueMemberName

		/// <summary>
		/// Ensures a unique name within the dictionary to get the name
		/// with an index number appended to the end e.g. the second
		/// occurrence of "MyProperty" becomes "MyProperty1"
		/// </summary>
		/// <remarks>A caveat of this method is where you may have two members
		/// like MyPage.aspx, MyPage.html as well as MyPage1.aspx. A conflict
		/// will occur giving duplicate members i.e. in the same order MyPage,
		/// MyPage1 and MyPage1 a second time. The likelihood of such conflicts
		/// is possible, with one possible solution to recurse using
		/// ensureUniqueMemberName with the newly generated name, or enumerate
		/// the parent type's Members to see whether there will be a conflict.
		/// </remarks>
		/// <param name="members">Dictionary containing the name of the members
		/// in the current namespace as the key + the number of appearances as
		/// the value.</param>
		/// <param name="type">The next member to add to the current namespace.</param>
		static void ensureUniqueMemberName(Dictionary<string, int> members, CodeTypeMember type)
		{
			string key = type.Name;
			int count;
			if (members.TryGetValue(key, out count))
			{
				type.Name = string.Concat(key, ++count);
				members[key] = count;
			}
			else
			{
				members[key] = 0;
			}
		}

		#endregion
	}
}