Add option to configure PathPatternParser

As of Spring Framework 5.3, it is now possible to use `PathPatternParser`
to parse and match request mapping path patterns, as an alternative to
the current default `AntPathMatcher`.

This new implementation has been used for a while in Spring WebFlux and
it’s been designed for consistency and performance.

This commit introduces a new configuration property for opting-in this
new variant:

`spring.mvc.pathmatch.matching-strategy=path_pattern_parser`

The default option is still `ant_path_matcher` for now, but we might
change the default in future versions since Spring Framework considers
it the best choice for modern applications.

There are several behavior differences with this new variant:

* double wildcards `"**"` are rejected when used in the middle patterns,
this is only allowed as the last matching segment in a pattern.

* it is incompatible with some path matching options, like
suffix-pattern, registered-suffix-pattern or configuring a Servlet
prefix on the `DispatcherServlet` (`spring.mvc.servlet.path=/test`)

This commit introduces two `FailureAnalyzer` implementations to guide
developers when facing those issues.

Closes gh-21694
pull/22302/head
Brian Clozel 4 years ago
parent 2d91a096db
commit 0f264b68e8

@ -119,6 +119,7 @@ import org.springframework.web.servlet.view.BeanNameViewResolver;
import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
import org.springframework.web.servlet.view.InternalResourceViewResolver; import org.springframework.web.servlet.view.InternalResourceViewResolver;
import org.springframework.web.util.UrlPathHelper; import org.springframework.web.util.UrlPathHelper;
import org.springframework.web.util.pattern.PathPatternParser;
/** /**
* {@link EnableAutoConfiguration Auto-configuration} for {@link EnableWebMvc Web MVC}. * {@link EnableAutoConfiguration Auto-configuration} for {@link EnableWebMvc Web MVC}.
@ -202,6 +203,7 @@ public class WebMvcAutoConfiguration {
this.messageConvertersProvider = messageConvertersProvider; this.messageConvertersProvider = messageConvertersProvider;
this.resourceHandlerRegistrationCustomizer = resourceHandlerRegistrationCustomizerProvider.getIfAvailable(); this.resourceHandlerRegistrationCustomizer = resourceHandlerRegistrationCustomizerProvider.getIfAvailable();
this.dispatcherServletPath = dispatcherServletPath; this.dispatcherServletPath = dispatcherServletPath;
this.mvcProperties.checkConfiguration();
} }
@Override @Override
@ -228,6 +230,10 @@ public class WebMvcAutoConfiguration {
@Override @Override
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public void configurePathMatch(PathMatchConfigurer configurer) { public void configurePathMatch(PathMatchConfigurer configurer) {
if (this.mvcProperties.getPathmatch()
.getMatchingStrategy() == WebMvcProperties.MatchingStrategy.PATH_PATTERN_PARSER) {
configurer.setPatternParser(new PathPatternParser());
}
configurer.setUseSuffixPatternMatch(this.mvcProperties.getPathmatch().isUseSuffixPattern()); configurer.setUseSuffixPatternMatch(this.mvcProperties.getPathmatch().isUseSuffixPattern());
configurer.setUseRegisteredSuffixPatternMatch( configurer.setUseRegisteredSuffixPatternMatch(
this.mvcProperties.getPathmatch().isUseRegisteredSuffixPattern()); this.mvcProperties.getPathmatch().isUseRegisteredSuffixPattern());

@ -23,6 +23,7 @@ import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
import org.springframework.boot.context.properties.IncompatibleConfigurationException;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.validation.DefaultMessageCodesResolver; import org.springframework.validation.DefaultMessageCodesResolver;
@ -235,6 +236,24 @@ public class WebMvcProperties {
return this.pathmatch; return this.pathmatch;
} }
@SuppressWarnings("deprecation")
public void checkConfiguration() {
if (this.getPathmatch().getMatchingStrategy() == MatchingStrategy.PATH_PATTERN_PARSER) {
if (this.getPathmatch().isUseSuffixPattern()) {
throw new IncompatibleConfigurationException("spring.mvc.pathmatch.matching-strategy",
"spring.mvc.pathmatch.use-suffix-pattern");
}
else if (this.getPathmatch().isUseRegisteredSuffixPattern()) {
throw new IncompatibleConfigurationException("spring.mvc.pathmatch.matching-strategy",
"spring.mvc.pathmatch.use-registered-suffix-pattern");
}
else if (!this.getServlet().getServletMapping().equals("/")) {
throw new IncompatibleConfigurationException("spring.mvc.pathmatch.matching-strategy",
"spring.mvc.servlet.path");
}
}
}
public static class Async { public static class Async {
/** /**
@ -256,7 +275,8 @@ public class WebMvcProperties {
public static class Servlet { public static class Servlet {
/** /**
* Path of the dispatcher servlet. * Path of the dispatcher servlet. Setting a custom value for this property is not
* compatible with the PathPatternParser matching strategy.
*/ */
private String path = "/"; private String path = "/";
@ -411,9 +431,15 @@ public class WebMvcProperties {
public static class Pathmatch { public static class Pathmatch {
/**
* Choice of strategy for matching request paths against registered mappings.
*/
private MatchingStrategy matchingStrategy = MatchingStrategy.ANT_PATH_MATCHER;
/** /**
* Whether to use suffix pattern match (".*") when matching patterns to requests. * Whether to use suffix pattern match (".*") when matching patterns to requests.
* If enabled a method mapped to "/users" also matches to "/users.*". * If enabled a method mapped to "/users" also matches to "/users.*". Enabling
* this option is not compatible with the PathPatternParser matching strategy.
*/ */
private boolean useSuffixPattern = false; private boolean useSuffixPattern = false;
@ -421,10 +447,19 @@ public class WebMvcProperties {
* Whether suffix pattern matching should work only against extensions registered * Whether suffix pattern matching should work only against extensions registered
* with "spring.mvc.contentnegotiation.media-types.*". This is generally * with "spring.mvc.contentnegotiation.media-types.*". This is generally
* recommended to reduce ambiguity and to avoid issues such as when a "." appears * recommended to reduce ambiguity and to avoid issues such as when a "." appears
* in the path for other reasons. * in the path for other reasons. Enabling this option is not compatible with the
* PathPatternParser matching strategy.
*/ */
private boolean useRegisteredSuffixPattern = false; private boolean useRegisteredSuffixPattern = false;
public MatchingStrategy getMatchingStrategy() {
return this.matchingStrategy;
}
public void setMatchingStrategy(MatchingStrategy matchingStrategy) {
this.matchingStrategy = matchingStrategy;
}
@DeprecatedConfigurationProperty( @DeprecatedConfigurationProperty(
reason = "Use of path extensions for request mapping and for content negotiation is discouraged.") reason = "Use of path extensions for request mapping and for content negotiation is discouraged.")
@Deprecated @Deprecated
@ -494,6 +529,20 @@ public class WebMvcProperties {
} }
public enum MatchingStrategy {
/**
* Use the {@code AntPathMatcher} implementation.
*/
ANT_PATH_MATCHER,
/**
* Use the {@code PathPatternParser} implementation.
*/
PATH_PATTERN_PARSER
}
public enum LocaleResolver { public enum LocaleResolver {
/** /**

@ -47,6 +47,7 @@ import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguratio
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
import org.springframework.boot.autoconfigure.validation.ValidatorAdapter; import org.springframework.boot.autoconfigure.validation.ValidatorAdapter;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter;
import org.springframework.boot.context.properties.IncompatibleConfigurationException;
import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
import org.springframework.boot.test.context.runner.ContextConsumer; import org.springframework.boot.test.context.runner.ContextConsumer;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
@ -768,6 +769,24 @@ class WebMvcAutoConfigurationTests {
}); });
} }
@Test
void usePathPatternParser() {
this.contextRunner.withPropertyValues("spring.mvc.pathmatch.matching-strategy:path_pattern_parser")
.run((context) -> {
RequestMappingHandlerMapping handlerMapping = context.getBean(RequestMappingHandlerMapping.class);
assertThat(handlerMapping.usesPathPatterns()).isTrue();
});
}
@Test
void incompatiblePathMatchingConfiguration() {
this.contextRunner
.withPropertyValues("spring.mvc.pathmatch.matching-strategy:path_pattern_parser",
"spring.mvc.pathmatch.use-suffix-pattern:true")
.run((context) -> assertThat(context.getStartupFailure()).getRootCause()
.isInstanceOf(IncompatibleConfigurationException.class));
}
@Test @Test
void defaultContentNegotiation() { void defaultContentNegotiation() {
this.contextRunner.run((context) -> { this.contextRunner.run((context) -> {

@ -1,5 +1,5 @@
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -22,6 +22,7 @@ import java.util.Map;
import org.assertj.core.util.Throwables; import org.assertj.core.util.Throwables;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.IncompatibleConfigurationException;
import org.springframework.boot.context.properties.bind.BindException; import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.bind.Binder;
@ -61,6 +62,32 @@ class WebMvcPropertiesTests {
(ex) -> assertThat(Throwables.getRootCause(ex)).hasMessage("Path must not contain wildcards")); (ex) -> assertThat(Throwables.getRootCause(ex)).hasMessage("Path must not contain wildcards"));
} }
@Test
@SuppressWarnings("deprecation")
void incompatiblePathMatchSuffixConfig() {
this.properties.getPathmatch().setMatchingStrategy(WebMvcProperties.MatchingStrategy.PATH_PATTERN_PARSER);
this.properties.getPathmatch().setUseSuffixPattern(true);
assertThatExceptionOfType(IncompatibleConfigurationException.class)
.isThrownBy(this.properties::checkConfiguration);
}
@Test
@SuppressWarnings("deprecation")
void incompatiblePathMatchRegisteredSuffixConfig() {
this.properties.getPathmatch().setMatchingStrategy(WebMvcProperties.MatchingStrategy.PATH_PATTERN_PARSER);
this.properties.getPathmatch().setUseRegisteredSuffixPattern(true);
assertThatExceptionOfType(IncompatibleConfigurationException.class)
.isThrownBy(this.properties::checkConfiguration);
}
@Test
void incompatiblePathMatchServletPathConfig() {
this.properties.getPathmatch().setMatchingStrategy(WebMvcProperties.MatchingStrategy.PATH_PATTERN_PARSER);
this.properties.getServlet().setPath("/test");
assertThatExceptionOfType(IncompatibleConfigurationException.class)
.isThrownBy(this.properties::checkConfiguration);
}
private void bind(String name, String value) { private void bind(String name, String value) {
bind(Collections.singletonMap(name, value)); bind(Collections.singletonMap(name, value));
} }

@ -2441,6 +2441,24 @@ Alternatively, rather than open all suffix patterns, it's more secure to just su
# spring.mvc.contentnegotiation.media-types.adoc=text/asciidoc # spring.mvc.contentnegotiation.media-types.adoc=text/asciidoc
---- ----
As of Spring Framework 5.3, Spring MVC supports several implementation strategies for matching request paths to Controller handlers.
It was previously only supporting the `AntPathMatcher` strategy, but it now also offers `PathPatternParser`.
Spring Boot now provides a configuration property to choose and opt in the new strategy:
[source,properties,indent=0,subs="verbatim,quotes,attributes"]
----
spring.mvc.pathmatch.matching-strategy=path_pattern_parser
----
For more details on why you should consider this new implementation, please check out the
https://spring.io/blog/2020/06/30/url-matching-with-pathpattern-in-spring-mvc[dedicated blog post].
NOTE: `PathPatternParser` is an optimized implementation but restricts usage of
{spring-framework-docs}/web.html#mvc-ann-requestmapping-uri-templates[some path patterns variants]
and is incompatible with suffix pattern matching (configprop:spring.mvc.pathmatch.use-suffix-pattern[deprecated],
configprop:spring.mvc.pathmatch.use-registered-suffix-pattern[deprecated]) or mapping the `DispatcherServlet`
with a Servlet prefix (configprop:spring.mvc.servlet.path[]).
[[boot-features-spring-mvc-web-binding-initializer]] [[boot-features-spring-mvc-web-binding-initializer]]

@ -0,0 +1,43 @@
/*
* Copyright 2012-2020 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.context.properties;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
/**
* Exception thrown when the application has configured an incompatible set of
* {@link ConfigurationProperties} keys.
*
* @author Brian Clozel
* @since 2.4.0
*/
public class IncompatibleConfigurationException extends RuntimeException {
private final List<String> incompatibleKeys;
public IncompatibleConfigurationException(String... incompatibleKeys) {
super("The following configuration properties have incompatible values: " + Arrays.toString(incompatibleKeys));
this.incompatibleKeys = Arrays.asList(incompatibleKeys);
}
public Collection<String> getIncompatibleKeys() {
return this.incompatibleKeys;
}
}

@ -0,0 +1,40 @@
/*
* Copyright 2012-2020 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.diagnostics.analyzer;
import java.util.stream.Collectors;
import org.springframework.boot.context.properties.IncompatibleConfigurationException;
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;
/**
* A {@code FailureAnalyzer} that performs analysis of failures caused by a
* {@code IncompatibleConfigurationException}.
*
* @author Brian Clozel
*/
class IncompatibleConfigurationFailureAnalyzer extends AbstractFailureAnalyzer<IncompatibleConfigurationException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, IncompatibleConfigurationException cause) {
String action = String.format("Review the docs for %s and change the configured values.",
cause.getIncompatibleKeys().stream().collect(Collectors.joining(", ")));
return new FailureAnalysis(cause.getMessage(), action, cause);
}
}

@ -0,0 +1,39 @@
/*
* Copyright 2012-2020 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.diagnostics.analyzer;
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;
import org.springframework.web.util.pattern.PatternParseException;
/**
* A {@code FailureAnalyzer} that performs analysis of failures caused by a
* {@code PatternParseException}.
*
* @author Brian Clozel
*/
class PatternParseFailureAnalyzer extends AbstractFailureAnalyzer<PatternParseException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, PatternParseException cause) {
return new FailureAnalysis("Invalid mapping pattern detected: " + cause.toDetailedString(),
"Fix this pattern in your application or switch to the legacy parser implementation with "
+ "`spring.mvc.pathpattern.matching-strategy=ant_path_matcher`.",
cause);
}
}

@ -52,8 +52,10 @@ org.springframework.boot.diagnostics.analyzer.NoSuchMethodFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.NoUniqueBeanDefinitionFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.NoUniqueBeanDefinitionFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.PortInUseFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.PortInUseFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.ValidationExceptionFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.ValidationExceptionFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.IncompatibleConfigurationFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.InvalidConfigurationPropertyNameFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.InvalidConfigurationPropertyNameFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.InvalidConfigurationPropertyValueFailureAnalyzer org.springframework.boot.diagnostics.analyzer.InvalidConfigurationPropertyValueFailureAnalyzer,\
org.springframework.boot.diagnostics.analyzer.PatternParseFailureAnalyzer
# FailureAnalysisReporters # FailureAnalysisReporters
org.springframework.boot.diagnostics.FailureAnalysisReporter=\ org.springframework.boot.diagnostics.FailureAnalysisReporter=\

@ -0,0 +1,47 @@
/*
* Copyright 2012-2020 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.diagnostics.analyzer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.IncompatibleConfigurationException;
import org.springframework.boot.diagnostics.FailureAnalysis;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link IncompatibleConfigurationFailureAnalyzer}
*
* @author Brian Clozel
*/
class IncompatibleConfigurationFailureAnalyzerTests {
@Test
void incompatibleConfigurationListsKeys() {
FailureAnalysis failureAnalysis = performAnalysis("spring.first.key", "spring.second.key");
assertThat(failureAnalysis.getDescription()).contains(
"The following configuration properties have incompatible values: [spring.first.key, spring.second.key]");
assertThat(failureAnalysis.getAction())
.contains("Review the docs for spring.first.key, spring.second.key and change the configured values.");
}
private FailureAnalysis performAnalysis(String... keys) {
IncompatibleConfigurationException failure = new IncompatibleConfigurationException(keys);
return new IncompatibleConfigurationFailureAnalyzer().analyze(failure);
}
}

@ -0,0 +1,61 @@
/*
* Copyright 2012-2020 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.diagnostics.analyzer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.diagnostics.FailureAnalysis;
import org.springframework.web.util.pattern.PathPatternParser;
import org.springframework.web.util.pattern.PatternParseException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link PatternParseFailureAnalyzer}
*
* @author Brian Clozel
*/
class PatternParseFailureAnalyzerTests {
private PathPatternParser parser = new PathPatternParser();
@Test
void patternParseFailureQuotesPattern() {
FailureAnalysis failureAnalysis = performAnalysis("/spring/**/framework");
assertThat(failureAnalysis.getDescription()).contains("Invalid mapping pattern detected: /spring/**/framework");
assertThat(failureAnalysis.getAction())
.contains("Fix this pattern in your application or switch to the legacy parser"
+ " implementation with `spring.mvc.pathpattern.matching-strategy=ant_path_matcher`.");
}
private FailureAnalysis performAnalysis(String pattern) {
PatternParseException failure = createFailure(pattern);
assertThat(failure).isNotNull();
return new PatternParseFailureAnalyzer().analyze(failure);
}
PatternParseException createFailure(String pattern) {
try {
this.parser.parse(pattern);
return null;
}
catch (PatternParseException ex) {
return ex;
}
}
}
Loading…
Cancel
Save