2

I have the Web API controller with 2 methods - let's say the first method returns the plain project list and the second one returns all projects assigned to the specific user.

public class ProjectController: ApiController
{
    public IQueryable<Project> Get() { ... }

    [HttpGet]
    public IQueryable<Project> ForUser(int userId) { ... }
}

The method implementation is not important in this case.

Web API route config is also adjusted to support the custom method names.

config.Routes.MapHttpRoute(
    "DefaultApi",
    "api/v1/{controller}/{id}",
    new { id = RouteParameter.Optional }
);

config.Routes.MapHttpRoute(
    "DefaultApiWithAction",
    "api/v1/{controller}/{action}");

It works fine, I can access both /api/v1/projects/ and /api/v1/projects/forUser/ endpoints, but seems that the route engine is too smart, so it decides that /api/v1/projects?userId=1 request may match the ForUser(..) method (due to the userId argument name, I guess) and ignores the {action} part of the route.

Is there any way to avoid this behavior and require the action part to be explicitly specified in the URL?

Levi Botelho
  • 24,626
  • 5
  • 61
  • 96
Andrew Khmylov
  • 732
  • 5
  • 18

3 Answers3

3

Couple things. First of all this route:

config.Routes.MapHttpRoute(
    "DefaultApiWithAction",
    "api/v1/{controller}/{action}",
    new { id = RouteParameter.Optional });

Does not have "action" as an optional parameter. You have included id as optional (I assume as a typo), but as it does not exist in the route, you will not get a match with only one supplementary segment. Only URLs containing two parts, a controller and an action, will pass through this route. This url:

/api/v1/projects?userId=1

...contains a single segment and will not. This route, and any other which lacks a second component will default to this route:

config.Routes.MapHttpRoute(
    name: "DefaultApi",
    routeTemplate: "api/v1/{controller}/{id}",
    defaults: new { id = RouteParameter.Optional }
);

...which only takes a controller and an optional ID. You need to either reformat the given URL to take an action parameter, or rewrite your route to make the action optional and set the default as you desire. This will all depend on your application architecture, but always err on the side of simplicity. Routes can get very complicated--simpler is generally better.

As for required/optional route components, keep in mind the following two things:

  • All route segments are required unless they are set as optional in the anonymous object.
  • Segments can also be excluded if they have a default value, set by providing one in the anonymous object in the form of placeholder = value.
Levi Botelho
  • 24,626
  • 5
  • 61
  • 96
  • Unfortunately, seems that you are mistaken, and the one-segment `/api/v1/projects?userId` URL is routed to the `ForUser()` action. I guess it is because `ForUser()` action have the `userId` parameter, which matches the `userId` argument from the URL. (I have also edited the question and removed the misleading parts) – Andrew Khmylov Dec 03 '12 at 11:26
  • 1
    @AndrewKhmylov - I think that maybe I didn't explain myself well enough. The one-segment URL **does** indeed match the ForUser action, but it does this because it passes through the `api/v1/{controller}/{id}` and not `api/v1/{controller}/{action}`. This is because there is no action specified in the URL. It then does what it will, most likely by looking at the ID, to try to find the best match in its opinion. If you want it to go to a specific match you need to either change your routes to add a default action, or add the action to your url, as it does not currently contain one. – Levi Botelho Dec 03 '12 at 11:41
0

I don't understand your problem completely. Shouldn't /api/v1/projects?userId=1 indeed call the ForUser action?

Anyway, to make the action required, make your HttpRoute like this:

   name: "DefaultApi",
   routeTemplate: "api/v1/{controller}/{action}/{id}",
   defaults: new { id = System.Web.Http.RouteParameter.Optional });

Now you can call like this: /api/v1/projects/ForUser/2

user1797792
  • 1,169
  • 10
  • 26
  • The intention is to make the action part explicit, so that URLs are meaningful. Right now, I can substitute anything as the action name and it will still use ForUser action. As to adding "id" parameter - I can agree that it works, but it's not exactly what I've been looking for (since some actions may have more than one argument). Thanks anyway. – Andrew Khmylov Dec 03 '12 at 10:29
  • `/api/v1/projects/ForUser/2` is intepreted the same as `/api/v1/projects/ForUser?userId=2`. To add more parameters, simply do `/api/v1/projects/ForUser?userId=2&userName=andrew` – user1797792 Dec 03 '12 at 10:39
0

I've finally come up with the solution that satisfies my requirements. I've combined this answer and ideas suggested by levib and user1797792 into the following config:

config.Routes.MapHttpRoute(
    "DefaultApiWithActionAndOptionalId",
    "api/v1/{controller}/{action}/{id}",
    new {id = RouteParameter.Optional});

config.Routes.MapHttpRoute(
    "DefaultApiGet",
    "api/v1/{controller}",
    new { action = "Get" },
    new { httpMethod = new HttpMethodConstraint(HttpMethod.Get) });

Note that the config order matters a lot here.

First of all, the /api/v1/projects request with any query string (even with arguments whose names match the other action's parameters) is dispatched to the Get() method via the second route. This is important because in the real project I've got a custom action filter attached to this action that filters the returned IQueryable based on the provided request arguments.

api/v1/projects/forUser/1-like requests are dispatched to ForUser(int id) method by the first route. Renaming userId parameter into id allowed to construct cleaner URLs.

Obviously, this approach has some limitations, but it is all I need in my specific case.

Community
  • 1
  • 1
Andrew Khmylov
  • 732
  • 5
  • 18