vkuzel.com

Request mapping on demand in Spring MVC

2016-05-03

Motivation

I want to generate request mappings during execution time of my application. I want to have several URL paths that points to a single controller method. Paths will be generated from e-shop product names. Each path should be bound to a language code send in a header. Of course paths can vary as user changes product names during an application's excution.

Here is an example list of mappings. Mapping for English language will match only if lang-header is set to en code. Same rule should apply for Czech lang-header.

[header: Accept-Language=en*]
/spinach
/carrot
/broccoli

[header: Accept-Language=cs*]
/spenat
/mrkev
/brokolice

These prerequisites disqualifies @RequestMapping annotation because:

  • Request mapping conditions are evaluated on application's init phase and cannot be changed on runtime.

  • It's not possible to attach a mapping path to a specific header value. There is many to many relation between paths and headers defined as a request mapping condition.

      @RequestMapping(value = {"/spinach", "/carrot", "/spenat", "/mrkev"}, headers = {"Accept-Language=en*", "Accept-Language=cs*"})
    

Theory of mapping a request to a controller method in Spring MVC

Following figure shows request's lifecycle in a Spring application.

Spring request mapping diagram

  1. The first place where the request is processed by an application is DispatcherServlet. There is only one servlet of this kind which processes all incoming requests.

  2. Then DispatcherServlet in its doDispatch method tries to find handler capable of processing request. DispatcherServlet holds a list of HandlerMappings built on application start.

    These mappings tells the servlet which particular mapping is suitable for processing request. RequestMappingHandlerMapping is interesting in our case because it maps a controller method according to @RequestMapping annotation.

    If request matches conditions specified by @RequestMapping annotation then a request handler will be send back to the servlet. The handler is instance of HandlerMethod wrapped in HandlerExecutionChain object and actually it's kind of pointer to the controller method.

  3. Handler (HandlerMethod) is passed to the HandlerAdapter's specialization RequestMappingHandlerAdapter and then executed.

    Actually RequestMappingHandlerAdapter can be easily renamed as HandlerMethodHandlerAdapter because this adapter doesn't have almost no relation to @RequestMapping annotation. This will be useful in one of my solutions.

  4. The handler is executed in RequestMappingHandlerAdapter.invokeHandlerMethod method. Result is wrapped into ModelAndView object and then send back to the servlet.

  5. The servlet resolves a view, processes it, and so on.

Solution: Custom request condition

Spring allows to extend @RequestMapping with a custom condition. By overwriting RequestMappingHandlerMapping.getCustomMethodCondition or RequestMappingHandlerMapping.getCustomTypeCondition methods you can create your own condition that will (or will not) match requests. If request matches mapping's condition and your custom conditions then handler is returned by the handler mapping.

Custom conditions are created during application's runtime so it can also be changed on application's runtime.

Here is a small demonstration of this approach. Of course in real-world application WebMvcRegistrations and controller shouldn't be combined.

@Controller
public class VegetableController extends WebMvcRegistrationsAdapter {

    private VegetableService vegetableService;
    
    @GetMapping("/{name}")
    public String vegetable(@PathVariable("name") String normalizedTitle, Model model) {
        // TODO Do something...
    }

    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new VegetableRequestMappingHandlerMapping();
    }

    public class VegetableRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

        private VegetableRequestCondition condition = null;

        @Override
        protected RequestCondition<?> getCustomMethodCondition(Method method) {
            if (method.getDeclaringClass() == VegetableController.class && "vegetable".equals(method.getName())) {
                if (condition == null) {
                    condition = new VegetableRequestCondition();
                }
                return condition;
            }

            return null;
        }
    }

    public class VegetableRequestCondition implements RequestCondition<VegetableRequestCondition> {

        @Override
        public VegetableRequestCondition getMatchingCondition(HttpServletRequest request) {
            String lang = request.getHeader("Accept-Language");
            String vegetableName = request.getServletPath();
            if (vegetableService.exists(lang, vegetableName)) {
                return this;
            }

            return null;
        }

        @Override
        public VegetableRequestCondition combine(VegetableRequestCondition other) {
            throw new UnsupportedOperationException("getMatchingCondition should not return multiple conditions so there is no need for a combination!");
        }

        @Override
        public int compareTo(VegetableRequestCondition other, HttpServletRequest request) {
            throw new UnsupportedOperationException("getMatchingCondition should not return multiple conditions for a request " + request.getPathInfo() + " so there is no need for a comparison");
        }
    }
}