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();
}
}
}