3

Context

I am trying to build an ASP.NET Core web API controller that will expose the following methods with specific semantics:

/api/experimental/cars — get entire collection

[HttpGet("/api/experimental/cars/")]
public Task<List<Car>> AllCars()

/api/experimental/cars/123 — get car by id "123"

[HttpGet("/api/experimental/cars/{carId}")]
public Task<Car> CarById([FromRoute] string carId)

/api/experimental/cars?nameFilter=Maz — get cars that match nameFilter = "Maz"

[HttpGet("/api/experimental/cars/{nameFilter?}")]
public Task<List<Car>> CarsByNameFilter([FromQuery] string nameFilter = "")

/api/experimental/cars?nameFilter=Maz&rating=2 — get cars that match nameFilter = "Maz" and with rating greater or equal to 2

[HttpGet("/api/experimental/cars/{nameFilter?}/{rating?}")]
public Task<List<Car>> CarsByNameAndRatingFilter([FromQuery] string nameFilter = "", [FromQuery] int rating = 1)

Note: I really want to keep the controller class clean and have a single method per Web API route — is it possible?

Problem

As you could guess, there's an issue with these API definitions. Basically, AllCars is intercepting pretty much all the requests. (When I was able to get at least the /api/experimental/cars/{carId} working, the query-string based APIs were still not working and intercepted by another method...

I tried many possible route syntaxes to express what I want with no luck. Is it even possible to use the default routing mechanism or I need to implement my own Router class or Middleware or something else?

Update 1: Problem definition

I know I can join at least three methods and their routes into a single WebAPI method that is being smart about the received parameters. Notice that this is exactly what I am trying to avoid.

Why?

Reason 1: I saw that in non-.NET routers, it worked well and there's no technical impossibility to implement semantic based route resolution.

Reason 2: I perceive all four URL patterns mentioned above as four different routes. One may not agree with me and it's okay, but for my purposes the methods and the routes are different and have to stay different.

Reason 3.1: This keeps controller code clean. Every method only handles one specific case. Parameter names are sufficient to properly resolve the routes (at least in humans head, therefore machine can do it too -- it's easy to formalize the algorithm). If client make a request with an unsupported query parameter, it should result in HTTP 404 Not Found or HTTP 400 Bad Request -- totally fine (client rather construct correct URLs).

Reason 3.2: On contrary, if I join the methods and use a more generic route, my implementation needs to be 'smart' about the combination of parameters. This is effectively, a leak of routing abstractions into a layer where it does not belong in my architecture. Complex validation is another thing I don't want to see in the Controller -- less code is better.

Update 2: Nancy — Another .NET example (other that .NET Core WebApi)

There is Nancy (a .NET framework) which perfectly deals with this aspect of routing: https://github.com/NancyFx/Nancy/wiki/Defining-routes#pattern The issue is that in my project we're not using it... Nancy works as a perfect example of a tool that leaves exact definition of routing semantics to the client, instead of enforcing too tight rules on what is the route vs what is not.

6
  • The optional routes parameters are the cause of your problems Commented Jan 16, 2017 at 22:29
  • Is the car id alpha numeric or numeric only? Commented Jan 16, 2017 at 22:42
  • @nkosi I understand where the problem comes from. :) The car Id is string, not a number. Commented Jan 16, 2017 at 22:48
  • Does it follow a pattern where you can apply a constraint to uniquely identify it when routing. Commented Jan 16, 2017 at 22:55
  • If you are talking about /api/experimental/cars/123, then yes. This one locates a car with id "123" (which may or may not exist). Commented Jan 16, 2017 at 22:58

3 Answers 3

2

You could Achieve this with just two routes:

[HttpGet("/api/experimental/cars/")]
public Task<List<Car>> SearchCars([FromQuery] string nameFilter = "", [FromQuery] int rating = 1)

and

[HttpGet("/api/experimental/cars/{carId}")]
public Task<Car> CarById([FromRoute] string carId)

I.e one route which brings the entire set back but can be filtered accordingly and one the brings back a single Car object by Id.

You will notice that the SearchCars method doesn't include the parameters in the route, FromQuery will catch these anyway.

EDIT: if your request becomes complex it can be nice to define a custom request object type to wrap all your filters together:

public class MyRequestObject
{
    public string NameFilter {get;set;}
    public int Rating {get;set;}
}

then:

   [HttpGet("/api/experimental/cars/")]
   public Task<List<Car>> SearchCars([FromQuery] MyRequestObject requestParams)
Sign up to request clarification or add additional context in comments.

19 Comments

I understand I could use two different routes instead of four, but that will make the implementation heavier. I really don't want that -- I want the API implementation to be thin and to the point. My question explicitly restricts the solution to four independent routes and methods. Do you know how to achieve that?
I think it is important to make the distinction between route values a query parameters here. With the four routes you define there is ambiguity. The routes: ...api/cars and ...api/cars?nameFilter=something are the same route "api/cars" to have a different route the url would look like: api/cars/someFilter. this is a new route because "/" denotes a new parameter - but I'm assuming you wanted the url more readable, which is why I suggested what I did - it fits your requirements for having the right Urls and I believe it is the cleanest, if not the only way.
Further... consider also the the two routes you tried with optional parameters. if these are not provided then there is a problem with having an ambiguous routes. i.e. /api/experimental/cars/{nameFilter?} without the parameter provided is /api/experimental/cars/ which you have already defined
@IgorSoloydenko You are trying to apply a general concept on a very .Net specific implementation. what you want will not work with the present iteration of the framework without making changes to how to uniquely differentiate the routes. From a framework perspective your route templates will conflict with each other because the are the same.
@IgorSoloydenko what you want can happen with some changes to your route templates but it looks like you a dead set on a specific pattern. Cannot fault you for that if those are your requirements. That is why I was inquiring about the possibility of applying route constraints to the templates .
|
2

My experience with this topic tells me that the best way to implement the APIs I wanted is to have two methods:

class CarsController {

    // [HttpGet("/api/experimental/cars/")]
    [HttpGet("/api/experimental/cars/{carId}")]
    public Task<IEnumerable<Car>> CarById([FromRoute] string carId)
    {
        if (carId == null)
            return GetAllCars();
        else
            return GetCarWithId(carId);
    }

    // [HttpGet("/api/experimental/cars/{nameFilter?}")]
    [HttpGet("/api/experimental/cars/{nameFilter?}/{rating?}")]
    public Task<IEnumerable<Car>> CarsByNameAndRatingFilter([FromQuery] string nameFilter = "", [FromQuery] int rating = 1)
    {
        // TODO Validate the combination of query string parameters for your specific API/business rules.
        var filter = new Filter {
            NameFilter = nameFilter,
            Rating = rating
        };
        return GetCarsMatchingFilter(filter);
    }

}

The first API is almost trivial. Even though returning a single item within a wrapping collection object may not look nice, it minimizes the number of API methods (which I personally am fine with).

The second API is trickier: in a way, it works as the façade pattern. I.e. that API will respond to pretty much all the possible /api/experimental/cars? based routes. Therefore, we need to very carefully validate the combination of received arguments before doing the actual work.

Comments

0

Take a look at the following suggested routes that when tested do not conflict with each other and still allow for all the actions to be segregated.

[Route("api/experimental/cars")]
public class CarsController : Controller {

    //GET api/experimental/cars
    [HttpGet("")]
    public IActionResult AllCars() { ... }

    //GET api/experimental/cars/123
    [HttpGet("{carId}")]
    public IActionResult CarById(string carId) { ... }

    //GET api/experimental/cars/named/Maz 
    //GET api/experimental/cars/named?filter=Maz 
    [HttpGet("named/{filter?}")]
    public IActionResult CarsByNameFilter(string filter = "") { ... }

    //GET api/experimental/cars/filtered?rating=2&name=Maz 
    //GET api/experimental/cars/filtered?rating=2 
    //GET api/experimental/cars/filtered?name=Maz 
    [HttpGet("filtered")]
    public IActionResult CarsByNameAndRatingFilter(string name = "", int rating = 1) { ... }

}

3 Comments

Thanks for providing this variant. I know it's a working solution, but the syntax for cars/named?filer=... and the one for /cars/filtered is a thing I am trying to avoid. Query parameters directly applied to the resource name (cars) would look way more natural and expressive. I read cars/named?filter=... as an address to a named collection of objects. The issue is that there is no such resource in fact. There is a resource cars but not cars/named. The latter is a virtual entity which is not truly a part of the business domain.
As I understand now, my problem definition does not have an .NET WebAPI based solution that would satisfy every single requirement point. As a result, our team will probably stick to the approach you described even though I find it way less expressive compared to the desired one. Please update your answer with an explicit note that this can not be achieved in the requested form. I will wait for a couple of days and mark your answer as an accepted. Thank you!
@IgorSoloydenko has this been resolved. Following up.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.