diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java index e5df7cb13e..50d22f8c45 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2017 the original author or authors. + * Copyright 2012-2018 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -101,6 +101,7 @@ import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.ResourceChainRegistration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; @@ -214,8 +215,23 @@ public class WebMvcAutoConfiguration { } } + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.setUseSuffixPatternMatch(this.mvcProperties + .getPathMatch().isUseSuffixPattern()); + configurer.setUseRegisteredSuffixPatternMatch(this.mvcProperties + .getPathMatch().isUseRegisteredSuffixPattern()); + } + @Override public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + WebMvcProperties.ContentNegotiation contentNegotiation + = this.mvcProperties.getContentNegotiation(); + configurer.favorPathExtension(contentNegotiation.isFavorPathExtension()); + configurer.favorParameter(contentNegotiation.isFavorParameter()); + if (contentNegotiation.getParameterName() != null) { + configurer.parameterName(contentNegotiation.getParameterName()); + } Map mediaTypes = this.mvcProperties.getMediaTypes(); for (Entry mediaType : mediaTypes.entrySet()) { configurer.mediaType(mediaType.getKey(), mediaType.getValue()); @@ -308,8 +324,8 @@ public class WebMvcAutoConfiguration { registry.addResourceHandler("/webjars/**") .addResourceLocations( "classpath:/META-INF/resources/webjars/") - .setCachePeriod(getSeconds(cachePeriod)) - .setCacheControl(cacheControl)); + .setCachePeriod(getSeconds(cachePeriod)) + .setCacheControl(cacheControl)); } String staticPathPattern = this.mvcProperties.getStaticPathPattern(); if (!registry.hasMappingForPattern(staticPathPattern)) { @@ -317,8 +333,8 @@ public class WebMvcAutoConfiguration { registry.addResourceHandler(staticPathPattern) .addResourceLocations(getResourceLocations( this.resourceProperties.getStaticLocations())) - .setCachePeriod(getSeconds(cachePeriod)) - .setCacheControl(cacheControl)); + .setCachePeriod(getSeconds(cachePeriod)) + .setCacheControl(cacheControl)); } } @@ -450,8 +466,8 @@ public class WebMvcAutoConfiguration { @Override public RequestMappingHandlerAdapter requestMappingHandlerAdapter() { RequestMappingHandlerAdapter adapter = super.requestMappingHandlerAdapter(); - adapter.setIgnoreDefaultModelOnRedirect(this.mvcProperties == null ? true - : this.mvcProperties.isIgnoreDefaultModelOnRedirect()); + adapter.setIgnoreDefaultModelOnRedirect(this.mvcProperties == null + || this.mvcProperties.isIgnoreDefaultModelOnRedirect()); return adapter; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java index 473d2c22c6..f9c2d88ea7 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2017 the original author or authors. + * Copyright 2012-2018 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import org.springframework.validation.DefaultMessageCodesResolver; * @author Sébastien Deleuze * @author Stephane Nicoll * @author Eddú Meléndez + * @author Brian Clozel * @since 1.1 */ @ConfigurationProperties(prefix = "spring.mvc") @@ -102,6 +103,10 @@ public class WebMvcProperties { private final View view = new View(); + private final ContentNegotiation contentNegotiation = new ContentNegotiation(); + + private final PathMatch pathMatch = new PathMatch(); + public DefaultMessageCodesResolver.Format getMessageCodesResolverFormat() { return this.messageCodesResolverFormat; } @@ -204,6 +209,14 @@ public class WebMvcProperties { return this.view; } + public ContentNegotiation getContentNegotiation() { + return this.contentNegotiation; + } + + public PathMatch getPathMatch() { + return this.pathMatch; + } + public static class Async { /** @@ -270,6 +283,84 @@ public class WebMvcProperties { } + public static class ContentNegotiation { + + /** + * Whether the path extension in the URL path should be used to determine + * the requested media type. If enabled a request "/users.pdf" will be + * interpreted as a request for "application/pdf" regardless of the 'Accept' header. + */ + private boolean favorPathExtension = false; + + /** + * Whether a request parameter ("format" by default) should be used to + * determine the requested media type. + */ + private boolean favorParameter = false; + + /** + * Query parameter name to use when "favor-parameter" is enabled. + */ + private String parameterName; + + public boolean isFavorPathExtension() { + return this.favorPathExtension; + } + + public void setFavorPathExtension(boolean favorPathExtension) { + this.favorPathExtension = favorPathExtension; + } + + public boolean isFavorParameter() { + return this.favorParameter; + } + + public void setFavorParameter(boolean favorParameter) { + this.favorParameter = favorParameter; + } + + public String getParameterName() { + return this.parameterName; + } + + public void setParameterName(String parameterName) { + this.parameterName = parameterName; + } + } + + public static class PathMatch { + + /** + * Whether to use suffix pattern match (".*") when matching patterns to + * requests. If enabled a method mapped to "/users" also matches to "/users.*". + */ + private boolean useSuffixPattern = false; + + /** + * Whether suffix pattern matching should work only against path extensions + * explicitly registered with "spring.mvc.media-types.*". + * This is generally recommended to reduce ambiguity and to + * avoid issues such as when a "." appears in the path for other reasons. + */ + private boolean useRegisteredSuffixPattern = false; + + public boolean isUseSuffixPattern() { + return this.useSuffixPattern; + } + + public void setUseSuffixPattern(boolean useSuffixPattern) { + this.useSuffixPattern = useSuffixPattern; + } + + public boolean isUseRegisteredSuffixPattern() { + return this.useRegisteredSuffixPattern; + } + + public void setUseRegisteredSuffixPattern(boolean useRegisteredSuffixPattern) { + this.useRegisteredSuffixPattern = useRegisteredSuffixPattern; + } + } + public enum LocaleResolver { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java index 537ea4275c..06c72a7af2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2017 the original author or authors. + * Copyright 2012-2018 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,6 +62,7 @@ import org.springframework.util.StringUtils; import org.springframework.validation.Validator; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.accept.ParameterContentNegotiationStrategy; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.filter.HttpPutFormContentFilter; import org.springframework.web.servlet.HandlerAdapter; @@ -470,7 +471,8 @@ public class WebMvcAutoConfigurationTests { @Test public void customMediaTypes() { - this.contextRunner.withPropertyValues("spring.mvc.mediaTypes.yaml:text/yaml") + this.contextRunner.withPropertyValues("spring.mvc.mediaTypes.yaml:text/yaml", + "spring.mvc.content-negotiation.favor-path-extension:true") .run((context) -> { RequestMappingHandlerAdapter adapter = context .getBean(RequestMappingHandlerAdapter.class); @@ -738,6 +740,63 @@ public class WebMvcAutoConfigurationTests { .run((context) -> assertCacheControl(context)); } + @Test + public void defaultPathMatching() { + this.contextRunner.run((context) -> { + RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); + assertThat(handlerMapping.useSuffixPatternMatch()).isFalse(); + assertThat(handlerMapping.useRegisteredSuffixPatternMatch()).isFalse(); + }); + } + + @Test + public void useSuffixPatternMatch() { + this.contextRunner + .withPropertyValues("spring.mvc.path-match.use-suffix-pattern:true", + "spring.mvc.path-match.use-registered-suffix-pattern:true") + .run((context) -> { + RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); + assertThat(handlerMapping.useSuffixPatternMatch()).isTrue(); + assertThat(handlerMapping.useRegisteredSuffixPatternMatch()).isTrue(); + }); + } + + @Test + public void defaultContentNegotiation() { + this.contextRunner.run((context) -> { + RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); + ContentNegotiationManager contentNegotiationManager = handlerMapping.getContentNegotiationManager(); + assertThat(contentNegotiationManager.getStrategies()) + .doesNotHaveAnyElementsOfTypes(WebMvcAutoConfiguration + .OptionalPathExtensionContentNegotiationStrategy.class); + }); + } + + @Test + public void pathExtensionContentNegotiation() { + this.contextRunner + .withPropertyValues("spring.mvc.content-negotiation.favor-path-extension:true") + .run((context) -> { + RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); + ContentNegotiationManager contentNegotiationManager = handlerMapping.getContentNegotiationManager(); + assertThat(contentNegotiationManager.getStrategies()) + .hasAtLeastOneElementOfType(WebMvcAutoConfiguration + .OptionalPathExtensionContentNegotiationStrategy.class); + }); + } + + @Test + public void queryParameterContentNegotiation() { + this.contextRunner + .withPropertyValues("spring.mvc.content-negotiation.favor-parameter:true") + .run((context) -> { + RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class); + ContentNegotiationManager contentNegotiationManager = handlerMapping.getContentNegotiationManager(); + assertThat(contentNegotiationManager.getStrategies()) + .hasAtLeastOneElementOfType(ParameterContentNegotiationStrategy.class); + }); + } + private void assertCacheControl(AssertableWebApplicationContext context) { Map handlerMap = getHandlerMap( context.getBean("resourceHandlerMapping", HandlerMapping.class)); diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc index 062166e1a9..d6bac7abbf 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc @@ -393,6 +393,9 @@ content into your application. Rather, pick only the properties that you need. # SPRING MVC ({sc-spring-boot-autoconfigure}/web/servlet/WebMvcProperties.{sc-ext}[WebMvcProperties]) spring.mvc.async.request-timeout= # Amount of time before asynchronous request handling times out. + spring.mvc.content-negotiation.favor-path-extension=false # Whether the path extension in the URL path should be used to determine the requested media type. + spring.mvc.content-negotiation.favor-parameter=false # Whether a request parameter ("format" by default) should be used to determine the requested media type. + spring.mvc.content-negotiation.parameter-name= # Query parameter name to use when "favor-parameter" is enabled. spring.mvc.date-format= # Date format to use. For instance, `dd/MM/yyyy`. spring.mvc.dispatch-trace-request=false # Whether to dispatch TRACE requests to the FrameworkServlet doService method. spring.mvc.dispatch-options-request=true # Whether to dispatch OPTIONS requests to the FrameworkServlet doService method. @@ -404,6 +407,8 @@ content into your application. Rather, pick only the properties that you need. spring.mvc.log-resolved-exception=false # Whether to enable warn logging of exceptions resolved by a "HandlerExceptionResolver". spring.mvc.media-types.*= # Maps file extensions to media types for content negotiation. spring.mvc.message-codes-resolver-format= # Formatting strategy for message codes. For instance, `PREFIX_ERROR_CODE`. + spring.mvc.path-match.use-registered-suffix-pattern=false # Whether suffix pattern matching should work only against path extensions explicitly registered with "spring.mvc.media-types.*". + spring.mvc.path-match.use-suffix-pattern=false # Whether to use suffix pattern match (".*") when matching patterns to requests. spring.mvc.servlet.load-on-startup=-1 # Load on startup priority of the Spring Web Services servlet. spring.mvc.static-path-pattern=/** # Path pattern used for static resources. spring.mvc.throw-exception-if-no-handler-found=false # Whether a "NoHandlerFoundException" should be thrown if no Handler was found to process a request. diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc index 9631818aa8..b2fa9ecd59 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc @@ -2053,6 +2053,52 @@ root of the classpath (in that order). If such a file is present, it is automati used as the favicon of the application. +[[boot-features-spring-mvc-pathmatch]] +==== Path Patching and Content Negotiation +Spring MVC can map incoming HTTP requests to handlers by looking at the request path and +matching it to the mappings defined in your application (for example, `@GetMapping` +annotations on Controller methods). + +Spring Boot chooses to disable suffix pattern matching by default, which means that +requests like `"GET /projects/spring-boot.json"` won't be matched to +`@GetMapping("/project/spring-boot")` mappings. +This is considered as a +{spring-reference}web.html#mvc-ann-requestmapping-suffix-pattern-match[best practice +for Spring MVC applications]. This feature was mainly useful in the past for HTTP +clients which did not send proper "Accept" request headers; we needed to make sure +to send the correct Content Type to the client. Nowadays, Content Negotiation +is much more reliable. + +There are other ways to deal with HTTP clients that don't consistently send proper +"Accept" request headers. Instead of using suffix matching, we can use a query +parameter to ensure that requests like `"GET /projects/spring-boot?format=json"` +will be mapped to `@GetMapping("/project/spring-boot")`: + +[source,properties,indent=0,subs="verbatim,quotes,attributes"] +---- + spring.mvc.content-negotiation.favor-parameter=true + + # We can change the parameter name, which is "format" by default: + # spring.mvc.content-negotiation.parameter-name=myparam + + # We can also register additional file extensions/media types with: + spring.mvc.media-types.markdown=text/markdown +---- + +If you understand the caveats and would still like your application to use +suffix pattern matching, the following configuration is required: + +[source,properties,indent=0,subs="verbatim,quotes,attributes"] +---- + spring.mvc.content-negotiation.favor-path-extension=true + + # You can also restrict that feature to known extensions only + # spring.mvc.path-match.use-registered-suffix-pattern=true + + # We can also register additional file extensions/media types with: + # spring.mvc.media-types.adoc=text/asciidoc +---- + [[boot-features-spring-mvc-web-binding-initializer]] ==== ConfigurableWebBindingInitializer