Michael HönnigMichael Hönnig

Recently I ran across a missing method="post" in a <form ...> tag within a Thymeleaf template which was working despite an @PostMapping annotation of the SpringFramework on the controller method. I was wondering how this could happen and did some investigation. Hopefully this article gives a better chance for the next one who runs into this problem.

The method looked like this:

    @PostMapping
    @RequestMapping("something")
    public String saveOrUpdate(@ModelAttribute SomethingCommand command){
        SomethingCommand savedCommand = [...];     
        return "redirect:/something/" + savedCommand.getId() + "/show";
    }

By the way, even after adding the method="post" to the form, the method was still called.

Finding the Cause

First I verified that there there no filter configured which adds this attribute, but the HTML source in the browser did not contain this attribute either. Then I checked the variables in the call stack to the controller method in a debugger and I could clearly see, that it was really called with a GET request. Next, I had a look at the JavaDocs, but I could not find anything relevant. Of cause I also did a web search and still no result.

@PostMapping is actually a shortcut for @RequestMapping( method = RequestMethod.POST, ...), but which was also annotated at the same method and thus caused confusion.

How it Actually Works

Then I tried various annotations on the saveOrUpdate() method and found the results shown in the comments:


    // becomes effectively a mapping for "something" with any submit method,
    // thus a @RequestMapping after a @PostMapping gets precedence over the @PostMapping
    @PostMapping
    @RequestMapping("something")
    public String saveOrUpdate(@ModelAttribute SomethingCommand command){
        long id = callSomethingService(command);
        return "redirect:/something/" + id + "/show";
    }

    // becomes effectively a mapping for "somethingReverse" with any submit method,
    // thus, @PostMapping after a @RequestMapping has no effect.
    // If same path is used as in the previous method, Spring notices the double mapping and complains:
    // "java.lang.IllegalStateException: Ambiguous mapping."
    @RequestMapping("somethingReverse")
    @PostMapping("otherthingReverse")
    public String saveOrUpdateReverse(@ModelAttribute SomethingCommand command){
        long id = callSomethingService(command);
        return "redirect:/something/" + id + "/show";
    }

    // maps only POST requests, is not called for other submit methods,
    // just to demonstrate that the submit method really makes a difference
    @PostMapping("something")
    public String saveOrUpdateByPost(@ModelAttribute SomethingCommand command){
        long id = callSomethingService(command);
        return "redirect:/something/" + id + "/show";
    }

    // maps only GET requests, is not called for other submit methods,
    // just to demonstrate that the submit method really makes a difference
    @GetMapping("something")
    public String saveOrUpdateByGet(@ModelAttribute SomethingCommand command){
        long id = callSomethingService(command);
        return "redirect:/something/" + id + "/show";
    }

For the investigation I used this JUnit test utilizing Mockito and MockMvc:


[...]
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

public class SomethingControllerTest {

    private SomethingController controller;
    private MockMvc mockMvc;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        controller = new SomethingController();
        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
    }

    @Test
    public void testPostRequest() throws Exception {
        SomethingCommand command = new SomethingCommand();

        mockMvc.perform(post("/something") // vary post/get and path here
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .param("id", "")
                .param("title", "some title")
        )
        .andExpect(status().is3xxRedirection())
        // vary the fake IDs to find out which method was actually called:
        .andExpect(view().name("redirect:/something/4711/show"));
    }

}

Conclusion

If @RequestMapping is used along with any of it's shortcuts like @PostRequest etc. at the same controller method then @RequestMapping gets preference and the specialized annotations are silently ignored.

If multiple mappings on different methods exist, which are effectively the same, then Spring complains at startup.