Search code examples
spring-mvctomcatspring-bootspring-restcontroller

Spring Boot 2.0.0.M4 and email address as @PathVariable gives HTTP 500 error


I'm trying to migrate from Spring Boot 1.5.7 to 2.0.0.M4

This is my rest controller:

@RestController
@RequestMapping("/v1.0/users")
public class UsersController {

    @Autowired
    private UserService userService;


    @RequestMapping(value = "/validate-username/{username}", method = RequestMethod.GET)
    @ResponseStatus(value = HttpStatus.OK)
    public void validateUsername(@PathVariable String username) {
        throw new EntityAlreadyExistsException();
    }

...

}

This is exception handler:

@ControllerAdvice
public class GlobalControllerExceptionHandler {

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(HttpStatus.CONFLICT)
    public Map<String, ResponseError> handleEntityAlreadyExistsException(EntityAlreadyExistsException e, HttpServletRequest request, HttpServletResponse response) throws IOException {
        logger.debug("API error", e);
        return createResponseError(HttpStatus.CONFLICT.value(), e.getMessage());
    }

}

in case of the following username, for example : alex everything is working fine and I'm receiving 409 status code with application/json; charset=UTF-8 as a content type but in case of the following user name, for example, alex@test.com my endpoint returns 500 status code and non-JSON content type, something like this:

enter image description here

I can reproduce this issue when username PathVariable contains .com at the end.

I use embedded Tomcat as tbe application server. Woth Spring Boot 1.5.7 the same functionality was working fine. How to make it working with Spring Boot 2.0.0.M4 ?

P.S.

I know that sending email addresses as URL parameter is a bad practice. I'm just interested in this particular case.


Solution

  • The issue you are observing goes deep into the Spring WebMvc internals.

    The root cause is that Spring is speculating about the accepted response type. In detail, the strategy class that is actually providing the answer for the accepted response type in case of alex@test.com is ServletPathExtensionContentNegotiationStrategy, which makes a guess based on what is finds in the path.

    Due to the fact that com is a valid file extension type (see this), Spring Boot 2.0.0.M4 tries to use that mime type to convert your response from your ControllerAdvice class to that mime type (and of course fails) and therefore falling back to it's default erroneous response.

    A first though to getting around this issue would be to you specify the HTTP header Accept with a value of application/json.

    Unfortunately Spring 2.0.0.M4 will still not use this mime type because the ServletPathExtensionContentNegotiationStrategy strategy takes precedence over HeaderContentNegotiationStrategy.

    Moreover alex is used or (even something like alex@test.gr for that matter), no mime type is being guessed by Spring, therefore allowing for the regular flow to proceed.

    The reason that this works is Spring Boot 1.5.7.RELEASE is that Spring is not attempting to map com to a mime type, and therefore a default response type is used which allows the process of converting the response object to JSON to continue.

    The difference between the two version boils down to this and this.

    Now comes the even more interesting part, which is of course the fix. I have two solutions in mind, but I will only show the first one and just mention the second.

    Here is the first solution which build upon my explanation of the problem. I admit that this solution does seem a bit intrusive, but it works a like a charm.

    What we need to do is alter the auto-configured ContentNegotiationManager in order to replace the supplied PathExtensionContentNegotiationStrategy with our own custom one. Such an operation can easily be performed by a BeanPostProcessor.

    import org.springframework.beans.BeansException;
    import org.springframework.beans.factory.config.BeanPostProcessor;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.http.MediaType;
    import org.springframework.web.accept.ContentNegotiationManager;
    import org.springframework.web.accept.ContentNegotiationStrategy;
    import org.springframework.web.accept.PathExtensionContentNegotiationStrategy;
    import org.springframework.web.context.request.NativeWebRequest;
    
    import java.util.ListIterator;
    
    @Configuration
    public class ContentNegotiationManagerConfiguration {
    
        @Bean
        public ContentNegotiationManagerBeanPostProcessor contentNegotiationManagerBeanPostProcessor() {
            return new ContentNegotiationManagerBeanPostProcessor();
        }
    
    
        private static class ContentNegotiationManagerBeanPostProcessor implements BeanPostProcessor {
    
            @Override
            public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
                return bean; //no op
            }
    
            @Override
            public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
                if (!(bean instanceof ContentNegotiationManager)) {
                    return bean;
                }
    
                final ContentNegotiationManager contentNegotiationManager = (ContentNegotiationManager) bean;
    
                ListIterator<ContentNegotiationStrategy> iterator =
                        contentNegotiationManager.getStrategies().listIterator();
    
                while (iterator.hasNext()) {
                    ContentNegotiationStrategy strategy = iterator.next();
                    if (strategy.getClass().getName().contains("OptionalPathExtensionContentNegotiationStrategy")) {
                        iterator.set(new RemoveHandleNoMatchContentNegotiationStrategy());
                    }
                }
    
                return bean;
            }
        }
    
        private static class RemoveHandleNoMatchContentNegotiationStrategy
                extends PathExtensionContentNegotiationStrategy {
    
            /**
             * Don't lookup file extensions to match mime-type
             * Effectively reverts to Spring Boot 1.5.7 behavior
             */
            @Override
            protected MediaType handleNoMatch(NativeWebRequest request, String key) {
                return null;
            }
        }
    }
    

    The second solution one could implement is leverage the capability of OptionalPathExtensionContentNegotiationStrategy class which is used by Spring by default.

    Essentially what you would need to do is ensure that every HTTP request to your validateUsername endpoint would contain an attribute named org.springframework.web.accept.PathExtensionContentNegotiationStrategy.SKIP with the value of true