Search code examples
javarestspring-web

Zero length part of URL in Spring controller RequestMapping PathVariable breaks resolution


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  

Solution

  • 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.