9

I currently have 2 areas in my ASP .NET MVC 5 project. One is called Supporters, and one is called Chatter. In each of these two areas, there is an ApiController named CommunicationController, and this poses a problem due to the nature of how an ApiController works with routing.

An example of the problem

If I had just one ApiController named CommunicationController in an area, its routing wouldn't include the area in the URL, and the URL would be something like this:

http://example.com/api/communication/someAction

But in the above URL, where is the area?

Since two of my controllers are named the same, they both have routing issues now.

What have I tried?

I tried following the instructions here: http://blogs.infosupport.com/asp-net-mvc-4-rc-getting-webapi-and-areas-to-play-nicely/

They seem to apply for ASP .NET MVC 4 RC, which is no longer relevant since I am using MVC 5, and that might be why it wasn't working.

However, to recap from that blogpost, here are my routing files.

App_Start\RouteConfig.cs

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute("Default", "{controller}/{action}",
            new {action = "Index", controller = "Home"},
            new[] { "Website.Controllers" }
            );
    }
}

App_Start\WebApiConfig.cs

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.MapHttpAttributeRoutes();

        //the two lines below were added.
        config.Routes.MapHttpRoute("SupportersApi", "api/supporters/{controller}/{id}", new {id = RouteParameter.Optional, area = "Supporters"}
            );
        config.Routes.MapHttpRoute("ChatterApi", "api/chatter/{controller}/{id}", new { id = RouteParameter.Optional, area = "Chatter" }
            );
    }
}

Areas\Chatter\ChatterAreaRegistration.cs

public class ChatterAreaRegistration : AreaRegistration
{
    public override string AreaName
    {
        get { return "Chatter"; }
    }

    public override void RegisterArea(AreaRegistrationContext context)
    {

        //the below line was added.
        context.Routes.MapHttpRoute("Chatter_api", "api/chatter/{controller}/{id}", new { id = RouteParameter.Optional, area = "Chatter" }
            );

        context.MapRoute(
            "Chatter_default",
            "Chatter/{controller}/{action}/{id}",
            new {action = "Index", id = UrlParameter.Optional}
            );
    }
}

Areas\Supporters\SupportersAreaRegistration.cs

public class SupportersAreaRegistration : AreaRegistration 
{
    public override string AreaName 
    {
        get 
        {
            return "Supporters";
        }
    }

    public override void RegisterArea(AreaRegistrationContext context)
    {
        //the below line was added.
        context.Routes.MapHttpRoute("Supporters_api", "api/supporters/{controller}/{id}", new { id = RouteParameter.Optional, area = "Supporters" }
            );

        context.MapRoute(
            "Supporters_default",
            "Supporters/{controller}/{action}/{id}",
            new { action = "Index", controller = "Home", id = UrlParameter.Optional }
        );
    }
}

What am I doing wrong here, and what are my options?

Mathias Lykkegaard Lorenzen
  • 15,031
  • 23
  • 100
  • 187
  • don't forget you can debug your routes with [Glimpse](http://getglimpse.com/). It will give you valuable info as to why your routes aren't working as you expect. – hometoast Aug 18 '14 at 10:54
  • Glimpse didn't help me. It just crashes when I load the page. – Mathias Lykkegaard Lorenzen Aug 18 '14 at 18:56
  • This might not be what you wanted to hear, but I personally would never use Areas. It is just not worth the effort. If i want to use "areas" I'll just setup the folder structure I want and related Views/Web.configs as necessary. I've found too many things just friction with areas whereas using native MVC and working with it is better. One of the best projects I ever led (using your terms) I had `/Modules/Chatter` and `/Modules/Supporters` with Controllers/Views folders nested. Only cost was I needed to do `public ActionResult foo() { return View("~/modules/chatter/foo.cshtml") }` – Chris Marisic Aug 18 '14 at 20:31

5 Answers5

6

Use the WebAPI 2 attributes, since you are using MVC 5, and you can get rid of a lot of that boilerplate code by declaring the routes for your API along with it's implementation (you can also specify verbs for HTTP actions, and even use attributes to auto-convert to XML/JSON/serialization-of-the-month).

Unless you are using areas for some other reason, you really don't need them to implement a Web API.

In particular, what you want is the RoutePrefix attribute.

Clever Neologism
  • 1,322
  • 8
  • 9
  • Actually, this didn't work. I now have the `Route` and `RoutePrefix` attributes set, but I get a 404 now when I try to access the URL. – Mathias Lykkegaard Lorenzen Aug 15 '14 at 22:27
  • 1
    Try this guide http://www.asp.net/web-api/overview/web-api-routing-and-actions/attribute-routing-in-web-api-2 use [RoutePrefix("/api/whatever")] above the CLASS and then [Route("myname")] above the ACTION. use [HttpGet] or [HttpPost] above the action as well. – Victorio Berra Aug 21 '14 at 16:09
  • That's not working either. I already did that, and I still get a 404. [This](http://i.imgur.com/3MCU6eX.png) is my configuration. – Mathias Lykkegaard Lorenzen Aug 23 '14 at 11:15
  • 1
    Don't forget to add config.MapHttpAttributeRoutes(); in the WebApiConfig.cs in App_Start – Alexandre Aug 24 '14 at 03:02
  • @Mathias There could be several problems, and without knowing what all is in your project, I can't really form a good theory as to why you get 404. In the link I sent, they have a guide to upgrade your solution from WebAPI1 to 2. I recommend setting up a barebones example based on the tutorials on MSDN, and then comparing with your code. – Clever Neologism Aug 25 '14 at 19:13
4

If two or more areas has apicontroller with same name, then in order to invoke controller in specific area, the area name must be included in URL.

So http://example.com/api/communication/someAction wont work.

In this case, it can be
http://example.com/supporters/api/communication/someAction and http://example.com/chatters/api/communication/someAction

The custom httpcontrollerselector given in http://blogs.infosupport.com/asp-net-mvc-4-rc-getting-webapi-and-areas-to-play-nicely works fine with mvc5 too.

Remove following lines in webapiconfig

config.Routes.MapHttpRoute("SupportersApi", "api/supporters/{controller}/{id}", new {id = RouteParameter.Optional, area = "Supporters"}
        );        

    config.Routes.MapHttpRoute("ChatterApi", "api/chatter/{controller}/{id}", new { id = RouteParameter.Optional, area = "Chatter" }
        );

Here are the steps, which works fine

1. Add following extension method to the project.

public static class AreaRegistrationContextExtensions
{
public static Route MapHttpRoute(this AreaRegistrationContext context, string name, string routeTemplate)
{
    return context.MapHttpRoute(name, routeTemplate, null, null);
}

public static Route MapHttpRoute(this AreaRegistrationContext context, string name, string routeTemplate, object defaults)
{
    return context.MapHttpRoute(name, routeTemplate, defaults, null);
}

public static Route MapHttpRoute(this AreaRegistrationContext context, string name, string routeTemplate, object defaults, object constraints)
{
    var route = context.Routes.MapHttpRoute(name, routeTemplate, defaults, constraints);
    if (route.DataTokens == null)
    {
        route.DataTokens = new RouteValueDictionary();
    }
    route.DataTokens.Add("area", context.AreaName);
    return route;
}

}

2. In each AreaRegistration file, add route which includes area name in routeTemplate

To SupportAreaRegistration, add

 context.MapHttpRoute(
name: "Supporters_DefaultApi",
routeTemplate: "supporters/api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);

To ChatterAreaRegistration, add

 context.MapHttpRoute(
name: "Chatters_DefaultApi",
routeTemplate: "chatters/api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);

Its context.MapHttpRoute, not context.Routes

3. Add custom HttpControllerSelector

 public class AreaHttpControllerSelector : DefaultHttpControllerSelector
    {
    private const string AreaRouteVariableName = "area";

    private readonly HttpConfiguration _configuration;
    private readonly Lazy<ConcurrentDictionary<string, Type>> _apiControllerTypes;

    public AreaHttpControllerSelector(HttpConfiguration configuration)
        : base(configuration)
    {
        _configuration = configuration;
        _apiControllerTypes = new Lazy<ConcurrentDictionary<string, Type>>(GetControllerTypes);
    }

    public override HttpControllerDescriptor SelectController(HttpRequestMessage request)
    {
        return this.GetApiController(request);
    }

    private static string GetAreaName(HttpRequestMessage request)
    {
        var data = request.GetRouteData();
        if (data.Route.DataTokens == null)
        {
            return null;
        } 
        else 
        {
            object areaName;
            return data.Route.DataTokens.TryGetValue(AreaRouteVariableName, out areaName) ? areaName.ToString() : null;
        }
    }

    private static ConcurrentDictionary<string, Type> GetControllerTypes()
    {
        var assemblies = AppDomain.CurrentDomain.GetAssemblies();

        var types = assemblies
            .SelectMany(a => a
                .GetTypes().Where(t =>
                    !t.IsAbstract &&
                    t.Name.EndsWith(ControllerSuffix, StringComparison.OrdinalIgnoreCase) &&
                    typeof(IHttpController).IsAssignableFrom(t)))
            .ToDictionary(t => t.FullName, t => t);

        return new ConcurrentDictionary<string, Type>(types);
    }

    private HttpControllerDescriptor GetApiController(HttpRequestMessage request)
    {
        var areaName = GetAreaName(request);
        var controllerName = GetControllerName(request);
        var type = GetControllerType(areaName, controllerName);

        return new HttpControllerDescriptor(_configuration, controllerName, type);
    }

    private Type GetControllerType(string areaName, string controllerName)
    {
        var query = _apiControllerTypes.Value.AsEnumerable();

        if (string.IsNullOrEmpty(areaName))
        {
            query = query.WithoutAreaName();
        }
        else
        {
            query = query.ByAreaName(areaName);
        }

        return query
            .ByControllerName(controllerName)
            .Select(x => x.Value)
            .Single();
    }
}

public static class ControllerTypeSpecifications
{
    public static IEnumerable<KeyValuePair<string, Type>> ByAreaName(this IEnumerable<KeyValuePair<string, Type>> query, string areaName)
    {
        var areaNameToFind = string.Format(CultureInfo.InvariantCulture, ".{0}.", areaName);

        return query.Where(x => x.Key.IndexOf(areaNameToFind, StringComparison.OrdinalIgnoreCase) != -1);
    }

    public static IEnumerable<KeyValuePair<string, Type>> WithoutAreaName(this IEnumerable<KeyValuePair<string, Type>> query)
    {
        return query.Where(x => x.Key.IndexOf(".areas.", StringComparison.OrdinalIgnoreCase) == -1);
    }

    public static IEnumerable<KeyValuePair<string, Type>> ByControllerName(this IEnumerable<KeyValuePair<string, Type>> query, string controllerName)
    {
        var controllerNameToFind = string.Format(CultureInfo.InvariantCulture, ".{0}{1}", controllerName, AreaHttpControllerSelector.ControllerSuffix);

        return query.Where(x => x.Key.EndsWith(controllerNameToFind, StringComparison.OrdinalIgnoreCase));
    }
}

4. Make change in Application_Start method in Global.Asax file, in order to use AreaHttpControllerSelector instead of DefaultHttpControllerSelector

GlobalConfiguration.Configuration.Services.Replace(typeof(IHttpControllerSelector), new AreaHttpControllerSelector(GlobalConfiguration.Configuration));
Vikram Babu Nagineni
  • 3,411
  • 6
  • 25
  • 34
  • This seems like a pretty reasonable solution (not that i actually tested the code) but this is exactly why i don't use areas. So much additional lifting for no value gain. – Chris Marisic Aug 18 '14 at 20:35
  • 1
    I tried following these exact instructions, but I still get a 404. – Mathias Lykkegaard Lorenzen Aug 21 '14 at 09:46
  • 1
    in line `var types = assemblies .SelectMany(a => a .GetTypes().Where(t => !t.IsAbstract && t.Name.EndsWith(ControllerSuffix, StringComparison.OrdinalIgnoreCase) && typeof(IHttpController).IsAssignableFrom(t))) .ToDictionary(t => t.FullName, t => t);` I get an exception `{"Unable to load one or more of the requested types. Retrieve the LoaderExceptions property for more information."}` , Any Idea why? – Sobhan Nov 24 '15 at 09:23
3

Try the below configuration. The trick here is to register the namespace to search for the API Controllers when the route matches.

config.Routes.MapHttpRoute(
            name: "chatterApi",
            routeTemplate: "api/chatter/{controller}/{action}",
            defaults: new { action = "", controller = "", namespaces = new string[] { "WebApplication.chatter.api" } }
        );

config.Routes.MapHttpRoute(
            name: "supportersApi",
            routeTemplate: "api/supporters/{controller}/{action}",
            defaults: new { action = "", controller = "", namespaces = new string[] { "WebApplication.supporters.api" } }
        );
cackharot
  • 688
  • 1
  • 6
  • 13
2

Since you are using MVC5 and it comes with WebAPI 2.0, you can use

[RoutePrefix("api/Supporters/Communication")]

to specify the area as @Clever Neologism mentioned. But dont forget to call

config.MapHttpAttributeRoutes();

while configuring the roouting in Global.asax.cs. Also see this answer

Community
  • 1
  • 1
Sanjay Sahani
  • 565
  • 4
  • 14
0

It has error, route. DataTokens is read-only, can't set value

CHEEKATLAPRADEEP
  • 12,191
  • 1
  • 19
  • 42
lmxin
  • 1