Browse Source

Enable use of parsed patterns by default in Spring MVC

Closes gh-28607
pull/28544/merge
rstoyanchev 4 years ago
parent
commit
92cf1e13e8
  1. 18
      spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java
  2. 6
      spring-web/src/main/java/org/springframework/web/cors/UrlBasedCorsConfigurationSource.java
  3. 6
      spring-web/src/main/java/org/springframework/web/util/ServletRequestPathUtils.java
  4. 26
      spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java
  5. 79
      spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/PathMatchConfigurer.java
  6. 76
      spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java
  7. 65
      spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java
  8. 4
      spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java
  9. 2
      spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java
  10. 16
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java
  11. 3
      spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java
  12. 19
      spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfigurationIntegrationTests.java
  13. 9
      spring-webmvc/src/test/java/org/springframework/web/servlet/handler/BeanNameUrlHandlerMappingTests.java
  14. 8
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/AbstractServletHandlerMethodTests.java
  15. 1
      spring-webmvc/src/test/resources/org/springframework/web/servlet/handler/map3.xml
  16. 42
      src/docs/asciidoc/web/webmvc.adoc

18
spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@ -127,6 +127,8 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM @@ -127,6 +127,8 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
@Nullable
private FlashMapManager flashMapManager;
private boolean preferPathMatcher = false;
@Nullable
private PathPatternParser patternParser;
@ -317,8 +319,10 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM @@ -317,8 +319,10 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
* @param parser the parser to use
* @since 5.3
*/
public void setPatternParser(PathPatternParser parser) {
public StandaloneMockMvcBuilder setPatternParser(@Nullable PathPatternParser parser) {
this.patternParser = parser;
this.preferPathMatcher = (this.patternParser == null);
return this;
}
/**
@ -332,6 +336,7 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM @@ -332,6 +336,7 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
@Deprecated
public StandaloneMockMvcBuilder setUseSuffixPatternMatch(boolean useSuffixPatternMatch) {
this.useSuffixPatternMatch = useSuffixPatternMatch;
this.preferPathMatcher |= useSuffixPatternMatch;
return this;
}
@ -468,15 +473,16 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM @@ -468,15 +473,16 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
RequestMappingHandlerMapping handlerMapping = handlerMappingFactory.get();
handlerMapping.setEmbeddedValueResolver(new StaticStringValueResolver(placeholderValues));
if (patternParser != null) {
handlerMapping.setPatternParser(patternParser);
}
else {
if (patternParser == null && preferPathMatcher) {
handlerMapping.setPatternParser(null);
handlerMapping.setUseSuffixPatternMatch(useSuffixPatternMatch);
if (removeSemicolonContent != null) {
handlerMapping.setRemoveSemicolonContent(removeSemicolonContent);
}
}
else if (patternParser != null) {
handlerMapping.setPatternParser(patternParser);
}
handlerMapping.setUseTrailingSlashMatch(useTrailingSlashPatternMatch);
handlerMapping.setOrder(0);
handlerMapping.setInterceptors(getInterceptors(mvcConversionService, mvcResourceUrlProvider));

6
spring-web/src/main/java/org/springframework/web/cors/UrlBasedCorsConfigurationSource.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2022 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.
@ -53,7 +53,7 @@ import org.springframework.web.util.pattern.PathPatternParser; @@ -53,7 +53,7 @@ import org.springframework.web.util.pattern.PathPatternParser;
*/
public class UrlBasedCorsConfigurationSource implements CorsConfigurationSource {
private static PathMatcher defaultPathMatcher = new AntPathMatcher();
private static final PathMatcher defaultPathMatcher = new AntPathMatcher();
private final PathPatternParser patternParser;
@ -157,7 +157,7 @@ public class UrlBasedCorsConfigurationSource implements CorsConfigurationSource @@ -157,7 +157,7 @@ public class UrlBasedCorsConfigurationSource implements CorsConfigurationSource
* pattern matching with {@link PathMatcher} or with parsed {@link PathPattern}s.
* <p>In Spring MVC, either a resolved String lookupPath or a parsed
* {@code RequestPath} is always available within {@code DispatcherServlet}
* processing. However in a Servlet {@code Filter} such as {@code CorsFilter}
* processing. However, in a Servlet {@code Filter} such as {@code CorsFilter}
* that may or may not be the case.
* <p>By default this is set to {@code true} in which case lazy lookupPath
* initialization is allowed. Set this to {@code false} when an

6
spring-web/src/main/java/org/springframework/web/util/ServletRequestPathUtils.java

@ -252,6 +252,9 @@ public abstract class ServletRequestPathUtils { @@ -252,6 +252,9 @@ public abstract class ServletRequestPathUtils {
if (UrlPathHelper.servlet4Present) {
String servletPathPrefix = Servlet4Delegate.getServletPathPrefix(request);
if (StringUtils.hasText(servletPathPrefix)) {
if (servletPathPrefix.endsWith("/")) {
servletPathPrefix = servletPathPrefix.substring(0, servletPathPrefix.length() - 1);
}
return new ServletRequestPath(requestUri, request.getContextPath(), servletPathPrefix);
}
}
@ -272,8 +275,7 @@ public abstract class ServletRequestPathUtils { @@ -272,8 +275,7 @@ public abstract class ServletRequestPathUtils {
if (mapping == null) {
mapping = request.getHttpServletMapping();
}
MappingMatch match = mapping.getMappingMatch();
if (!ObjectUtils.nullSafeEquals(match, MappingMatch.PATH)) {
if (!ObjectUtils.nullSafeEquals(mapping.getMappingMatch(), MappingMatch.PATH)) {
return null;
}
String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE);

26
spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@ -405,22 +405,28 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser { @@ -405,22 +405,28 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser {
if (pathMatchingElement != null) {
Object source = context.extractSource(element);
if (pathMatchingElement.hasAttribute("suffix-pattern")) {
Boolean useSuffixPatternMatch = Boolean.valueOf(pathMatchingElement.getAttribute("suffix-pattern"));
handlerMappingDef.getPropertyValues().add("useSuffixPatternMatch", useSuffixPatternMatch);
}
if (pathMatchingElement.hasAttribute("trailing-slash")) {
Boolean useTrailingSlashMatch = Boolean.valueOf(pathMatchingElement.getAttribute("trailing-slash"));
boolean useTrailingSlashMatch = Boolean.parseBoolean(pathMatchingElement.getAttribute("trailing-slash"));
handlerMappingDef.getPropertyValues().add("useTrailingSlashMatch", useTrailingSlashMatch);
}
boolean preferPathMatcher = false;
if (pathMatchingElement.hasAttribute("suffix-pattern")) {
boolean useSuffixPatternMatch = Boolean.parseBoolean(pathMatchingElement.getAttribute("suffix-pattern"));
handlerMappingDef.getPropertyValues().add("useSuffixPatternMatch", useSuffixPatternMatch);
preferPathMatcher |= useSuffixPatternMatch;
}
if (pathMatchingElement.hasAttribute("registered-suffixes-only")) {
Boolean useRegisteredSuffixPatternMatch = Boolean.valueOf(pathMatchingElement.getAttribute("registered-suffixes-only"));
boolean useRegisteredSuffixPatternMatch = Boolean.parseBoolean(pathMatchingElement.getAttribute("registered-suffixes-only"));
handlerMappingDef.getPropertyValues().add("useRegisteredSuffixPatternMatch", useRegisteredSuffixPatternMatch);
preferPathMatcher |= useRegisteredSuffixPatternMatch;
}
RuntimeBeanReference pathHelperRef = null;
if (pathMatchingElement.hasAttribute("path-helper")) {
pathHelperRef = new RuntimeBeanReference(pathMatchingElement.getAttribute("path-helper"));
preferPathMatcher = true;
}
pathHelperRef = MvcNamespaceUtils.registerUrlPathHelper(pathHelperRef, context, source);
handlerMappingDef.getPropertyValues().add("urlPathHelper", pathHelperRef);
@ -428,10 +434,16 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser { @@ -428,10 +434,16 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser {
RuntimeBeanReference pathMatcherRef = null;
if (pathMatchingElement.hasAttribute("path-matcher")) {
pathMatcherRef = new RuntimeBeanReference(pathMatchingElement.getAttribute("path-matcher"));
preferPathMatcher = true;
}
pathMatcherRef = MvcNamespaceUtils.registerPathMatcher(pathMatcherRef, context, source);
handlerMappingDef.getPropertyValues().add("pathMatcher", pathMatcherRef);
if (preferPathMatcher) {
handlerMappingDef.getPropertyValues().add("patternParser", null);
}
}
}
private Properties getDefaultMediaTypes() {

79
spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/PathMatchConfigurer.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@ -23,7 +23,6 @@ import java.util.function.Predicate; @@ -23,7 +23,6 @@ import java.util.function.Predicate;
import org.springframework.lang.Nullable;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.web.servlet.handler.AbstractHandlerMapping;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.UrlPathHelper;
import org.springframework.web.util.pattern.PathPattern;
@ -34,14 +33,19 @@ import org.springframework.web.util.pattern.PathPatternParser; @@ -34,14 +33,19 @@ import org.springframework.web.util.pattern.PathPatternParser;
* <ul>
* <li>{@link WebMvcConfigurationSupport#requestMappingHandlerMapping}</li>
* <li>{@link WebMvcConfigurationSupport#viewControllerHandlerMapping}</li>
* <li>{@link WebMvcConfigurationSupport#beanNameHandlerMapping}</li>
* <li>{@link WebMvcConfigurationSupport#routerFunctionMapping}</li>
* <li>{@link WebMvcConfigurationSupport#resourceHandlerMapping}</li>
* </ul>
*
* @author Brian Clozel
* @author Rossen Stoyanchev
* @since 4.0.3
*/
public class PathMatchConfigurer {
private boolean preferPathMatcher = false;
@Nullable
private PathPatternParser patternParser;
@ -74,17 +78,28 @@ public class PathMatchConfigurer { @@ -74,17 +78,28 @@ public class PathMatchConfigurer {
/**
* Enable use of parsed {@link PathPattern}s as described in
* {@link AbstractHandlerMapping#setPatternParser(PathPatternParser)}.
* <p><strong>Note:</strong> This is mutually exclusive with use of
* {@link #setUrlPathHelper(UrlPathHelper)} and
* {@link #setPathMatcher(PathMatcher)}.
* <p>By default this is not enabled.
* Set the {@link PathPatternParser} to parse {@link PathPattern patterns}
* with for URL path matching. Parsed patterns provide a more modern and
* efficient alternative to String path matching via {@link AntPathMatcher}.
* <p><strong>Note:</strong> This property is mutually exclusive with the
* following other, {@code AntPathMatcher} related properties:
* <ul>
* <li>{@link #setUseSuffixPatternMatch(Boolean)}
* <li>{@link #setUseRegisteredSuffixPatternMatch(Boolean)}
* <li>{@link #setUrlPathHelper(UrlPathHelper)}
* <li>{@link #setPathMatcher(PathMatcher)}
* </ul>
* <p>By default, as of 6.0, a {@link PathPatternParser} with default
* settings is used, which enables parsed {@link PathPattern patterns}.
* Set this property to {@code null} to fall back on String path matching via
* {@link AntPathMatcher} instead, or alternatively, setting one of the above
* listed {@code AntPathMatcher} related properties has the same effect.
* @param patternParser the parser to pre-parse patterns with
* @since 5.3
*/
public PathMatchConfigurer setPatternParser(PathPatternParser patternParser) {
public PathMatchConfigurer setPatternParser(@Nullable PathPatternParser patternParser) {
this.patternParser = patternParser;
this.preferPathMatcher = (patternParser == null);
return this;
}
@ -120,9 +135,11 @@ public class PathMatchConfigurer { @@ -120,9 +135,11 @@ public class PathMatchConfigurer {
/**
* Whether to use suffix pattern match (".*") when matching patterns to
* requests. If enabled a method mapped to "/users" also matches to "/users.*".
* <p><strong>Note:</strong> This property is mutually exclusive with
* {@link #setPatternParser(PathPatternParser)}. If set, it enables use of
* String path matching, unless a {@code PathPatternParser} is also
* explicitly set in which case this property is ignored.
* <p>By default this is set to {@code false}.
* <p><strong>Note:</strong> This property is mutually exclusive with and
* ignored when {@link #setPatternParser(PathPatternParser)} is set.
* @deprecated as of 5.2.4. See class-level note in
* {@link RequestMappingHandlerMapping} on the deprecation of path extension
* config options. As there is no replacement for this method, in 5.2.x it is
@ -132,6 +149,7 @@ public class PathMatchConfigurer { @@ -132,6 +149,7 @@ public class PathMatchConfigurer {
@Deprecated
public PathMatchConfigurer setUseSuffixPatternMatch(Boolean suffixPatternMatch) {
this.suffixPatternMatch = suffixPatternMatch;
this.preferPathMatcher |= suffixPatternMatch;
return this;
}
@ -141,9 +159,11 @@ public class PathMatchConfigurer { @@ -141,9 +159,11 @@ public class PathMatchConfigurer {
* {@link WebMvcConfigurer#configureContentNegotiation configure content
* negotiation}. This is generally recommended to reduce ambiguity and to
* avoid issues such as when a "." appears in the path for other reasons.
* <p><strong>Note:</strong> This property is mutually exclusive with
* {@link #setPatternParser(PathPatternParser)}. If set, it enables use of
* String path matching, unless a {@code PathPatternParser} is also
* explicitly set in which case this property is ignored.
* <p>By default this is set to "false".
* <p><strong>Note:</strong> This property is mutually exclusive with and
* ignored when {@link #setPatternParser(PathPatternParser)} is set.
* @deprecated as of 5.2.4. See class-level note in
* {@link RequestMappingHandlerMapping} on the deprecation of path extension
* config options.
@ -151,31 +171,54 @@ public class PathMatchConfigurer { @@ -151,31 +171,54 @@ public class PathMatchConfigurer {
@Deprecated
public PathMatchConfigurer setUseRegisteredSuffixPatternMatch(Boolean registeredSuffixPatternMatch) {
this.registeredSuffixPatternMatch = registeredSuffixPatternMatch;
this.preferPathMatcher |= registeredSuffixPatternMatch;
return this;
}
/**
* Set the UrlPathHelper to use to resolve the mapping path for the application.
* <p><strong>Note:</strong> This property is mutually exclusive with and
* ignored when {@link #setPatternParser(PathPatternParser)} is set.
* <p><strong>Note:</strong> This property is mutually exclusive with
* {@link #setPatternParser(PathPatternParser)}. If set, it enables use of
* String path matching, unless a {@code PathPatternParser} is also
* explicitly set in which case this property is ignored.
* <p>By default this is an instance of {@link UrlPathHelper} with default
* settings.
*/
public PathMatchConfigurer setUrlPathHelper(UrlPathHelper urlPathHelper) {
this.urlPathHelper = urlPathHelper;
this.preferPathMatcher = true;
return this;
}
/**
* Set the PathMatcher to use for String pattern matching.
* <p>By default this is {@link AntPathMatcher}.
* <p><strong>Note:</strong> This property is mutually exclusive with and
* ignored when {@link #setPatternParser(PathPatternParser)} is set.
* <p><strong>Note:</strong> This property is mutually exclusive with
* {@link #setPatternParser(PathPatternParser)}. If set, it enables use of
* String path matching, unless a {@code PathPatternParser} is also
* explicitly set in which case this property is ignored.
* <p>By default this is an instance of {@link AntPathMatcher} with default
* settings.
*/
public PathMatchConfigurer setPathMatcher(PathMatcher pathMatcher) {
this.pathMatcher = pathMatcher;
this.preferPathMatcher = true;
return this;
}
/**
* Whether to prefer {@link PathMatcher}. This is the case when either is true:
* <ul>
* <li>{@link PathPatternParser} is explicitly set to {@code null}.
* <li>{@link PathPatternParser} is not explicitly set, and a
* {@link PathMatcher} related option is explicitly set.
* </ul>
* @since 6.0
*/
protected boolean preferPathMatcher() {
return (this.patternParser == null && this.preferPathMatcher);
}
/**
* Return the {@link PathPatternParser} to use, if configured.
* @since 5.3

76
spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@ -313,18 +313,12 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @@ -313,18 +313,12 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping();
mapping.setOrder(0);
mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
mapping.setContentNegotiationManager(contentNegotiationManager);
mapping.setCorsConfigurations(getCorsConfigurations());
PathMatchConfigurer pathConfig = getPathMatchConfigurer();
if (pathConfig.getPatternParser() != null) {
mapping.setPatternParser(pathConfig.getPatternParser());
}
else {
mapping.setUrlPathHelper(pathConfig.getUrlPathHelperOrDefault());
mapping.setPathMatcher(pathConfig.getPathMatcherOrDefault());
initHandlerMapping(mapping, conversionService, resourceUrlProvider);
PathMatchConfigurer pathConfig = getPathMatchConfigurer();
if (pathConfig.preferPathMatcher()) {
Boolean useSuffixPatternMatch = pathConfig.isUseSuffixPatternMatch();
if (useSuffixPatternMatch != null) {
mapping.setUseSuffixPatternMatch(useSuffixPatternMatch);
@ -334,10 +328,12 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @@ -334,10 +328,12 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
mapping.setUseRegisteredSuffixPatternMatch(useRegisteredSuffixPatternMatch);
}
}
Boolean useTrailingSlashMatch = pathConfig.isUseTrailingSlashMatch();
if (useTrailingSlashMatch != null) {
mapping.setUseTrailingSlashMatch(useTrailingSlashMatch);
}
if (pathConfig.getPathPrefixes() != null) {
mapping.setPathPrefixes(pathConfig.getPathPrefixes());
}
@ -497,21 +493,29 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @@ -497,21 +493,29 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
ViewControllerRegistry registry = new ViewControllerRegistry(this.applicationContext);
addViewControllers(registry);
AbstractHandlerMapping handlerMapping = registry.buildHandlerMapping();
if (handlerMapping == null) {
return null;
AbstractHandlerMapping mapping = registry.buildHandlerMapping();
initHandlerMapping(mapping, conversionService, resourceUrlProvider);
return mapping;
}
private void initHandlerMapping(
@Nullable AbstractHandlerMapping mapping, FormattingConversionService conversionService,
ResourceUrlProvider resourceUrlProvider) {
if (mapping == null) {
return;
}
PathMatchConfigurer pathConfig = getPathMatchConfigurer();
if (pathConfig.getPatternParser() != null) {
handlerMapping.setPatternParser(pathConfig.getPatternParser());
if (pathConfig.preferPathMatcher()) {
mapping.setPatternParser(null);
mapping.setUrlPathHelper(pathConfig.getUrlPathHelperOrDefault());
mapping.setPathMatcher(pathConfig.getPathMatcherOrDefault());
}
else {
handlerMapping.setUrlPathHelper(pathConfig.getUrlPathHelperOrDefault());
handlerMapping.setPathMatcher(pathConfig.getPathMatcherOrDefault());
else if (pathConfig.getPatternParser() != null) {
mapping.setPatternParser(pathConfig.getPatternParser());
}
handlerMapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
handlerMapping.setCorsConfigurations(getCorsConfigurations());
return handlerMapping;
mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
mapping.setCorsConfigurations(getCorsConfigurations());
}
/**
@ -532,18 +536,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @@ -532,18 +536,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
BeanNameUrlHandlerMapping mapping = new BeanNameUrlHandlerMapping();
mapping.setOrder(2);
PathMatchConfigurer pathConfig = getPathMatchConfigurer();
if (pathConfig.getPatternParser() != null) {
mapping.setPatternParser(pathConfig.getPatternParser());
}
else {
mapping.setUrlPathHelper(pathConfig.getUrlPathHelperOrDefault());
mapping.setPathMatcher(pathConfig.getPathMatcherOrDefault());
}
mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
mapping.setCorsConfigurations(getCorsConfigurations());
initHandlerMapping(mapping, conversionService, resourceUrlProvider);
return mapping;
}
@ -599,20 +592,9 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @@ -599,20 +592,9 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
this.servletContext, contentNegotiationManager, pathConfig.getUrlPathHelper());
addResourceHandlers(registry);
AbstractHandlerMapping handlerMapping = registry.getHandlerMapping();
if (handlerMapping == null) {
return null;
}
if (pathConfig.getPatternParser() != null) {
handlerMapping.setPatternParser(pathConfig.getPatternParser());
}
else {
handlerMapping.setUrlPathHelper(pathConfig.getUrlPathHelperOrDefault());
handlerMapping.setPathMatcher(pathConfig.getPathMatcherOrDefault());
}
handlerMapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
handlerMapping.setCorsConfigurations(getCorsConfigurations());
return handlerMapping;
AbstractHandlerMapping mapping = registry.getHandlerMapping();
initHandlerMapping(mapping, conversionService, resourceUrlProvider);
return mapping;
}
/**

65
spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@ -49,6 +49,7 @@ import org.springframework.web.cors.CorsProcessor; @@ -49,6 +49,7 @@ import org.springframework.web.cors.CorsProcessor;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.cors.DefaultCorsProcessor;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;
@ -87,7 +88,7 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport @@ -87,7 +88,7 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport
private Object defaultHandler;
@Nullable
private PathPatternParser patternParser;
private PathPatternParser patternParser = new PathPatternParser();
private UrlPathHelper urlPathHelper = new UrlPathHelper();
@ -127,43 +128,45 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport @@ -127,43 +128,45 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport
}
/**
* Enable use of pre-parsed {@link PathPattern}s as an alternative to
* String pattern matching with {@link AntPathMatcher}. The syntax is
* largely the same but the {@code PathPattern} syntax is more tailored for
* web applications, and its implementation is more efficient.
* <p>This property is mutually exclusive with the following others which
* are effectively ignored when this is set:
* Set the {@link PathPatternParser} to parse {@link PathPattern patterns}
* with for URL path matching. Parsed patterns provide a more modern and
* efficient alternative to String path matching via {@link AntPathMatcher}.
* <p><strong>Note:</strong> This property is mutually exclusive with the
* below properties, all of which are not necessary for parsed patterns and
* are ignored when a {@code PathPatternParser} is available:
* <ul>
* <li>{@link #setAlwaysUseFullPath} -- {@code PathPatterns} always use the
* full path and ignore the servletPath/pathInfo which are decoded and
* partially normalized and therefore not comparable against the
* {@link HttpServletRequest#getRequestURI() requestURI}.
* <li>{@link #setRemoveSemicolonContent} -- {@code PathPatterns} always
* <li>{@link #setAlwaysUseFullPath} -- parsed patterns always use the
* full path and consider the servletPath only when a Servlet is mapped by
* path prefix.
* <li>{@link #setRemoveSemicolonContent} -- parsed patterns always
* ignore semicolon content for path matching purposes, but path parameters
* remain available for use in controllers via {@code @MatrixVariable}.
* <li>{@link #setUrlDecode} -- {@code PathPatterns} match one decoded path
* segment at a time and never need the full decoded path which can cause
* issues due to decoded reserved characters.
* <li>{@link #setUrlPathHelper} -- the request path is pre-parsed globally
* by the {@link org.springframework.web.servlet.DispatcherServlet
* DispatcherServlet} or by
* <li>{@link #setUrlDecode} -- parsed patterns match one decoded path
* segment at a time and therefore don't need to decode the full path.
* <li>{@link #setUrlPathHelper} -- for parsed patterns, the request path
* is parsed once in {@link org.springframework.web.servlet.DispatcherServlet
* DispatcherServlet} or in
* {@link org.springframework.web.filter.ServletRequestPathFilter
* ServletRequestPathFilter} using {@link ServletRequestPathUtils} and saved
* in a request attribute for re-use.
* <li>{@link #setPathMatcher} -- patterns are parsed to {@code PathPatterns}
* and used instead of String matching with {@code PathMatcher}.
* ServletRequestPathFilter} using {@link ServletRequestPathUtils} and cached
* in a request attribute.
* <li>{@link #setPathMatcher} -- a parsed patterns encapsulates the logic
* for path matching and does need a {@code PathMatcher}.
* </ul>
* <p>By default this is not set.
* <p>By default, as of 6.0, this is set to a {@link PathPatternParser}
* instance with default settings and therefore use of parsed patterns is
* enabled. Set this to {@code null} to switch to String path matching
* via {@link AntPathMatcher} instead.
* @param patternParser the parser to use
* @since 5.3
*/
public void setPatternParser(PathPatternParser patternParser) {
public void setPatternParser(@Nullable PathPatternParser patternParser) {
this.patternParser = patternParser;
}
/**
* Return the {@link #setPatternParser(PathPatternParser) configured}
* {@code PathPatternParser}, or {@code null}.
* {@code PathPatternParser}, or {@code null} otherwise which indicates that
* String pattern matching with {@link AntPathMatcher} is enabled instead.
* @since 5.3
*/
@Nullable
@ -570,7 +573,7 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport @@ -570,7 +573,7 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport
protected String initLookupPath(HttpServletRequest request) {
if (usesPathPatterns()) {
request.removeAttribute(UrlPathHelper.PATH_ATTRIBUTE);
RequestPath requestPath = ServletRequestPathUtils.getParsedRequestPath(request);
RequestPath requestPath = getRequestPath(request);
String lookupPath = requestPath.pathWithinApplication().value();
return UrlPathHelper.defaultInstance.removeSemicolonContent(lookupPath);
}
@ -579,6 +582,14 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport @@ -579,6 +582,14 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport
}
}
private RequestPath getRequestPath(HttpServletRequest request) {
// Expect pre-parsed path with DispatcherServlet,
// but otherwise parse per handler lookup + cache for handling
return request.getAttribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null ?
ServletRequestPathUtils.getParsedRequestPath(request) :
ServletRequestPathUtils.parseAndCache(request);
}
/**
* Build a {@link HandlerExecutionChain} for the given handler, including
* applicable interceptors.

4
spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@ -102,7 +102,7 @@ public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMap @@ -102,7 +102,7 @@ public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMap
@Override
public void setPatternParser(PathPatternParser patternParser) {
public void setPatternParser(@Nullable PathPatternParser patternParser) {
Assert.state(this.mappingRegistry.getRegistrations().isEmpty(),
"PathPatternParser must be set before the initialization of " +
"request mappings through InitializingBean#afterPropertiesSet.");

2
spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java

@ -75,7 +75,7 @@ public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping i @@ -75,7 +75,7 @@ public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping i
@Override
public void setPatternParser(PathPatternParser patternParser) {
public void setPatternParser(@Nullable PathPatternParser patternParser) {
Assert.state(this.handlerMap.isEmpty(),
"PathPatternParser must be set before the initialization of " +
"the handler map via ApplicationContextAware#setApplicationContext.");

16
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java

@ -76,6 +76,8 @@ import org.springframework.web.util.pattern.PathPatternParser; @@ -76,6 +76,8 @@ import org.springframework.web.util.pattern.PathPatternParser;
public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping
implements MatchableHandlerMapping, EmbeddedValueResolverAware {
private boolean defaultPatternParser = true;
private boolean useSuffixPatternMatch = false;
private boolean useRegisteredSuffixPatternMatch = false;
@ -92,6 +94,14 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -92,6 +94,14 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration();
@Override
public void setPatternParser(@Nullable PathPatternParser patternParser) {
if (patternParser != null) {
this.defaultPatternParser = false;
}
super.setPatternParser(patternParser);
}
/**
* Whether to use suffix pattern match (".*") when matching patterns to
* requests. If enabled a method mapped to "/users" also matches to "/users.*".
@ -191,6 +201,12 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -191,6 +201,12 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
this.config.setTrailingSlashMatch(useTrailingSlashMatch());
this.config.setContentNegotiationManager(getContentNegotiationManager());
if (getPatternParser() != null && this.defaultPatternParser &&
(this.useSuffixPatternMatch || this.useRegisteredSuffixPatternMatch)) {
setPatternParser(null);
}
if (getPatternParser() != null) {
this.config.setPatternParser(getPatternParser());
Assert.isTrue(!this.useSuffixPatternMatch && !this.useRegisteredSuffixPatternMatch,

3
spring-webmvc/src/test/java/org/springframework/web/servlet/config/MvcNamespaceTests.java

@ -37,6 +37,7 @@ import com.fasterxml.jackson.databind.MapperFeature; @@ -37,6 +37,7 @@ import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.MappingMatch;
import jakarta.validation.constraints.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -137,6 +138,7 @@ import org.springframework.web.servlet.view.groovy.GroovyMarkupConfigurer; @@ -137,6 +138,7 @@ import org.springframework.web.servlet.view.groovy.GroovyMarkupConfigurer;
import org.springframework.web.servlet.view.groovy.GroovyMarkupViewResolver;
import org.springframework.web.servlet.view.script.ScriptTemplateConfigurer;
import org.springframework.web.servlet.view.script.ScriptTemplateViewResolver;
import org.springframework.web.testfixture.servlet.MockHttpServletMapping;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import org.springframework.web.testfixture.servlet.MockRequestDispatcher;
@ -688,6 +690,7 @@ public class MvcNamespaceTests { @@ -688,6 +690,7 @@ public class MvcNamespaceTests {
request.setRequestURI("/myapp/app/");
request.setContextPath("/myapp");
request.setServletPath("/app/");
request.setHttpServletMapping(new MockHttpServletMapping("", "", "", MappingMatch.PATH));
chain = mapping2.getHandler(request);
assertThat(chain.getInterceptorList().size()).isEqualTo(4);
assertThat(chain.getInterceptorList().get(1) instanceof ConversionServiceExposingInterceptor).isTrue();

19
spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/DelegatingWebMvcConfigurationIntegrationTests.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2022 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.
@ -181,6 +181,7 @@ public class DelegatingWebMvcConfigurationIntegrationTests { @@ -181,6 +181,7 @@ public class DelegatingWebMvcConfigurationIntegrationTests {
this.context = webContext;
}
@Configuration
static class ViewControllerConfiguration implements WebMvcConfigurer {
@ -188,8 +189,16 @@ public class DelegatingWebMvcConfigurationIntegrationTests { @@ -188,8 +189,16 @@ public class DelegatingWebMvcConfigurationIntegrationTests {
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/test");
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// tests need to check the "mvcPathMatcher" and "mvcUrlPathHelper" instances
configurer.setPatternParser(null);
}
}
@Configuration
static class ResourceHandlerConfiguration implements WebMvcConfigurer {
@ -197,5 +206,13 @@ public class DelegatingWebMvcConfigurationIntegrationTests { @@ -197,5 +206,13 @@ public class DelegatingWebMvcConfigurationIntegrationTests {
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**");
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// tests need to check the "mvcPathMatcher" and "mvcUrlPathHelper" instances
configurer.setPatternParser(null);
}
}
}

9
spring-webmvc/src/test/java/org/springframework/web/servlet/handler/BeanNameUrlHandlerMappingTests.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2022 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.
@ -37,8 +37,6 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; @@ -37,8 +37,6 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
*/
public class BeanNameUrlHandlerMappingTests {
public static final String CONF = "/org/springframework/web/servlet/handler/map1.xml";
private ConfigurableWebApplicationContext wac;
@ -47,7 +45,7 @@ public class BeanNameUrlHandlerMappingTests { @@ -47,7 +45,7 @@ public class BeanNameUrlHandlerMappingTests {
MockServletContext sc = new MockServletContext("");
wac = new XmlWebApplicationContext();
wac.setServletContext(sc);
wac.setConfigLocations(new String[] {CONF});
wac.setConfigLocations("/org/springframework/web/servlet/handler/map1.xml");
wac.refresh();
}
@ -55,7 +53,7 @@ public class BeanNameUrlHandlerMappingTests { @@ -55,7 +53,7 @@ public class BeanNameUrlHandlerMappingTests {
public void requestsWithoutHandlers() throws Exception {
HandlerMapping hm = (HandlerMapping) wac.getBean("handlerMapping");
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/mypath/nonsense.html");
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/myapp/mypath/nonsense.html");
req.setContextPath("/myapp");
Object h = hm.getHandler(req);
assertThat(h == null).as("Handler is null").isTrue();
@ -121,6 +119,7 @@ public class BeanNameUrlHandlerMappingTests { @@ -121,6 +119,7 @@ public class BeanNameUrlHandlerMappingTests {
@Test
public void requestsWithFullPaths() throws Exception {
BeanNameUrlHandlerMapping hm = new BeanNameUrlHandlerMapping();
hm.setPatternParser(null); // the test targets AntPathPatcher-specific feature
hm.setAlwaysUseFullPath(true);
hm.setApplicationContext(wac);
Object bean = wac.getBean("godCtrl");

8
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/AbstractServletHandlerMethodTests.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2022 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.
@ -29,7 +29,6 @@ import org.springframework.web.servlet.DispatcherServlet; @@ -29,7 +29,6 @@ import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
import org.springframework.web.testfixture.servlet.MockServletConfig;
import org.springframework.web.util.pattern.PathPatternParser;
import static org.assertj.core.api.Assertions.assertThat;
@ -97,9 +96,8 @@ public abstract class AbstractServletHandlerMethodTests { @@ -97,9 +96,8 @@ public abstract class AbstractServletHandlerMethodTests {
}
BeanDefinition mappingDef = wac.getBeanDefinition("handlerMapping");
if (usePathPatterns && !mappingDef.hasAttribute("patternParser")) {
BeanDefinition parserDef = register("parser", PathPatternParser.class, wac);
mappingDef.getPropertyValues().add("patternParser", parserDef);
if (!usePathPatterns) {
mappingDef.getPropertyValues().add("patternParser", null);
}
register("handlerAdapter", RequestMappingHandlerAdapter.class, wac);

1
spring-webmvc/src/test/resources/org/springframework/web/servlet/handler/map3.xml

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
</bean>
<bean id="urlMapping2" class="org.springframework.web.servlet.handler.SimpleUrlHandlerMapping">
<property name="patternParser"><null/></property>
<property name="urlDecode"><value>true</value></property>
<property name="mappings"><ref bean="mappings"/></property>
</bean>

42
src/docs/asciidoc/web/webmvc.adoc

@ -580,8 +580,8 @@ initialization parameters (`init-param` elements) to the Servlet declaration in @@ -580,8 +580,8 @@ initialization parameters (`init-param` elements) to the Servlet declaration in
The Servlet API exposes the full request path as `requestURI` and further sub-divides it
into `contextPath`, `servletPath`, and `pathInfo` whose values vary depending on how a
Servlet is mapped. From these inputs, Spring MVC needs to determine the lookup path to
use for handler mapping, which is the path within the mapping of the `DispatcherServlet`
itself, excluding the `contextPath` and any `servletMapping` prefix, if present.
use for mapping handlers, which should exclude the `contextPath` and any `servletMapping`
prefix, if applicable.
The `servletPath` and `pathInfo` are decoded and that makes them impossible to compare
directly to the full `requestURI` in order to derive the lookupPath and that makes it
@ -611,15 +611,17 @@ encoded path which may not always work well. Furthermore, sometimes the @@ -611,15 +611,17 @@ encoded path which may not always work well. Furthermore, sometimes the
`DispatcherServlet` needs to share the URL space with another Servlet and may need to
be mapped by prefix.
The above issues can be addressed more comprehensively by switching from `PathMatcher` to
the parsed `PathPattern` available in 5.3 or higher, see
<<mvc-ann-requestmapping-pattern-comparison>>. Unlike `AntPathMatcher` which needs
either the lookup path decoded or the controller mapping encoded, a parsed `PathPattern`
matches to a parsed representation of the path called `RequestPath`, one path segment
at a time. This allows decoding and sanitizing path segment values individually without
the risk of altering the structure of the path. Parsed `PathPattern` also supports
the use of `servletPath` prefix mapping as long as the prefix is kept simple and does
not have any characters that need to be encoded.
The above issues are addressed when using `PathPatternParser` and parsed patterns, as
an alternative to String path matching with `AntPathMatcher`. The `PathPatternParser` has
been available for use in Spring MVC from version 5.3, and is enabled by default from
version 6.0. Unlike `AntPathMatcher` which needs either the lookup path decoded or the
controller mapping encoded, a parsed `PathPattern` matches to a parsed representation
of the path called `RequestPath`, one path segment at a time. This allows decoding and
sanitizing path segment values individually without the risk of altering the structure
of the path. Parsed `PathPattern` also supports the use of `servletPath` prefix mapping
as long as a Servlet path mapping is used and the prefix is kept simple, i.e. it has no
encoded characters. For pattern syntax details and comparison, see
<<mvc-ann-requestmapping-pattern-comparison>>.
@ -1617,11 +1619,11 @@ filesystem, and other locations. It is less efficient and the String path input @@ -1617,11 +1619,11 @@ filesystem, and other locations. It is less efficient and the String path input
challenge for dealing effectively with encoding and other issues with URLs.
`PathPattern` is the recommended solution for web applications and it is the only choice in
Spring WebFlux. Prior to version 5.3, `AntPathMatcher` was the only choice in Spring MVC
and continues to be the default. However `PathPattern` can be enabled in the
<<mvc-config-path-matching, MVC config>>.
Spring WebFlux. It was enabled for use in Spring MVC from version 5.3 and is enabled by
default from version 6.0. See <<mvc-config-path-matching, MVC config>> for
customizations of path matching options.
`PathPattern` supports the same pattern syntax as `AntPathMatcher`. In addition it also
`PathPattern` supports the same pattern syntax as `AntPathMatcher`. In addition, it also
supports the capturing pattern, e.g. `+{*spring}+`, for matching 0 or more path segments
at the end of a path. `PathPattern` also restricts the use of `+**+` for matching multiple
path segments such that it's only allowed at the end of a pattern. This eliminates many
@ -5997,9 +5999,7 @@ The following example shows how to customize path matching in Java configuration @@ -5997,9 +5999,7 @@ The following example shows how to customize path matching in Java configuration
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer
.setPatternParser(new PathPatternParser())
.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController.class));
configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController.class));
}
private PathPatternParser patternParser() {
@ -6015,9 +6015,7 @@ The following example shows how to customize path matching in Java configuration @@ -6015,9 +6015,7 @@ The following example shows how to customize path matching in Java configuration
class WebConfig : WebMvcConfigurer {
override fun configurePathMatch(configurer: PathMatchConfigurer) {
configurer
.setPatternParser(patternParser)
.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController::class.java))
configurer.addPathPrefix("/api", HandlerTypePredicate.forAnnotation(RestController::class.java))
}
fun patternParser(): PathPatternParser {
@ -6026,7 +6024,7 @@ The following example shows how to customize path matching in Java configuration @@ -6026,7 +6024,7 @@ The following example shows how to customize path matching in Java configuration
}
----
The following example shows how to achieve the same configuration in XML:
The following example shows how to customize path matching in XML configuration:
[source,xml,indent=0,subs="verbatim,quotes"]
----

Loading…
Cancel
Save