0

I'm trying to make an app's REST API more RESTful and it feels like I'm not using the Spring RequestMappings in the way intended.

I have a single GET end point for doing reads:

@RequestMapping(value = "thing/{thingName}",
        method = RequestMethod.GET,
        produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String getThing(
        @PathVariable(value = "thingName", required = false)
                String thingName,
        @RequestParam(value = "findByComponent", required = false)
                String findByComponentQuery,
        @AuthenticationPrincipal User user) {
...

To be more restful, I want this endpoint to do both:

  1. GET /thing/{thingName} returns a single thing having that name
  2. GET /thing or /thing/ with query params returns lists of things

So in my controller, I can test whether {thingName} is null or zero-length and if so, check the query params for known query names.

However calling this with http://localhost:8080/thing/?findByComponent=component123 returns a 404 from Spring with this logging:

12:45:18.485 PageNotFound : No mapping found for HTTP request with URI [/thing/] in DispatcherServlet with name 'dispatcher' : WARN : XNIO-1 task-3 : org.springframework.web.servlet.DispatcherServlet  
Adam
  • 5,215
  • 5
  • 51
  • 90
  • Is there any restriction that does not allow you to break this `@RequestMapping` into two different `@RequestMapping`s? – Justin Albano Jan 07 '19 at 13:19
  • No, if it's possible. That's one thing I didn't try. I assume you mean I should create a 2nd mapping like this `@RequestMapping("thing")`? – Adam Jan 07 '19 at 13:23

1 Answers1

2

Spring does not allow path variables ({thingName}) to be mapped to an empty String. In practice, this means that the URL /thing/?findByComponent=component123 does not map to thing/{thingName} with an empty {thingName}, but rather, it expects there to be some mapping for thing. Since there is no endpoint that maps to the path thing (without the path variable), a 404 error is returned.

To solve this issue, you can break this single endpoint into two separate endpoints:

@RequestMapping(value = "thing/{thingName}",
        method = RequestMethod.GET,
        produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String getThing(
        @PathVariable(value = "thingName") String thingName,
        @AuthenticationPrincipal User user) {
    // ...
}

@RequestMapping(value = "thing",
        method = RequestMethod.GET,
        produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String getThings(,
        @RequestParam(value = "findByComponent", required = false) String findByComponentQuery,
        @AuthenticationPrincipal User user) {
    // ...
}

For more information, see With Spring 3.0, can I make an optional path variable?.

The required=false flag allows for two types of requests:

  1. /thing
  2. /thing/<some_value>

Strictly speaking, including a trailing slash at the end of the URL (i.e. /thing/) means that a decision was made to include a value for the path variable, but none was provided. In the context of REST APIs, /thing and /thing/ are two different endpoints, where the latter means that a value after the trailing slash was expected.

A workaround for not having to create three separate request mappings (one for each case above) is to set the @RequestMapping value for the controller to the base path and then have a "" and "/{thingName} request mapping for the two endpoints:

@RestController
@RequestMapping("thing")
public class ThingController {

    @RequestMapping(value = "/{thingName}",
            method = RequestMethod.GET,
            produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public String getThing(
            @PathVariable(value = "thingName") String thingName) {
        return "foo";
    }

    @RequestMapping(value = "",
            method = RequestMethod.GET,
            produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public String getThings(
            @RequestParam(value = "findByComponent", required = false) String findByComponentQuery) {
        return "bar";
    }
}

In this case, the following mappings will occur:

  1. /thing: getThings
  2. /thing/: getThings
  3. /thing/foo: getThing

An example of this workaround, including test cases can be found here.

Justin Albano
  • 3,809
  • 2
  • 24
  • 51
  • OK, great, but does this mean I need a 3rd endpoint with `@RequestMapping("thing/")`? Spring is giving me `404` when I try `localhost:8080/thing/?findByComponent`. It seems it only likes `localhost:8080/thing?findByComponent`. My clients would complain that I'm being pedantic if I didn't allow that. I also wondered why Spring lets me specify `@PathVariable(required=false)`. – Adam Jan 07 '19 at 13:56
  • @Adam There is a little bit of nuance between having or not having a trailing slash. I've updated the answer above to explain the difference and have included a workaround so that you do not have to create 3 separate endpoints. – Justin Albano Jan 07 '19 at 14:23
  • Hey, good explanation and makes sense - but I don't see a work-around. The only difference I see is the class-level annotation and as is, it doesn't provlde the `/thing/` endpoint - `/thing/?findByComponent=...` still returns `404`. I double-checked my code. – Adam Jan 07 '19 at 15:06
  • @Adam I've included a link in the answer to a test project that exercises all of the endpoints described in the answer. Adding the top-level `@RequestMapping` requires a change to the `@RequestMapping` for each of the endpoints as well. Be sure that the endpoints in your project reflect the `@RequestMapping` values seen in the example project. – Justin Albano Jan 07 '19 at 15:28
  • Thanks for your help. It looks like the way to go, but while I can get it to work with your project, I can't get `/?findByComponent=...` to resolve in my project and I can't find the difference grrrr. – Adam Jan 07 '19 at 17:01