Feel like a geek and get yourself Ema Personal Wiki for Android and Windows

23 August 2009

Chaining ASP.NET MVC actions

Action Chaining is using the output of action A as input for action B. Applying this pattern to ASP.NET MVC projects is not trivial. This post is meant as a quick start.

Suppose you have the following scenario
  • client places order in browser

  • system redirects client to ~/Order/ThankYou

  • system sends email to client with summary about order

  • system shows summary of order to client
To accomplish this, it would be convenient to reuse ~/Order/Summary for both the email and the browser page


There are other usecases in which Action Chaining would be a valid pattern, for example in cases where you want to show the output of one action as PDF or as HTML depending on the required format. Apache Cocoon is based on this pattern.

The way to execute an action and capture the output in ASP.NET MVC involves some hijacking of the Response Stream and the RouteValues.

The Response Stream can messed with using a ResponseFilter. A response filter is a class that inherits from stream and takes a stream as constructor argument. The stream in the constructor is the original Response stream (or another filter, they can be chained). If you set the filter, the filter stream gets the writes and is supposed to pass the writes to the wrapped stream. Which is exactly what we won't do: we jealously keep the bytes to ourselves in a memorystream:
class BufferingMemoryStreamFilter : MemoryStream
{
public BufferingMemoryStreamFilter(Stream wrappedStream)
{
// ignore the wrapped stream
}
}
Controllers react on the routevalues to find the right action and view. Because we will be executing another action (perhaps on another controller also), the routevalues should be changed for the occasion and restored afterwards.

The method that execute and capture an arbitrary action on an arbitrary controller is posted below.
string GetActionOutput(string controller, string action)
{
// hijack the response stream
var orgResponseFilter = HttpContext.Response.Filter;
var memoryStreamFilter = new BufferingMemoryStreamFilter(
HttpContext.Response.Filter);
HttpContext.Response.Filter = memoryStreamFilter;

// hijack routeData
var routeData = ControllerContext.RequestContext.RouteData;
var orgAction = routeData.Values["action"];
var orgController = routeData.Values["controller"];
routeData.Values["action"] = action;
routeData.Values["controller"] = controller;

var c = ControllerBuilder.Current
.GetControllerFactory()
.CreateController(ControllerContext.RequestContext, controller);
c.Execute(ControllerContext.RequestContext);

HttpContext.Response.Flush();
memoryStreamFilter.Position = 0;
string result;
using (var r = new StreamReader(memoryStreamFilter))
result = r.ReadToEnd();

// restore
HttpContext.Response.Filter = orgResponseFilter;
routeData.Values["action"] = orgAction;
routeData.Values["controller"] = orgController;

return result;
}
Include this in a (base) controller or in a class that has access to the controller to be able to use it.

The code in this post is licensed under the Apache 2.0 license, which in practice means you have permission to use it.

6 comments:

Marc Chouteau said...

Great article, thank you

wbhm said...

Interesting approach. When trying to implement, I'm getting and exception thrown wen trying to execute the 'redirected' controller action. Here's the code:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.IO;
using System.Text;

namespace ResponseCaptureDemo.Controllers
{
[HandleError]
public class HomeController : Controller
{
[AcceptVerbs(HttpVerbs.Get)]
public ActionResult Index()
{
string output = 'some html here'

return Content(output, "text/html");
}

//[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Redirected()
{
StringBuilder output = new StringBuilder(GetActionOutput("HomeController", "SampleAction"));
output.Append("some additional text here");
return Content(output.ToString(), "text/html");
}

public ActionResult SampleAction()
{
string output = 'some other html here';
return Content(output, "text/html");
}

private string GetActionOutput(string controller, string action)
{

//grab original response stream
var origResponseFilter = HttpContext.Response.Filter;
//replace original filter with empty filter
var memoryStreamFilter = new ResponseCaptureDemo.Utilities.BufferedMemoryStreamFilter(HttpContext.Response.Filter);
HttpContext.Response.Filter = memoryStreamFilter;

//grab original route data
var routeData = ControllerContext.RequestContext.RouteData;
var origAction = routeData.Values["action"];
var origController = routeData.Values["controller"];

//re-route to desired controller and action
routeData.Values["action"] = action;
routeData.Values["controller"] = controller;

//create desired controller and execute
var c = ControllerBuilder.Current.GetControllerFactory().CreateController(ControllerContext.RequestContext, controller); <-- EXCEPTION HERE!
c.Execute(ControllerContext.RequestContext);

HttpContext.Response.Flush();
memoryStreamFilter.Position = 0;
string result;
using (var r = new StreamReader(memoryStreamFilter))
{
result = r.ReadToEnd();
}

//restore
HttpContext.Response.Filter = origResponseFilter;
routeData.Values["action"] = origAction;
routeData.Values["controller"] = origController;

return result;

}
}
}

EXCEPTION - The controller for path '/Home/Redirected' could not be found or it does not implement IController.

Any thoughts?

Jan Willem said...

Sorry I missed your comment until now. Maybe an obvious one, but does the Url Home/Redirected give the expected results if called manually?

Anonymous said...

The above code works fine. Just remove the Controller keyword from this line
StringBuilder(GetActionOutput("HomeController", "SampleAction"));

It should be StringBuilder(GetActionOutput("Home", "SampleAction"));

Anonymous said...

Unfortunately HttpContext.Response.Flush(); flushes the headers, which means that we no longer control that part of response :(

chandrakant prajapati said...

Thank u...