Productive Rage

Dan's techie ramblings

Handing off to another ASP.net MVC Controller Action without redirecting

Just a quick one today, more as a reminder to myself in the future than anything. I've had reason before now to have an Action within the current Controller to give up and pass control off to another Action in another Controller. In this particular case, I had a "DefaultController" that handled 95% of page requests and returned an appropriate 404 page where required - if the current configuration included custom 404 content then it would show that, otherwise rendering some default markup.

I then had a "DownloadController" that handled delivery of "protected downloads" (only allow the download if the user is logged in; redirecting to the login page and back if not). I wanted this second Controller to be able to reuse the 404-handling from the first.

Now, one argument might be that the 404-handling logic should be extracted out so that both Controllers could use it. Which is a fair comment, but I'm only using this real-world setup as an example - so let's not worry about picking apart my approach at this point.

The first thought that came to mind was - quite naively - to do the following within the DownloadController's processing function:

var defaultController = new DefaultController();
return defaultController.Return404();

But this wouldn't work because the Return404 Action requires a Server reference so that it can call MapPath to look for custom a "404.html" file in the site root.

So I next tried to write

var defaultController = new DefaultController();
defaultController.Server = Server;
return defaultController.Return404();

But, unfortunately, the Server property of a Controller is read-only.

So then I turned to Google (which is practically the same thing as saying "turned to Stack Overflow", in cases like these).

Searching for "asp.net mvc return result from another controller" returned a lot of matches where many of the results indicated that I should call "RedirectToAction" - eg.

return RedirectToAction("Force404", "Default");

This didn't really sound like what I wanted because I didn't want to redirect, I just wanted to return the result from "Force404" as the result of the current Action. But I tried it anyway..

And it didn't work! It failed with runtime error:

No route in the route table matches the supplied values.

It turns out that you can't use RedirectToAction unless you have a route configured to handle it - eg.

routes.MapRoute(
    "404-just-in-case",
    "should-never-get-here-through-a-real-url",
    new { controller = "Default", action = "Force404" }
);

Even if I don't want any URL to match to this Controller / Action, I still need a route to be specified. Although the "RedirectToAction" function takes arguments for "actionName" and "controllerName", just specifying these is not enough - that route must exist. And the order in which the routes are specified is still important here - if there is a catch-all route before this route is specified then this route will not be hit. Maybe there's a good reason for all this, but I really can't see it. If there's a function called "RedirectToAction" and I give it an unambigous name of a Controller and an Action, then I expect it to redirect.. to.. that.. action.

Transferring control the correct way

Eventually I read enough answers that I came to the correct information (after reading a lot more unhelpful suggestions about "RedirectToAction"). The credit goes to this answer: stackoverflow.com/a/16453648, but I'll take its information to complete my original example -

var defaultController = new DefaultController();
defaultController.ControllerContext = new ControllerContext(
    this.ControllerContext.RequestContext,
    defaultController
);
return defaultController.Return404();

And that's it!

I must admit that I don't do much that's complicated with MVC and so, to those more experienced with the framework, it might be -

  1. Obvious that this is the correct way to do it
  2. Obvious that, actually, it's not the correct way to do it because it's new'ing up a DefaultController and you're not making use of a Controller factory or other IOC integration
  3. Obvious why RedirectToAction is as it is

But maybe, perhaps, possibly.. this will be of use to someone in the future in my boat who wanted to borrow one Controller's Action to finish off another Controller's Action part-way through.

.. though the more I think about it, the more I think I should have just extracted that 404-handling logic into a shared location and called it from any Controller that required it!

Posted at 22:09

Comments

STA ApartmentState with ASP.Net MVC

Continuing on with the proof-of-concept I'm doing at work regarding reimplementing a VBScript Engine with WSC Controls in .Net I've been trying to develop an ASP.Net MVC Controller that will execute in the STA ApartmentState having read this article:

http://msdn.microsoft.com/en-us/magazine/cc163544.aspx.

The upshot is that if components that run in STA are shared by something executing as MTA then only a single thread from the MTA worker can operate on the component at a time. If the caller is running as STA then separate instances will exist such that each request (I'm thinking in terms of ASP.Net MVC requests) gets its own instance, preventing requests getting queued up waiting for each other when accessing the STA components.

ASP.Net WebForms Pages support an "ASPCompat" attibute which will create the request as STA, rather than MTA. The article I linked above demonstrates how to do similar for an asmx web service. And the forum answer here claims to describe how to do the same for ASP.Net MVC: http://forums.asp.net/t/1302406.aspx.

However..

I'm not sure what version of MVC that was for, and if things have changed since then (it's marked August 2008), but when I tried to use it it didn't compile :(

So here's the version I'm using with the MVC 3 / .Net 4.0 project I've got on the go - we need an IRouteHandler implementation which makes use of an STA-inducing Handler. Thus:

public class STAThreadRouteHandler : IRouteHandler
{
    public IHttpHandler GetHttpHandler(RequestContext requestContext)
    {
        if (requestContext == null)
            throw new ArgumentNullException("requestContext");

        return new STAThreadHttpAsyncHandler(requestContext);
    }
}

public class STAThreadHttpAsyncHandler : Page, IHttpAsyncHandler, IRequiresSessionState
{
    private RequestContext _requestContext;
    public STAThreadHttpAsyncHandler(RequestContext requestContext)
    {
        if (requestContext == null)
            throw new ArgumentNullException("requestContext");

        _requestContext = requestContext;
    }

    public IAsyncResult BeginProcessRequest(HttpContext context, AsyncCallback cb, object extraData)
    {
        return this.AspCompatBeginProcessRequest(context, cb, extraData);
    }

    protected override void OnInit(EventArgs e)
    {
        var controllerName = _requestContext.RouteData.GetRequiredString("controller");
        var controllerFactory = ControllerBuilder.Current.GetControllerFactory();
        var controller = controllerFactory.CreateController(_requestContext, controllerName);
        if (controller == null)
            throw new InvalidOperationException("Could not find controller: " + controllerName);
        try
        {
            controller.Execute(_requestContext);
        }
        finally
        {
            controllerFactory.ReleaseController(controller);
        }
        this.Context.ApplicationInstance.CompleteRequest();
    }

    public void EndProcessRequest(IAsyncResult result)
    {
        this.AspCompatEndProcessRequest(result);
    }

    public override void ProcessRequest(HttpContext httpContext)
    {
        throw new NotSupportedException(
            "STAThreadRouteHandler does not support ProcessRequest called (only BeginProcessRequest)"
        );
    }
}

Then in the routes defined in Global.asx.cs we need something along the lines of:

RouteTable.Routes.Add(new Route(
    "{*url}",
    new RouteValueDictionary(new { controller = "Default", action = "PageRequest" }),
    new STAThreadRouteHandler()
));

in place of

routes.MapRoute(
    "Default",
    "{*url}",
    new { controller = "Default", action = "PageRequest" }
);

This post has been quite derivative of other works but it took me a fair amount of researching to get to this point! Maybe this will benefit someone else going down a similar windy path..

IRequiresSessionState

Of particular note (and absent from the referenced forum answer) is the IRequiresSessionState implemented by STAThreadRouteHandler. This interface has no methods or properties but identifies the Handler as being one that requires that Session State be passed to it.. er, as the name implies! But without this, the Session property of the specified Controller will always be null. This took me quite a while to track down since - unless you know of this particular interface - it's fairly difficult information to track down! Or maybe I was just having a bad Google day.. :)

Posted at 23:46

Comments