ASP.NET MVC: Discover the MasterPageFile Value at Runtime

A couple weeks ago it was finally time to add a context-sensitive, data driven menu system to our MVC application. As I thought about it I was stuck. I wasn’t sure what the best way to implement it was. As is common with an MVC application there was no 1-to-1 relationship between actions and views. And even more difficult was that our *.master files could be used by views tied to different controllers. So it was looking like I would have to load the data I needed from the ViewMasterPage.

I really didn’t like this option and looked around a bit trying to find out what others had done. Here’s a couple examples of what I found:

While all of these options work, none of them sat well with me because they either require me to remember to include the data or they feel contrived or foreign to MVC.

@Page Directive

When you create a new View file you can specify that you want to use a MasterPage. When you do this your @Page Directive will look like this:

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Default.Master" Inherits="System.Web.MVC.ViewPage" %>

This can be changed as needed but if you are using MasterPages in your application you the value of the MasterPageFile is exactly what you need to determine which MasterPage is being used by the view being returned. I like this idea because the same action can return different views, or even result in a redirect, so it isn’t until you actually arrive at the controller’s ActionExecuted event that you know for sure that the result is a View and which view that will be.

Controller.OnActionExecuted event

The key to the whole thing is you need to be able to read the @Page directive located in the first line of your ViewPage. When you’re handling the OnActionExecuted event you get an ActionExecutedContext object passed in from System.Web.MVC.ControllerBase which contains the result of Action which just finished executing. Here’s what you do to get from the start of the event to the value of MasterPageFile:

  1. Check to see if ActionExecutedContext.Result is an ViewResult
  2. Check to see if ViewResult.ViewName has been set (if you’re writing tests for your Actions you’ll be doing this anyway). If it hasn’t then you know that the name of your view will be the same as the Action, so you can get the value from ControllerContext.RouteData.
  3. As long as you are using the WebForms view engine (or inheriting from it) you can use the ViewResult.ViewEngineCollection.FindView method to let the ViewEngine find the view for you.
  4. FindView returns a ViewEngineResult has a View property which returns a WebFormView which in turn has a ViewPath property.
  5. At this point you can get the source of your view, parse it and retrieve the value of MasterPageFile. Once you’ve done this I’d recommend caching the value to prevent the need to parse the file every time.

Here’s what the full implementation looks like:

using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
namespace GPM.Web.Code {
 public class MasterMenuDataFilterAttribute : ActionFilterAttribute {
 // private vairables supporting MasterPageFile discovery
 private static Dictionary _viewMasterType = new Dictionary();
 private static Regex _masterFilePath = new Regex("\\bMasterPageFile=\"(?[^\"]*)\"", RegexOptions.Compiled);
 // private members for improved readability
 private HttpContextBase _httpContext;
 private ControllerContext _controllerContext;
 /// 
 /// Loads data for dynamic menus in our MasterPage (if applicable)
 /// 
 private void LoadMenuData(string viewName, string masterPath) {
 if (string.IsNullOrEmpty(masterPath) || !System.IO.File.Exists(_httpContext.Server.MapPath(masterPath)))
 return;
 switch (Path.GetFileName(masterPath)) {
 case "Site.Master":
 break;
 case "Default.Master":
 break;
 case "Custom.Master":
 break;
 default:
 break;
 }
 }
 /// 
 /// Discovers the master page declared by the view so we can determine
 /// which menu data we need loaded for the view
 /// 
 /// 
 /// If we find that we have too many controllers which don't need this 
 /// functionality we can impelment this as a filter attribute instead
 /// and apply it only where needed.
 /// 
 public override void OnActionExecuted(ActionExecutedContext filterContext) {
 // this logic only applies to ViewResult 
 ViewResult result = filterContext.Result as ViewResult;
 if (result == null)
 return;
 // store contexts as private members to make things easier
 _httpContext = filterContext.HttpContext;
 _controllerContext = filterContext.Controller.ControllerContext;
 // get the default value for ViewName
 if (string.IsNullOrEmpty(result.ViewName))
 result.ViewName = _controllerContext.RouteData.GetRequiredString("action");
 string cacheKey = _controllerContext.Controller.ToString() + "_" + result.ViewName;
 // check to see if we have cached the MasterPageFile for this view
 if (_viewMasterType.ContainsKey(cacheKey)) {
 // Load the data for the menus in our MasterPage
 LoadMenuData(result.ViewName, _viewMasterType[cacheKey]);
 return;
 }
 // get the MasterPageFile (if any)
 string masterPath = DiscoverMasterPath(result);
 // make sure this is thread-safe
 lock (_viewMasterType) {
 // cache the value of MasterPageFile
 if (!_viewMasterType.ContainsKey(cacheKey)) {
 _viewMasterType.Add(cacheKey, masterPath);
 }
 }
 // now we can load the data for the menus in our MasterPage
 LoadMenuData(result.ViewName, masterPath);
 }
 /// 
 /// Parses the View's source for the MasterPageFile attribute of the Page directive
 /// 
 /// The ViewResult returned from the Controller's action
 /// The value of the Page directive's MasterPageFile attribute
 private string DiscoverMasterPath(ViewResult result) {
 string masterPath = string.Empty;
 // get the view
 ViewEngineResult engineResult = result.ViewEngineCollection.FindView(
 _controllerContext, result.ViewName, result.MasterName);
 // oops! caller is going to throw a "view not found" exception for us, so just exit now
 if (engineResult.View == null)
 return string.Empty;
 // we currently only support the WebForms view engine, so we'll exit if it isn't WebFormView
 WebFormView view = engineResult.View as WebFormView;
 if (view == null)
 return string.Empty;
 // open file contents and read header for MasterPage directive
 using (StreamReader reader = System.IO.File.OpenText(_httpContext.Server.MapPath(view.ViewPath))) {
 // flag to help short circuit our loop early
 bool readingDirective = false;
 while (!reader.EndOfStream) {
 string line = reader.ReadLine();
 // don't bother with empty lines
 if (string.IsNullOrEmpty(line))
 continue;
 // check to see if the current line contains the Page directive
 if (line.IndexOf("<%@ Page") != -1)
 readingDirective = true;
 // if we're reading the Page directive, check this line for the MasterPageFile attribute
 if (readingDirective) {
 Match filePath = _masterFilePath.Match(line);
 if (filePath.Success) {
 // found it - exit loop
 masterPath = filePath.Groups["path"].Value;
 break;
 }
 }
 // check to see if we're done reading the page directive (multiline directive)
 if (readingDirective && line.IndexOf("%>") != -1)
 break; // no MasterPageFile attribute found
 }
 }
 return masterPath;
 }
 }
}

I’ve implemented this as an ActionFilterAttribute so you can just apply it to any controller or action. This way you can use it in a more flexible way. The only thing left for you to do is fill in the blanks in the LoadData method to retrieve the data you need based on the name of the MasterPageFile.

Conclusion

We’ve been running this setup for a couple weeks now in development, QA and UA and it’s working like a charm so far. Once you have it setup, you’re free to forget about it until you need to change how your menus function or your data set. Plus now you’re keeping all your interactions with your model inside your controller and your view just needs to pull the data from the ViewDataDictionary.