Thursday, June 05, 2008

MVC Framework: Capturing the output of a view (part 2)

Yesterday I wrote about a technique for capturing the HTML output by a view so that you could do something else with it, like send it in an email. However there turned out to be serious problems with that technique. Because it used the current HttpResponse instance it set the internal _headersWritten flag of the Response. When I subsequently wanted to execute a redirect I got an error saying:

"System.Web.HttpException: Cannot redirect after HTTP headers have been sent."

I tried to find some way to reset this flag, but there didn't seem to be any way of doing it. However, after digging around the MVC source code and with a bit of help from Reflector I found an entirely different (and better I think) approach. Rather than use the MVCContrib BlockRenderer I simply replace the current HttpContext with a new one that hosts a Response that writes to a StringWriter.

I've wrapped it all up in an extension method on Controller called CaptureActionHtml, so you can write code like this example from Suteki Shop that I use to send an order email confirmation:

[NonAction]
public virtual void EmailOrder(Order order)
{
    string subject = "{0}: your order".With(this.BaseControllerService.ShopName);

    string message = this.CaptureActionHtml(c => (ViewResult)c.Print(order.OrderId));

    var toAddresses = new[] { order.Email, BaseControllerService.EmailAddress };

    // send the message
    emailSender.Send(toAddresses, subject, message);
}

There are also overloads that allow you to call an action on a different controller than the current one, here's an example from a test controller I wrote where the TestController is executing the Item action on the orderController, using an alternate master page, called  "Print", and then writing the HTML output to a log:

public class TestController : Controller
{
    private readonly OrderController orderController;
    private readonly ILogger logger;

    public TestController(OrderController orderController, ILogger logger)
    {
        this.orderController = orderController;
        this.logger = logger;
    }

    public ActionResult Index()
    {
        string html = this.CaptureActionHtml(orderController, "Print", c => (ViewResult)c.Item(8));

        logger.Info(html);

        // do a redirect
        return RedirectToRoute(new {Controller = "Order", Action = "Item", Id = 8});
    }
}

The best way of seeing this all in action is to get the latest code for Suteki Shop, There's a stand-alone Suteki.Common assembly that you can include in your project that provides these extension methods in the Suteki.Common.Extensions namespace.

But here is the code for the extension class if you want to just cut and paste:

using System;
using System.IO;
using System.Web;
using System.Web.Mvc;

namespace Suteki.Common.Extensions
{
    public static class ControllerExtensions
    {
        /// <summary>
        /// Captures the HTML output by a controller action that returns a ViewResult
        /// </summary>
        /// <typeparam name="TController">The type of controller to execute the action on</typeparam>
        /// <param name="controller">The controller</param>
        /// <param name="action">The action to execute</param>
        /// <returns>The HTML output from the view</returns>
        public static string CaptureActionHtml<TController>(
            this TController controller,
            Func<TController, ViewResult> action)
            where TController : Controller
        {
            return controller.CaptureActionHtml(controller, null, action);
        }

        /// <summary>
        /// Captures the HTML output by a controller action that returns a ViewResult
        /// </summary>
        /// <typeparam name="TController">The type of controller to execute the action on</typeparam>
        /// <param name="controller">The controller</param>
        /// <param name="masterPageName">The master page to use for the view</param>
        /// <param name="action">The action to execute</param>
        /// <returns>The HTML output from the view</returns>
        public static string CaptureActionHtml<TController>(
            this TController controller,
            string masterPageName,
            Func<TController, ViewResult> action)
            where TController : Controller
        {
            return controller.CaptureActionHtml(controller, masterPageName, action);
        }

        /// <summary>
        /// Captures the HTML output by a controller action that returns a ViewResult
        /// </summary>
        /// <typeparam name="TController">The type of controller to execute the action on</typeparam>
        /// <param name="controller">The current controller</param>
        /// <param name="targetController">The controller which has the action to execute</param>
        /// <param name="action">The action to execute</param>
        /// <returns>The HTML output from the view</returns>
        public static string CaptureActionHtml<TController>(
            this Controller controller,
            TController targetController, 
            Func<TController, ViewResult> action)
            where TController : Controller
        {
            return controller.CaptureActionHtml(targetController, null, action);
        }

        /// <summary>
        /// Captures the HTML output by a controller action that returns a ViewResult
        /// </summary>
        /// <typeparam name="TController">The type of controller to execute the action on</typeparam>
        /// <param name="controller">The current controller</param>
        /// <param name="targetController">The controller which has the action to execute</param>
        /// <param name="masterPageName">The name of the master page for the view</param>
        /// <param name="action">The action to execute</param>
        /// <returns>The HTML output from the view</returns>
        public static string CaptureActionHtml<TController>(
            this Controller controller,
            TController targetController, 
            string masterPageName,
            Func<TController, ViewResult> action)
            where TController : Controller
        {
            if (controller == null)
            {
                throw new ArgumentNullException("controller");
            }
            if (targetController == null)
            {
                throw new ArgumentNullException("targetController");
            }
            if (action == null)
            {
                throw new ArgumentNullException("action");
            }

            // pass the current controller context to orderController
            var controllerContext = controller.ControllerContext;
            targetController.ControllerContext = controllerContext;

            // replace the current context with a new context that writes to a string writer
            var existingContext = System.Web.HttpContext.Current;
            var writer = new StringWriter();
            var response = new HttpResponse(writer);
            var context = new HttpContext(existingContext.Request, response) {User = existingContext.User};
            System.Web.HttpContext.Current = context;

            // execute the action
            var viewResult = action(targetController);

            // change the master page name
            if (masterPageName != null)
            {
                viewResult.MasterName = masterPageName;
            }

            // we have to set the controller route value to the name of the controller we want to execute
            // because the ViewLocator class uses this to find the correct view
            var oldController = controllerContext.RouteData.Values["controller"];
            controllerContext.RouteData.Values["controller"] = typeof(TController).Name.Replace("Controller", "");

            // execute the result
            viewResult.ExecuteResult(controllerContext);

            // restore the old route data
            controllerContext.RouteData.Values["controller"] = oldController;

            // restore the old context
            System.Web.HttpContext.Current = existingContext;

            return writer.ToString();
        }
    }
}

16 comments:

Anonymous said...

You shouldn't refer to HttpContext.Current. It makes you code untestable.

why not simply use controllerContext.HttpContext.Response.Filter

Here's an example

http://abombss.com/blog/2008/01/17/ms-mvc-blocks-refactored/

Mike Hadlow said...

Hi tgmdbm,

Thanks for your comment.

Yes, my original solution used Response.Filter, but, as I said in the post, that sets the internal _headersWritten flag in the Response. This means you can't do a response redirect for example after rendering out the view.

Unfortunately ViewPage inherits from the core System.Web.UI.Page class which writes to HttpContext.Current so the only way to redirect the output from the page is to replace HttpContext.Current with a new HttpContext instance. This is a problem with the asp.net infrastructure, it was written before Microsoft discovered unit testing and is inherently un-mockable. That's why we have to do these ugly hacks :(

Mike

Anonymous said...

Hey Mike, I'm glad to see others are working on this issue and bumping into the same things I am. I posted about this in the forums trying to figure out a way around the HttpContext problem.

http://forums.asp.net/t/1261306.aspx

Ultimately this really is an issue with the web forms view engine that is used. Other view engines that don't rely upon the old aspx templating engine should be OK.

Since I wanted to package up the actionresult to send email I couldn't rely upon the hack because of it's dependency on that specific view engine.

I hope MS gets a chance to put some effort into the view engine being used with mvc. Or at least provides a way to intercept the response stream.

Mike Hadlow said...

Hi evan,

I read your post on the MVC forum. It looks like we both came up independently with almost identical solutions.

It's a shame there wasn't any further response from Phil Haack on that thread, it would be nice to know that the MVC team is planning to do something about the overly tight coupling between the asp.net infrastructure and the web forms view engine. That's not their fault, it's the nature of the web forms beast. But I whole heartedly agree with your point that the IViewEngine interface should be the only contract between the asp.net runtime and the view.

I love your quote: "HttpContext.Current appears to be getting whored around under the hood of the mvc framework."

Anonymous said...

Hi Mike, I've been using this bad boy lately and needed to improve it for situations when an exception is thrown when invoking the action on the target controller.. Basically I put it into a try..catch..finally, with the finally resetting the HttpContext and the controllerContext back to how it was before entering the CaptureActionHtml method. Wondering if I can commit it to Suteki.. or just post it here?

Mike Hadlow said...

Hi Jake, that's great. Can you send me a subversion patch. If that's a bore just email me the relevant bits of code. My email is on the top left of the blog.

Thanks!

Anonymous said...

For those who wish to look at alternates, I had some problems with this with redirecting, and found my solution at Stack Overflow:

http://stackoverflow.com/questions/483091/render-a-view-as-a-string

Joey said...

I'm getting problems when using this with controller actions that reference Session. It is null, because of the new HttpContext.

Mike Hadlow said...

Hi Joey,

Yes, of course you'll loose the session data. You could manually copy it over to the new context. I would say though, that it's better to avoid using Session state if you possibly can. Try to keep your application stateless and use the database as your state store.

Anonymous said...

Hi,

Great bit of work, however I'm having difficulty with extenension overload #2. Can you help?

string ControllerExtensions.CaptureActionHtml(this Controller, TController targetController, Func<TController, PartialViewResult> action)
targetController: The controller which has the action to execute.

I am trying to pass a reference to a different controller, which sets some ViewData, have this returned and rendered.

This already works if I am rendering the HTML from the same controller (eg, Rendering 'Register' from within the Account Controller), however if I try to execute the Register action from the TestController, no view data is returned in the rendered HTML.


For example, I am calling:
ControllerExtensions.CaptureActionHtml

from within a Controler called "TestController", and I'd like to use the default MVC "AccountController" to execute a Register View, and have the viewData "passwordLength=6" displayed:

[From within TestController.cs]

AccountController accountController = new AccountController();

// populate viewdata "passwordLength=6"
accountController.Register();

// return the partial view
var result1 = PartialView("Register", accountController.ViewData);

// render HTML passing in the account controller
string output1 = Suteki.Common.Extensions.ControllerExtensions.CaptureActionHtml(this,accountController,"", c => result1);


Now altought the ViewData being passed into to RenderPartial contains the value "passwordLength=6", the returned HTML does not display this password length.

However if I perform this from within the AccountController, it does.

What am I doing wrong? I ideally need to be able to create partial views that reside in another controller, and return the HTML with the ViewData / ModelData populating the HTML correctly.

Can you shed any light?

taliesins said...

I think most people are after this:

public static class ControllerExtensions
{
/// <summary>
/// Captures the HTML output by a controller action that returns a ViewResult
/// </summary>
/// <typeparam name="TController"></typeparam>
/// <param name="controller"></param>
/// <param name="viewName">The name of the view to execute</param>
/// <returns>The HTML output from the view</returns>
public static string CaptureView<TController>(this TController controller, string viewName)
where TController : Controller
{
return CaptureView(controller, viewName, string.Empty, null);
}

/// <summary>
/// Captures the HTML output by a controller action that returns a ViewResult
/// </summary>
/// <typeparam name="TController"></typeparam>
/// <param name="controller"></param>
/// <param name="viewName">The name of the view to execute</param>
/// <param name="model">The model to pass to the view</param>
/// <returns>The HTML output from the view</returns>
public static string CaptureView<TController>(this TController controller, string viewName, object model)
where TController : Controller
{
return CaptureView(controller, viewName, string.Empty, model);
}

/// <summary>
/// Captures the HTML output by a controller action that returns a ViewResult
/// </summary>
/// <typeparam name="TController"></typeparam>
/// <param name="controller"></param>
/// <param name="viewName">The name of the view to execute</param>
/// <param name="masterName">The master template to use for the view</param>
/// <param name="model">The model to pass to the view</param>
/// <returns>The HTML output from the view</returns>
public static string CaptureView<TController>(this TController controller, string viewName, string masterName, object model)
where TController : Controller
{
// pass the current controller context to orderController
var controllerContext = controller.ControllerContext;

// replace the current context with a new context that writes to a string writer
var existingContext = System.Web.HttpContext.Current;
var writer = new StringWriter();
var response = new HttpResponse(writer);
var context = new HttpContext(existingContext.Request, response) { User = existingContext.User };
System.Web.HttpContext.Current = context;

// execute the action

if (model != null)
{
controller.ViewData.Model = model;
}

var result = new ViewResult
{
ViewName = viewName,
MasterName = masterName,
ViewData = controller.ViewData,
TempData = controller.TempData
};

// execute the result
result.ExecuteResult(controllerContext);

// restore the old context
System.Web.HttpContext.Current = existingContext;

return writer.ToString();
}
}

Anonymous said...

Hi Mike,

All wonderful - but I have a question re: rendering view's containing Html.BeginForm.

This method writes the form opening/closing tags direct to the response buffer. Reflector shows:

private static MvcForm FormHelper(this HtmlHelper htmlHelper, string formAction, FormMethod method, IDictionary<string, object> htmlAttributes)
{
TagBuilder builder = new TagBuilder("form");
builder.MergeAttributes<string, object>(htmlAttributes);
builder.MergeAttribute("action", formAction);
builder.MergeAttribute("method", HtmlHelper.GetFormMethodString(method), true);
htmlHelper.ViewContext.HttpContext.Response.Write(builder.ToString(TagRenderMode.StartTag));
return new MvcForm(htmlHelper.ViewContext.HttpContext.Response);
}


So how can I use CaptureView, with a View containing the use of the HTML.BeginForm helper, and capture the form opening/closing tags with the rest of the captured HTML?

Or rather, how I change the htmlHelper.ViewContext.HttpContext on the fly, so it gets written to the dummy context in CaptureView?

Thanks for your help,

Will

Mike Hadlow said...

Will,

OK, so the BeginForm helper is directly accessing htmlHelper.ViewContext.HttpContext? That's not nice. I haven't tried to do what you are asking, so I don't have an answer for you unfortunately. I guess you will either have to work out a way of replacing the htmlHelper.ViewContext.HttpContext or use a different view engine.

Anonymous said...

Hi there,

I tried using your ControllerExtensions class and it keeps giving me an error "Constraints are not allowed on non-generic declarations"

Anonymous said...

Thanks for this article. In my controller, I am initializing another controller and using your methods of creating a new response text writer and then passing that to the ViewResult. I then call ExecuteResult with the temporary controller context, I trap the html output then save it as ViewData to be rendered in my main controller, however the output that I wanted to capture gets rendered at the top of my main controller. Any clues on how I can fix this? Thanks.

public ActionResult Index()
{
var existingContext = System.Web.HttpContext.Current;

var writer1 = new StringWriter();
var response1 = new HttpResponse(writer1);

var context1 = new HttpContext(existingContext.Request, response1) { User = existingContext.User };
System.Web.HttpContext.Current = context1;

ClassLibrary1.ModuleControllerContainer objTestController = new ClassLibrary1.ModuleControllerContainer();
objTestController.ControllerContext = new System.Web.Mvc.ControllerContext(this.HttpContext, this.RouteData, this);

ViewResult ViewResult1 = objTestController.DoSpecialRendering();
ViewResult1.ExecuteResult(objTestController.ControllerContext);

string Output = writer1.ToString();

System.Web.HttpContext.Current = existingContext;

ViewData["Output"] = Output;
return View();
}

Thanks,

Ching

Mike Hadlow said...

Hi Ching, everyone,

This post is pretty old now and I really wouldn't recommend following its advice. The default aspx view engine is very deeply baked into the asp.net runtime and that's why it's very hard to use it as a stand-alone templating engine... as the nasty hackery of this post shows.

These days I would recommend staying away from the default view engine if you want to do this kind of thing. There are several alternatives, I would look at:

Spark View Engine:
http://sparkviewengine.com/

Microsoft's new Razor View Engine:
http://weblogs.asp.net/scottgu/archive/2010/07/02/introducing-razor.aspx

Both of these are fully decoupled from the asp.net runtime so it's simply a case of passing them a template and some data and then grabbing their output.