diff --git a/spring-web/src/main/java/org/springframework/web/cors/UrlBasedCorsConfigurationSource.java b/spring-web/src/main/java/org/springframework/web/cors/UrlBasedCorsConfigurationSource.java index 0d4eb11406..a5843aaf8e 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/UrlBasedCorsConfigurationSource.java +++ b/spring-web/src/main/java/org/springframework/web/cors/UrlBasedCorsConfigurationSource.java @@ -21,9 +21,9 @@ import java.util.LinkedHashMap; import java.util.Map; import javax.servlet.http.HttpServletRequest; -import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import org.springframework.util.PathMatcher; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; /** @@ -40,7 +40,7 @@ public class UrlBasedCorsConfigurationSource implements CorsConfigurationSource private final Map corsConfigurations = new LinkedHashMap<>(); - private PathMatcher pathMatcher = new AntPathMatcher(); + private PathMatcher pathMatcher = new ParsingPathMatcher(); private UrlPathHelper urlPathHelper = new UrlPathHelper(); diff --git a/spring-web/src/main/java/org/springframework/web/util/ParsingPathMatcher.java b/spring-web/src/main/java/org/springframework/web/util/ParsingPathMatcher.java new file mode 100644 index 0000000000..fff0d2af73 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/ParsingPathMatcher.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.web.util; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.util.PathMatcher; +import org.springframework.web.util.patterns.PathPattern; +import org.springframework.web.util.patterns.PathPatternParser; +import org.springframework.web.util.patterns.PatternComparatorConsideringPath; + + +/** + * + * @author Andy Clement + * @since 5.0 + */ +public class ParsingPathMatcher implements PathMatcher { + + Map cache = new HashMap<>(); + + PathPatternParser parser; + + public ParsingPathMatcher() { + parser = new PathPatternParser(); + } + + @Override + public boolean match(String pattern, String path) { + PathPattern p = getPathPattern(pattern); + return p.matches(path); + } + + @Override + public boolean matchStart(String pattern, String path) { + PathPattern p = getPathPattern(pattern); + return p.matchStart(path); + } + + @Override + public String extractPathWithinPattern(String pattern, String path) { + PathPattern p = getPathPattern(pattern); + return p.extractPathWithinPattern(path); + } + + @Override + public Map extractUriTemplateVariables(String pattern, String path) { + PathPattern p = getPathPattern(pattern); + return p.matchAndExtract(path); + } + + @Override + public String combine(String pattern1, String pattern2) { + PathPattern pathPattern = getPathPattern(pattern1); + return pathPattern.combine(pattern2); + } + + @Override + public Comparator getPatternComparator(String path) { + return new PathPatternStringComparatorConsideringPath(path); + } + + class PathPatternStringComparatorConsideringPath implements Comparator { + + PatternComparatorConsideringPath ppcp; + + public PathPatternStringComparatorConsideringPath(String path) { + ppcp = new PatternComparatorConsideringPath(path); + } + + @Override + public int compare(String o1, String o2) { + if (o1 == null) { + return (o2==null?0:+1); + } else if (o2 == null) { + return -1; + } + PathPattern p1 = getPathPattern(o1); + PathPattern p2 = getPathPattern(o2); + return ppcp.compare(p1,p2); + } + + } + + @Override + public boolean isPattern(String path) { + // TODO crude, should be smarter, lookup pattern and ask it + return (path.indexOf('*') != -1 || path.indexOf('?') != -1); + } + + private PathPattern getPathPattern(String pattern) { + PathPattern pathPattern = cache.get(pattern); + if (pathPattern == null) { + pathPattern = parser.parse(pattern); + cache.put(pattern, pathPattern); + } + return pathPattern; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/CaptureTheRestPathElement.java b/spring-web/src/main/java/org/springframework/web/util/patterns/CaptureTheRestPathElement.java new file mode 100644 index 0000000000..ff2076abb8 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/CaptureTheRestPathElement.java @@ -0,0 +1,77 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import org.springframework.web.util.patterns.PathPattern.MatchingContext; + +/** + * A path element representing capturing the rest of a path. In the pattern + * '/foo/{*foobar}' the /{*foobar} is represented as a {@link CaptureTheRestPathElement}. + * + * @author Andy Clement + */ +class CaptureTheRestPathElement extends PathElement { + + private String variableName; + + private char separator; + + /** + * @param pos + * @param captureDescriptor a character array containing contents like '{' '*' 'a' 'b' '}' + * @param separator the separator ahead of this construct + */ + CaptureTheRestPathElement(int pos, char[] captureDescriptor, char separator) { + super(pos); + variableName = new String(captureDescriptor, 2, captureDescriptor.length - 3); + this.separator = separator; + } + + @Override + public boolean matches(int candidateIndex, MatchingContext matchingContext) { + // No need to handle 'match start' checking as this captures everything + // anyway and cannot be followed by anything else + // assert next == null + while ((candidateIndex+1) matchingContext.candidateLength) { + return false; // not enough data, cannot be a match + } + if (caseSensitive) { + for (int i = 0; i < len; i++) { + if (matchingContext.candidate[candidateIndex++] != text[i]) { + return false; + } + } + } else { + for (int i = 0; i < len; i++) { + // TODO revisit performance if doing a lot of case insensitive matching + if (Character.toLowerCase(matchingContext.candidate[candidateIndex++]) != text[i]) { + return false; + } + } + } + if (next == null) { + return candidateIndex == matchingContext.candidateLength; + } else { + if (matchingContext.isMatchStartMatching && candidateIndex == matchingContext.candidateLength) { + return true; // no more data but everything matched so far + } + return next.matches(candidateIndex, matchingContext); + } + } + + @Override + public int getNormalizedLength() { + return len; + } + + public String toString() { + return "Literal(" + new String(text) + ")"; + } + +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PathElement.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PathElement.java new file mode 100644 index 0000000000..29fb5f492a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PathElement.java @@ -0,0 +1,88 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import org.springframework.web.util.patterns.PathPattern.MatchingContext; + +/** + * Common supertype for the Ast nodes created to represent a path pattern. + * + * @author Andy Clement + */ +abstract class PathElement { + + // Score related + protected static final int WILDCARD_WEIGHT = 100; + protected static final int CAPTURE_VARIABLE_WEIGHT = 1; + + /** + * Position in the pattern where this path element starts + */ + protected int pos; + + /** + * The next path element in the chain + */ + protected PathElement next; + + /** + * The previous path element in the chain + */ + protected PathElement prev; + + /** + * Create a new path element. + * @param pos the position where this path element starts in the pattern data + */ + PathElement(int pos) { + this.pos = pos; + } + + /** + * Attempt to match this path element. + * + * @param candidatePos the current position within the candidate path + * @param matchingContext encapsulates context for the match including the candidate + * @return true if matches, otherwise false + */ + public abstract boolean matches(int candidatePos, MatchingContext matchingContext); + + /** + * @return the length of the path element where captures are considered to be one character long + */ + public abstract int getNormalizedLength(); + + /** + * @return the number of variables captured by the path element + */ + public int getCaptureCount() { + return 0; + } + + /** + * @return the number of wildcard elements (*, ?) in the path element + */ + public int getWildcardCount() { + return 0; + } + + /** + * @return the score for this PathElement, combined score is used to compare parsed patterns. + */ + public int getScore() { + return 0; + } +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PathPattern.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PathPattern.java new file mode 100644 index 0000000000..65738470f9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PathPattern.java @@ -0,0 +1,462 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.util.PathMatcher; + +/** + * Represents a parsed path pattern. Includes a chain of path elements + * for fast matching and accumulates computed state for quick comparison of + * patterns. + * + * @author Andy Clement + */ +public class PathPattern implements Comparable { + + private final static Map NO_VARIABLES_MAP = Collections.emptyMap(); + + /** First path element in the parsed chain of path elements for this pattern */ + private PathElement head; + + /** The text of the parsed pattern */ + private String patternString; + + /** The separator used when parsing the pattern */ + private char separator; + + /** Will this match candidates in a case sensitive way? (case sensitivity at parse time) */ + private boolean caseSensitive; + + /** How many variables are captured in this pattern */ + private int capturedVariableCount; + + /** + * The normalized length is trying to measure the 'active' part of the pattern. It is computed + * by assuming all captured variables have a normalized length of 1. Effectively this means changing + * your variable name lengths isn't going to change the length of the active part of the pattern. + * Useful when comparing two patterns. + */ + int normalizedLength; + + /** + * Does the pattern end with '<separator>*' + */ + boolean endsWithSeparatorWildcard = false; + + /** + * Score is used to quickly compare patterns. Different pattern components are given different + * weights. A 'lower score' is more specific. Current weights: + *
    + *
  • Captured variables are worth 1 + *
  • Wildcard is worth 100 + *
+ */ + private int score; + + /** Does the pattern end with {*...} */ + private boolean isCatchAll = false; + + public PathPattern(String patternText, PathElement head, char separator, boolean caseSensitive) { + this.head = head; + this.patternString = patternText; + this.separator = separator; + this.caseSensitive = caseSensitive; + // Compute fields for fast comparison + PathElement s = head; + while (s != null) { + this.capturedVariableCount += s.getCaptureCount(); + this.normalizedLength += s.getNormalizedLength(); + this.score += s.getScore(); + if (s instanceof CaptureTheRestPathElement || s instanceof WildcardTheRestPathElement) { + this.isCatchAll = true; + } + if (s instanceof SeparatorPathElement && s.next!=null && s.next instanceof WildcardPathElement && s.next.next == null) { + this.endsWithSeparatorWildcard=true; + } + s = s.next; + } + } + + /** + * @param path the candidate path to attempt to match against this pattern + * @return true if the path matches this pattern + */ + public boolean matches(String path) { + if (head == null) { + return (path == null) || (path.length() == 0); + } else if (path == null || path.length() == 0) { + if (head instanceof WildcardTheRestPathElement || head instanceof CaptureTheRestPathElement) { + path = ""; // Will allow CaptureTheRest to bind the variable to empty + } else { + return false; + } + } + MatchingContext matchingContext = new MatchingContext(path,false); + return head.matches(0, matchingContext); + } + + /** + * @param path the path to check against the pattern + * @return true if the pattern matches as much of the path as is supplied + */ + public boolean matchStart(String path) { + if (head == null) { + return (path==null || path.length() == 0); + } else if (path == null || path.length() == 0) { + return true; + } + MatchingContext matchingContext = new MatchingContext(path,false); + matchingContext.setMatchStartMatching(true); + return head.matches(0, matchingContext); + } + + /** + * @param path a path to match against this pattern + * @return a map of extracted variables - an empty map if no variables extracted. + */ + public Map matchAndExtract(String path) { + MatchingContext matchingContext = new MatchingContext(path,true); + if (head != null && head.matches(0, matchingContext)) { + return matchingContext.getExtractedVariables(); + } else { + if (path== null || path.length()==0) { + return NO_VARIABLES_MAP; + } else { + throw new IllegalStateException("Pattern \"" + this.toString() + "\" is not a match for \"" + path + "\""); + } + } + } + + /** + * @return the original pattern string that was parsed to create this PathPattern + */ + public String getPatternString() { + return patternString; + } + + public PathElement getHeadSection() { + return head; + } + + /** + * Given a full path, determine the pattern-mapped part.

For example:

    + *
  • '{@code /docs/cvs/commit.html}' and '{@code /docs/cvs/commit.html} -> ''
  • + *
  • '{@code /docs/*}' and '{@code /docs/cvs/commit} -> '{@code cvs/commit}'
  • + *
  • '{@code /docs/cvs/*.html}' and '{@code /docs/cvs/commit.html} -> '{@code commit.html}'
  • + *
  • '{@code /docs/**}' and '{@code /docs/cvs/commit} -> '{@code cvs/commit}'
  • + *
+ *

Note: Assumes that {@link #matches} returns {@code true} for '{@code pattern}' and '{@code path}', but + * does not enforce this. As per the contract on {@link PathMatcher}, this + * method will trim leading/trailing separators. It will also remove duplicate separators in + * the returned path. + * @param path a path that matches this pattern + * @return the subset of the path that is matched by pattern or "" if none of it is matched by pattern elements + */ + public String extractPathWithinPattern(String path) { + // assert this.matches(path) + PathElement s = head; + int separatorCount = 0; + // Find first path element that is pattern based + while (s != null) { + if (s instanceof SeparatorPathElement || s instanceof CaptureTheRestPathElement || s instanceof WildcardTheRestPathElement) { + separatorCount++; + } + if (s.getWildcardCount()!=0 || s.getCaptureCount()!=0) { + break; + } + s = s.next; + } + if (s == null) { + return ""; // There is no pattern mapped section + } + // Now separatorCount indicates how many sections of the path to skip + char[] pathChars = path.toCharArray(); + int len = pathChars.length; + int pos = 0; + while (separatorCount > 0 && pos < len) { + if (path.charAt(pos++) == separator) { + // Skip any adjacent separators + while (path.charAt(pos) == separator) { + pos++; + } + separatorCount--; + } + } + int end = len; + // Trim trailing separators + while (path.charAt(end-1) == separator) { + end--; + } + // Check if multiple separators embedded in the resulting path, if so trim them out. + // Example: aaa////bbb//ccc/d -> aaa/bbb/ccc/d + // The stringWithDuplicateSeparatorsRemoved is only computed if necessary + int c = pos; + StringBuilder stringWithDuplicateSeparatorsRemoved = null; + while (c extractedVariables; + + public boolean extractingVariables; + + public MatchingContext(String path, boolean extractVariables) { + candidate = path.toCharArray(); + candidateLength = candidate.length; + this.extractingVariables = extractVariables; + } + + public void setMatchStartMatching(boolean b) { + isMatchStartMatching = b; + } + + public void set(String key, String value) { + if (this.extractedVariables == null) { + extractedVariables = new HashMap<>(); + } + extractedVariables.put(key, value); + } + + public Map getExtractedVariables() { + if (this.extractedVariables == null) { + return NO_VARIABLES_MAP; + } else { + return this.extractedVariables; + } + } + + /** + * Scan ahead from the specified position for either the next separator + * character or the end of the candidate. + * + * @param pos the starting position for the scan + * @return the position of the next separator or the end of the candidate + */ + public int scanAhead(int pos) { + while (pos < candidateLength) { + if (candidate[pos] == separator) { + return pos; + } + pos++; + } + return candidateLength; + } + } + + /** + * Combine this pattern with another. Currently does not produce a new PathPattern, just produces a new string. + */ + public String combine(String pattern2string) { + // If one of them is empty the result is the other. If both empty the result is "" + if (patternString == null || patternString.length()==0) { + if (pattern2string == null || pattern2string.length()==0) { + return ""; + } else { + return pattern2string; + } + } else if (pattern2string == null || pattern2string.length()==0) { + return patternString; + } + + // /* + /hotel => /hotel + // /*.* + /*.html => /*.html + // However: + // /usr + /user => /usr/user + // /{foo} + /bar => /{foo}/bar + if (!patternString.equals(pattern2string) && capturedVariableCount==0 && matches(pattern2string)) { + return pattern2string; + } + + // /hotels/* + /booking => /hotels/booking + // /hotels/* + booking => /hotels/booking + if (endsWithSeparatorWildcard) { + return concat(patternString.substring(0,patternString.length()-2), pattern2string); + } + + // /hotels + /booking => /hotels/booking + // /hotels + booking => /hotels/booking + int starDotPos1 = patternString.indexOf("*."); // Are there any file prefix/suffix things to consider? + if (capturedVariableCount!=0 || starDotPos1 == -1 || separator=='.') { + return concat(patternString, pattern2string); + } + + // /*.html + /hotel => /hotel.html + // /*.html + /hotel.* => /hotel.html + String firstExtension = patternString.substring(starDotPos1+1); // looking for the first extension + int dotPos2 = pattern2string.indexOf('.'); + String file2 = (dotPos2==-1?pattern2string:pattern2string.substring(0,dotPos2)); + String secondExtension = (dotPos2 == -1?"":pattern2string.substring(dotPos2)); + boolean firstExtensionWild = (firstExtension.equals(".*") || firstExtension.equals("")); + boolean secondExtensionWild = (secondExtension.equals(".*") || secondExtension.equals("")); + if (!firstExtensionWild && !secondExtensionWild) { + throw new IllegalArgumentException("Cannot combine patterns: " + patternString + " and " + pattern2string); + } + return file2 + (firstExtensionWild?secondExtension:firstExtension); + } + + /** + * Join two paths together including a separator if necessary. Extraneous separators are removed (if the first path + * ends with one and the second path starts with one). + * @param path1 First path + * @param path2 Second path + * @return joined path that may include separator if necessary + */ + private String concat(String path1, String path2) { + boolean path1EndsWithSeparator = path1.charAt(path1.length()-1)==separator; + boolean path2StartsWithSeparator = path2.charAt(0)==separator; + if (path1EndsWithSeparator && path2StartsWithSeparator) { + return path1 + path2.substring(1); + } + else if (path1EndsWithSeparator || path2StartsWithSeparator) { + return path1 + path2; + } + else { + return path1 + separator + path2; + } + } + +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PathPatternComparator.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PathPatternComparator.java new file mode 100644 index 0000000000..167774dba1 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PathPatternComparator.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.util.Comparator; + +/** + * Basic PathPattern comparator. + * + * @author Andy Clement + */ +public class PathPatternComparator implements Comparator { + + @Override + public int compare(PathPattern o1, PathPattern o2) { + // Nulls get sorted to the end + if (o1 == null) { + return (o2==null?0:+1); + } else if (o2 == null) { + return -1; + } + return o1.compareTo(o2); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PathPatternParser.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PathPatternParser.java new file mode 100644 index 0000000000..31317e267a --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PathPatternParser.java @@ -0,0 +1,390 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.PatternSyntaxException; + +/** + * Parser for URI template patterns. It breaks the path pattern into a number of + * path elements in a linked list. + * + * @author Andy Clement + */ +public class PathPatternParser { + + public final static char DEFAULT_SEPARATOR = '/'; + + // The expected path separator to split path elements during parsing + char separator = DEFAULT_SEPARATOR; + + // Is the parser producing case sensitive PathPattern matchers + boolean caseSensitive = true; + + // The input data for parsing + private char[] pathPatternData; + + // The length of the input data + private int pathPatternLength; + + // Current parsing position + int pos; + + // How many ? characters in a particular path element + private int singleCharWildcardCount; + + // Is the path pattern using * characters in a particular path element + private boolean wildcard = false; + + // Is the construct {*...} being used in a particular path element + private boolean isCaptureTheRestVariable = false; + + // Has the parser entered a {...} variable capture block in a particular + // path element + private boolean insideVariableCapture = false; + + // How many variable captures are occurring in a particular path element + private int variableCaptureCount = 0; + + // Start of the most recent path element in a particular path element + int pathElementStart; + + // Start of the most recent variable capture in a particular path element + int variableCaptureStart; + + // Variables captures in this path pattern + List capturedVariableNames; + + // The head of the path element chain currently being built + PathElement headPE; + + // The most recently constructed path element in the chain + PathElement currentPE; + + /** + * Default constructor, will use the default path separator to identify + * the elements of the path pattern. + */ + public PathPatternParser() { + } + + /** + * Create a PatternParser that will use the specified separator instead of + * the default. + * + * @param separator the path separator to look for when parsing. + */ + public PathPatternParser(char separator) { + this.separator = separator; + } + + public void setCaseSensitive(boolean caseSensitive) { + this.caseSensitive = caseSensitive; + } + + /** + * Process the path pattern data, a character at a time, breaking it into + * path elements around separator boundaries and verifying the structure at each + * stage. Produces a PathPattern object that can be used for fast matching + * against paths. + * + * @param pathPattern the input path pattern, e.g. /foo/{bar} + * @return a PathPattern for quickly matching paths against the specified path pattern + */ + public PathPattern parse(String pathPattern) { + if (pathPattern == null) { + pathPattern = ""; + } +// int starstar = pathPattern.indexOf("**"); +// if (starstar!=-1 && starstar!=pathPattern.length()-2) { +// throw new IllegalStateException("Not allowed ** unless at end of pattern: "+pathPattern); +// } + pathPatternData = pathPattern.toCharArray(); + pathPatternLength = pathPatternData.length; + headPE = null; + currentPE = null; + capturedVariableNames = null; + pathElementStart = -1; + pos = 0; + resetPathElementState(); + while (pos < pathPatternLength) { + char ch = pathPatternData[pos]; + if (ch == separator) { + if (pathElementStart != -1) { + pushPathElement(createPathElement()); + } + // Skip over multiple separators + while ((pos+1) < pathPatternLength && pathPatternData[pos+1] == separator) { + pos++; + } + if (peekDoubleWildcard()) { + pushPathElement(new WildcardTheRestPathElement(pos,separator)); + pos+=2; + } else { + pushPathElement(new SeparatorPathElement(pos, separator)); + } + } else { + if (pathElementStart == -1) { + pathElementStart = pos; + } + if (ch == '?') { + singleCharWildcardCount++; + } else if (ch == '{') { + if (insideVariableCapture) { + throw new PatternParseException(pos, pathPatternData, PatternMessage.ILLEGAL_NESTED_CAPTURE); + // If we enforced that adjacent captures weren't allowed, this would do it (this would be an error: /foo/{bar}{boo}/) +// } else if (pos > 0 && pathPatternData[pos - 1] == '}') { +// throw new PatternParseException(pos, pathPatternData, +// PatternMessage.CANNOT_HAVE_ADJACENT_CAPTURES); + } + insideVariableCapture = true; + variableCaptureStart = pos; + } else if (ch == '}') { + if (!insideVariableCapture) { + throw new PatternParseException(pos, pathPatternData, PatternMessage.MISSING_OPEN_CAPTURE); + } + insideVariableCapture = false; + if (isCaptureTheRestVariable && (pos + 1) < pathPatternLength) { + throw new PatternParseException(pos + 1, pathPatternData, + PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + } + variableCaptureCount++; + } else if (ch == ':') { + if (insideVariableCapture) { + skipCaptureRegex(); + insideVariableCapture = false; + variableCaptureCount++; + } + } else if (ch == '*') { + if (insideVariableCapture) { + if (variableCaptureStart == pos - 1) { + isCaptureTheRestVariable = true; + } + } + wildcard = true; + } + // Check that the characters used for captured variable names are like java identifiers + if (insideVariableCapture) { + if ((variableCaptureStart + 1 + (isCaptureTheRestVariable ? 1 : 0)) == pos + && !Character.isJavaIdentifierStart(ch)) { + throw new PatternParseException(pos, pathPatternData, + PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, + Character.toString(ch)); + + } else if ((pos > (variableCaptureStart + 1 + (isCaptureTheRestVariable ? 1 : 0)) + && !Character.isJavaIdentifierPart(ch))) { + throw new PatternParseException(pos, pathPatternData, + PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR, Character.toString(ch)); + } + } + } + pos++; + } + if (pathElementStart != -1) { + pushPathElement(createPathElement()); + } + return new PathPattern(pathPattern, headPE, separator, caseSensitive); + } + + /** + * Just hit a ':' and want to jump over the regex specification for this + * variable. pos will be pointing at the ':', we want to skip until the }. + *

+ * Nested {...} pairs don't have to be escaped: /abc/{var:x{1,2}}/def + *

An escaped } will not be treated as the end of the regex: /abc/{var:x\\{y:}/def + *

A separator that should not indicate the end of the regex can be escaped: + */ + private void skipCaptureRegex() { + pos++; + int regexStart = pos; + int curlyBracketDepth = 0; // how deep in nested {...} pairs + boolean previousBackslash = false; + while (pos < pathPatternLength) { + char ch = pathPatternData[pos]; + if (ch == '\\' && !previousBackslash) { + pos++; + previousBackslash = true; + continue; + } + if (ch == '{' && !previousBackslash) { + curlyBracketDepth++; + } else if (ch == '}' && !previousBackslash) { + if (curlyBracketDepth == 0) { + if (regexStart == pos) { + throw new PatternParseException(regexStart, pathPatternData, + PatternMessage.MISSING_REGEX_CONSTRAINT); + } + return; + } + curlyBracketDepth--; + } + if (ch == separator && !previousBackslash) { + throw new PatternParseException(pos, pathPatternData, PatternMessage.MISSING_CLOSE_CAPTURE); + } + pos++; + previousBackslash=false; + } + throw new PatternParseException(pos - 1, pathPatternData, PatternMessage.MISSING_CLOSE_CAPTURE); + } + + /** + * After processing a separator, a quick peek whether it is followed by ** + * (and only ** before the end of the pattern or the next separator) + */ + private boolean peekDoubleWildcard() { + if ((pos + 2) >= pathPatternLength) { + return false; + } + if (pathPatternData[pos + 1] != '*' || pathPatternData[pos + 2] != '*') { + return false; + } + return (pos + 3 == pathPatternLength); + } + + /** + * @param newPathElement the new path element to add to the chain being built + */ + private void pushPathElement(PathElement newPathElement) { + if (newPathElement instanceof CaptureTheRestPathElement) { + // There must be a separator ahead of this thing + // currentPE SHOULD be a SeparatorPathElement + if (currentPE == null) { + headPE = newPathElement; + currentPE = newPathElement; + } else if (currentPE instanceof SeparatorPathElement) { + PathElement peBeforeSeparator = currentPE.prev; + if (peBeforeSeparator == null) { + // /{*foobar} is at the start + headPE = newPathElement; + newPathElement.prev = peBeforeSeparator; + } else { + peBeforeSeparator.next = newPathElement; + newPathElement.prev = peBeforeSeparator; + } + currentPE = newPathElement; + } else { + throw new IllegalStateException("Expected SeparatorPathElement but was "+currentPE); + } + } else { + if (headPE == null) { + headPE = newPathElement; + currentPE = newPathElement; + } else { + currentPE.next = newPathElement; + newPathElement.prev = currentPE; + currentPE = newPathElement; + } + } + resetPathElementState(); + } + + /** + * Used the knowledge built up whilst processing since the last path element to determine what kind of path + * element to create. + * @return the new path element + */ + private PathElement createPathElement() { + if (insideVariableCapture) { + throw new PatternParseException(pos, pathPatternData, PatternMessage.MISSING_CLOSE_CAPTURE); + } + char[] pathElementText = new char[pos - pathElementStart]; + System.arraycopy(pathPatternData, pathElementStart, pathElementText, 0, pos - pathElementStart); + PathElement newPE = null; + if (variableCaptureCount > 0) { + if (variableCaptureCount == 1 && pathElementStart == variableCaptureStart && pathPatternData[pos - 1] == '}') { + if (isCaptureTheRestVariable) { + // It is {*....} + newPE = new CaptureTheRestPathElement(pathElementStart, pathElementText, separator); + } else { + // It is a full capture of this element (possibly with constraint), for example: /foo/{abc}/ + try { + newPE = new CaptureVariablePathElement(pathElementStart, pathElementText, caseSensitive); + } catch (PatternSyntaxException pse) { + throw new PatternParseException(pse, findRegexStart(pathPatternData,pathElementStart)+pse.getIndex(), pathPatternData, PatternMessage.JDK_PATTERN_SYNTAX_EXCEPTION); + } + recordCapturedVariable(pathElementStart, ((CaptureVariablePathElement) newPE).getVariableName()); + } + } else { + if (isCaptureTheRestVariable) { + throw new PatternParseException(pathElementStart, pathPatternData, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); + } + RegexPathElement newRegexSection = new RegexPathElement(pathElementStart, pathElementText, caseSensitive, pathPatternData); + for (String variableName : newRegexSection.getVariableNames()) { + recordCapturedVariable(pathElementStart, variableName); + } + newPE = newRegexSection; + } + } else { + if (wildcard) { + if (pos - 1 == pathElementStart) { + newPE = new WildcardPathElement(pathElementStart); + } else { + newPE = new RegexPathElement(pathElementStart, pathElementText, caseSensitive, pathPatternData); + } + } else if (singleCharWildcardCount!=0) { + newPE = new SingleCharWildcardedPathElement(pathElementStart, pathElementText, singleCharWildcardCount, caseSensitive); + } else { + newPE = new LiteralPathElement(pathElementStart, pathElementText, caseSensitive); + } + } + return newPE; + } + + /** + * For a path element representing a captured variable, locate the constraint pattern. + * Assumes there is a constraint pattern. + * @param data a complete path expression, e.g. /aaa/bbb/{ccc:...} + * @param offset the start of the capture pattern of interest + * @return the index of the character after the ':' within the pattern expression relative to the start of the whole expression + */ + private int findRegexStart(char[] data, int offset) { + int pos = offset; + while (pos(); + } + if (capturedVariableNames.contains(variableName)) { + throw new PatternParseException(pos, this.pathPatternData, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, variableName); + } + capturedVariableNames.add(variableName); + } +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PatternComparatorConsideringPath.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PatternComparatorConsideringPath.java new file mode 100644 index 0000000000..06fb69251c --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PatternComparatorConsideringPath.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.util.Comparator; + +/** + * Similar to {@link PathPatternComparator} but this takes account of a specified path and + * sorts anything that exactly matches it to be first. + * + * @author Andy Clement + */ +public class PatternComparatorConsideringPath implements Comparator { + + private String path; + + public PatternComparatorConsideringPath(String path) { + this.path = path; + } + + @Override + public int compare(PathPattern o1, PathPattern o2) { + // Nulls get sorted to the end + if (o1 == null) { + return (o2==null?0:+1); + } else if (o2 == null) { + return -1; + } + if (o1.getPatternString().equals(path)) { + return (o2.getPatternString().equals(path))?0:-1; + } else if (o2.getPatternString().equals(path)) { + return +1; + } + return o1.compareTo(o2); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PatternMessage.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PatternMessage.java new file mode 100644 index 0000000000..a3b8836533 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PatternMessage.java @@ -0,0 +1,52 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.text.MessageFormat; + +/** + * The messages that can be included in a {@link PatternParseException} when there is a parse failure. + * + * @author Andy Clement + */ +public enum PatternMessage { + + // @formatter:off + MISSING_CLOSE_CAPTURE("Expected close capture character after variable name '}'"), + MISSING_OPEN_CAPTURE("Missing preceeding open capture character before variable name'{'"), + ILLEGAL_NESTED_CAPTURE("Not allowed to nest variable captures"), + CANNOT_HAVE_ADJACENT_CAPTURES("Adjacent captures are not allowed"), + ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR("Character ''{0}'' is not allowed at start of captured variable name"), + ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR("Character ''{0}'' is not allowed in a captured variable name"), + NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST("No more pattern data allowed after '{*...}' pattern element"), + BADLY_FORMED_CAPTURE_THE_REST("Expected form when capturing the rest of the path is simply '{*...}'"), + MISSING_REGEX_CONSTRAINT("Missing regex constraint on capture"), + ILLEGAL_DOUBLE_CAPTURE("Not allowed to capture ''{0}'' twice in the same pattern"), + JDK_PATTERN_SYNTAX_EXCEPTION("Exception occurred in pattern compilation"), + CAPTURE_ALL_IS_STANDALONE_CONSTRUCT("'{*...}' can only be preceeded by a path separator"); + // @formatter:on + + private final String message; + + private PatternMessage(String message) { + this.message = message; + } + + public String formatMessage(Object... inserts) { + return MessageFormat.format(this.message, inserts); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/PatternParseException.java b/spring-web/src/main/java/org/springframework/web/util/patterns/PatternParseException.java new file mode 100644 index 0000000000..7e40476c04 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/PatternParseException.java @@ -0,0 +1,86 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +/** + * Exception that is thrown when there is a problem with the pattern being parsed. + * + * @author Andy Clement + */ +public class PatternParseException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private int pos; + + private char[] patternText; + + private final PatternMessage message; + + private final Object[] inserts; + + public PatternParseException(int pos, char[] patternText, PatternMessage message, Object... inserts) { + super(message.formatMessage(inserts)); + this.pos = pos; + this.patternText = patternText; + this.message = message; + this.inserts = inserts; + } + + public PatternParseException(Throwable cause, int pos, char[] patternText, PatternMessage message, Object... inserts) { + super(message.formatMessage(inserts),cause); + this.pos = pos; + this.patternText = patternText; + this.message = message; + this.inserts = inserts; + } + + /** + * @return a formatted message with inserts applied + */ + @Override + public String getMessage() { + return this.message.formatMessage(this.inserts); + } + + /** + * @return a detailed message that includes the original pattern text with a pointer to the error position, + * as well as the error message. + */ + public String toDetailedString() { + StringBuilder buf = new StringBuilder(); + buf.append(patternText).append('\n'); + for (int i = 0; i < pos; i++) { + buf.append(' '); + } + buf.append("^\n"); + buf.append(getMessage()); + return buf.toString(); + } + + public Object[] getInserts() { + return this.inserts; + } + + public int getPosition() { + return pos; + } + + public PatternMessage getMessageType() { + return message; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/RegexPathElement.java b/spring-web/src/main/java/org/springframework/web/util/patterns/RegexPathElement.java new file mode 100644 index 0000000000..a164718905 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/RegexPathElement.java @@ -0,0 +1,174 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Matcher; + +import org.springframework.util.AntPathMatcher; +import org.springframework.web.util.patterns.PathPattern.MatchingContext; + +/** + * A regex path element. Used to represent any complicated element of the path. + * For example in '/foo/*_*/*_{foobar}' both *_* and *_{foobar} + * are {@link RegexPathElement} path elements. Derived from the general {@link AntPathMatcher} approach. + * + * @author Andy Clement + */ +class RegexPathElement extends PathElement { + + private final java.util.regex.Pattern GLOB_PATTERN = java.util.regex.Pattern + .compile("\\?|\\*|\\{((?:\\{[^/]+?\\}|[^/{}]|\\\\[{}])+?)\\}"); + + private final String DEFAULT_VARIABLE_PATTERN = "(.*)"; + + private final List variableNames = new LinkedList<>(); + + private char[] regex; + + private java.util.regex.Pattern pattern; + + private boolean caseSensitive; + + private int wildcardCount; + + RegexPathElement(int pos, char[] regex, boolean caseSensitive, char[] completePattern) { + super(pos); + this.regex = regex; + this.caseSensitive = caseSensitive; + buildPattern(regex, completePattern); + } + + public void buildPattern(char[] regex, char[] completePattern) { + StringBuilder patternBuilder = new StringBuilder(); + String text = new String(regex); + Matcher matcher = GLOB_PATTERN.matcher(text); + int end = 0; + while (matcher.find()) { + patternBuilder.append(quote(text, end, matcher.start())); + String match = matcher.group(); + if ("?".equals(match)) { + patternBuilder.append('.'); + } else if ("*".equals(match)) { + patternBuilder.append(".*"); + wildcardCount++; + } else if (match.startsWith("{") && match.endsWith("}")) { + int colonIdx = match.indexOf(':'); + if (colonIdx == -1) { + patternBuilder.append(DEFAULT_VARIABLE_PATTERN); + String variableName = matcher.group(1); + if (variableNames.contains(variableName)) { + throw new PatternParseException(pos, completePattern, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, + variableName); + } + this.variableNames.add(variableName); + } else { + String variablePattern = match.substring(colonIdx + 1, match.length() - 1); + patternBuilder.append('('); + patternBuilder.append(variablePattern); + patternBuilder.append(')'); + String variableName = match.substring(1, colonIdx); + if (variableNames.contains(variableName)) { + throw new PatternParseException(pos, completePattern, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, + variableName); + } + this.variableNames.add(variableName); + } + } + end = matcher.end(); + } + patternBuilder.append(quote(text, end, text.length())); + if (caseSensitive) { + pattern = java.util.regex.Pattern.compile(patternBuilder.toString()); + } else { + pattern = java.util.regex.Pattern.compile(patternBuilder.toString(), + java.util.regex.Pattern.CASE_INSENSITIVE); + } + } + + public List getVariableNames() { + return variableNames; + } + + private String quote(String s, int start, int end) { + if (start == end) { + return ""; + } + return java.util.regex.Pattern.quote(s.substring(start, end)); + } + + @Override + public boolean matches(int candidateIndex, MatchingContext matchingContext) { + int p = matchingContext.scanAhead(candidateIndex); + Matcher m = pattern.matcher(new SubSequence(matchingContext.candidate, candidateIndex, p)); + boolean matches = m.matches(); + if (matches) { + if (next == null) { + // No more pattern, is there more data? + matches = (p == matchingContext.candidateLength); + } else { + if (matchingContext.isMatchStartMatching && p == matchingContext.candidateLength) { + return true; // no more data but matches up to this point + } + matches = next.matches(p, matchingContext); + } + } + if (matches && matchingContext.extractingVariables) { + // Process captures + if (this.variableNames.size() != m.groupCount()) { // SPR-8455 + throw new IllegalArgumentException("The number of capturing groups in the pattern segment " + + this.pattern + " does not match the number of URI template variables it defines, " + + "which can occur if capturing groups are used in a URI template regex. " + + "Use non-capturing groups instead."); + } + for (int i = 1; i <= m.groupCount(); i++) { + String name = this.variableNames.get(i - 1); + String value = m.group(i); + matchingContext.set(name, value); + } + } + return matches; + } + + public String toString() { + return "Regex(" + new String(regex) + ")"; + } + + @Override + public int getNormalizedLength() { + int varsLength = 0; + for (String variableName : variableNames) { + varsLength += variableName.length(); + } + return regex.length - varsLength - variableNames.size(); + } + + public int getCaptureCount() { + return variableNames.size(); + } + + @Override + public int getWildcardCount() { + return wildcardCount; + } + + @Override + public int getScore() { + return getCaptureCount()*CAPTURE_VARIABLE_WEIGHT + getWildcardCount()*WILDCARD_WEIGHT; + } + +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/util/patterns/SeparatorPathElement.java b/spring-web/src/main/java/org/springframework/web/util/patterns/SeparatorPathElement.java new file mode 100644 index 0000000000..d85a1cbb4e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/util/patterns/SeparatorPathElement.java @@ -0,0 +1,74 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import org.springframework.web.util.patterns.PathPattern.MatchingContext; + +/** + * A separator path element. In the pattern '/foo/bar' the two occurrences + * of '/' will be represented by a SeparatorPathElement (if the default + * separator of '/' is being used). + * + * @author Andy Clement + */ +class SeparatorPathElement extends PathElement { + + private char separator; + + SeparatorPathElement(int pos, char separator) { + super(pos); + this.separator = separator; + } + + /** + * Matching a separator is easy, basically the character at candidateIndex + * must be the separator. + */ + @Override + public boolean matches(int candidateIndex, MatchingContext matchingContext) { + boolean matched = false; + if (candidateIndex < matchingContext.candidateLength) { + if (matchingContext.candidate[candidateIndex] == separator) { + // Skip further separators in the path (they are all 'matched' + // by a single SeparatorPathElement) + while ((candidateIndex+1) extracted = checkCapture("/abc","/abc"); + assertEquals(0,extracted.size()); + } + + @Test + public void extractUriTemplateVariablesRegex() { + PathPatternParser pp = new PathPatternParser(); + PathPattern p = null; + + p = pp.parse("{symbolicName:[\\w\\.]+}-{version:[\\w\\.]+}.jar"); + Map result = p.matchAndExtract("com.example-1.0.0.jar"); + assertEquals("com.example", result.get("symbolicName")); + assertEquals("1.0.0", result.get("version")); + + p = pp.parse("{symbolicName:[\\w\\.]+}-sources-{version:[\\w\\.]+}.jar"); + result = p.matchAndExtract("com.example-sources-1.0.0.jar"); + assertEquals("com.example", result.get("symbolicName")); + assertEquals("1.0.0", result.get("version")); + } + + @Test + public void extractUriTemplateVarsRegexQualifiers() { + PathPatternParser pp = new PathPatternParser(); + + PathPattern p = pp.parse("{symbolicName:[\\p{L}\\.]+}-sources-{version:[\\p{N}\\.]+}.jar"); + Map result = p.matchAndExtract("com.example-sources-1.0.0.jar"); + assertEquals("com.example", result.get("symbolicName")); + assertEquals("1.0.0", result.get("version")); + + p = pp.parse("{symbolicName:[\\w\\.]+}-sources-{version:[\\d\\.]+}-{year:\\d{4}}{month:\\d{2}}{day:\\d{2}}.jar"); + result = p.matchAndExtract("com.example-sources-1.0.0-20100220.jar"); + assertEquals("com.example", result.get("symbolicName")); + assertEquals("1.0.0", result.get("version")); + assertEquals("2010", result.get("year")); + assertEquals("02", result.get("month")); + assertEquals("20", result.get("day")); + + p = pp.parse("{symbolicName:[\\p{L}\\.]+}-sources-{version:[\\p{N}\\.\\{\\}]+}.jar"); + result = p.matchAndExtract("com.example-sources-1.0.0.{12}.jar"); + assertEquals("com.example", result.get("symbolicName")); + assertEquals("1.0.0.{12}", result.get("version")); + } + + @Test + public void extractUriTemplateVarsRegexCapturingGroups() { + PathPatternParser pp = new PathPatternParser(); + PathPattern pathMatcher = pp.parse("/web/{id:foo(bar)?}_{goo}"); + exception.expect(IllegalArgumentException.class); + exception.expectMessage(containsString("The number of capturing groups in the pattern")); + pathMatcher.matchAndExtract("/web/foobar_goo"); + } + + @Rule + public final ExpectedException exception = ExpectedException.none(); + + @Test + public void combine() { + TestPathCombiner pathMatcher = new TestPathCombiner(); + assertEquals("", pathMatcher.combine(null, null)); + assertEquals("/hotels", pathMatcher.combine("/hotels", null)); + assertEquals("/hotels", pathMatcher.combine(null, "/hotels")); + assertEquals("/hotels/booking", pathMatcher.combine("/hotels/*", "booking")); + assertEquals("/hotels/booking", pathMatcher.combine("/hotels/*", "/booking")); + assertEquals("/hotels/**/booking", pathMatcher.combine("/hotels/**", "booking")); + assertEquals("/hotels/**/booking", pathMatcher.combine("/hotels/**", "/booking")); + assertEquals("/hotels/booking", pathMatcher.combine("/hotels", "/booking")); + assertEquals("/hotels/booking", pathMatcher.combine("/hotels", "booking")); + assertEquals("/hotels/booking", pathMatcher.combine("/hotels/", "booking")); + assertEquals("/hotels/{hotel}", pathMatcher.combine("/hotels/*", "{hotel}")); + assertEquals("/hotels/**/{hotel}", pathMatcher.combine("/hotels/**", "{hotel}")); + assertEquals("/hotels/{hotel}", pathMatcher.combine("/hotels", "{hotel}")); + assertEquals("/hotels/{hotel}.*", pathMatcher.combine("/hotels", "{hotel}.*")); + assertEquals("/hotels/*/booking/{booking}", + pathMatcher.combine("/hotels/*/booking", "{booking}")); + assertEquals("/hotel.html", pathMatcher.combine("/*.html", "/hotel.html")); + assertEquals("/hotel.html", pathMatcher.combine("/*.html", "/hotel")); + assertEquals("/hotel.html", pathMatcher.combine("/*.html", "/hotel.*")); + // TODO this seems rather bogus, should we eagerly show an error? + assertEquals("/d/e/f/hotel.html", pathMatcher.combine("/a/b/c/*.html", "/d/e/f/hotel.*")); + assertEquals("/*.html", pathMatcher.combine("/**", "/*.html")); + assertEquals("/*.html", pathMatcher.combine("/*", "/*.html")); + assertEquals("/*.html", pathMatcher.combine("/*.*", "/*.html")); + assertEquals("/{foo}/bar", pathMatcher.combine("/{foo}", "/bar")); // SPR-8858 + assertEquals("/user/user", pathMatcher.combine("/user", "/user")); // SPR-7970 + assertEquals("/{foo:.*[^0-9].*}/edit/", + pathMatcher.combine("/{foo:.*[^0-9].*}", "/edit/")); // SPR-10062 + assertEquals("/1.0/foo/test", pathMatcher.combine("/1.0", "/foo/test")); + // SPR-10554 + assertEquals("/hotel", pathMatcher.combine("/", "/hotel")); // SPR-12975 + assertEquals("/hotel/booking", pathMatcher.combine("/hotel/", "/booking")); // SPR-12975 + assertEquals("",pathMatcher.combine(null, null)); + assertEquals("",pathMatcher.combine(null, "")); + assertEquals("",pathMatcher.combine("",null)); + assertEquals("",pathMatcher.combine(null, null)); + assertEquals("",pathMatcher.combine("", "")); + assertEquals("/hotel",pathMatcher.combine("", "/hotel")); + assertEquals("/hotel",pathMatcher.combine("/hotel", null)); + assertEquals("/hotel",pathMatcher.combine("/hotel", "")); + // TODO Do we need special handling when patterns contain multiple dots? + } + + @Test + public void combineWithTwoFileExtensionPatterns() { + TestPathCombiner pathMatcher = new TestPathCombiner(); + exception.expect(IllegalArgumentException.class); + pathMatcher.combine("/*.html", "/*.txt"); + } + + @Test + public void patternComparator() { + Comparator comparator = new PatternComparatorConsideringPath( + "/hotels/new"); + + assertEquals(0, comparator.compare(null, null)); + assertEquals(1, comparator.compare(null, parse("/hotels/new"))); + assertEquals(-1, comparator.compare(parse("/hotels/new"), null)); + + assertEquals(0, comparator.compare(parse("/hotels/new"), parse("/hotels/new"))); + + assertEquals(-1, comparator.compare(parse("/hotels/new"), parse("/hotels/*"))); + assertEquals(1, comparator.compare(parse("/hotels/*"), parse("/hotels/new"))); + assertEquals(0, comparator.compare(parse("/hotels/*"), parse("/hotels/*"))); + + assertEquals(-1, + comparator.compare(parse("/hotels/new"), parse("/hotels/{hotel}"))); + assertEquals(1, + comparator.compare(parse("/hotels/{hotel}"), parse("/hotels/new"))); + assertEquals(0, + comparator.compare(parse("/hotels/{hotel}"), parse("/hotels/{hotel}"))); + assertEquals(-1, comparator.compare(parse("/hotels/{hotel}/booking"), + parse("/hotels/{hotel}/bookings/{booking}"))); + assertEquals(1, comparator.compare(parse("/hotels/{hotel}/bookings/{booking}"), + parse("/hotels/{hotel}/booking"))); + + assertEquals(-1, + comparator.compare( + parse("/hotels/{hotel}/bookings/{booking}/cutomers/{customer}"), + parse("/**"))); + assertEquals(1, comparator.compare(parse("/**"), + parse("/hotels/{hotel}/bookings/{booking}/cutomers/{customer}"))); + assertEquals(0, comparator.compare(parse("/**"), parse("/**"))); + + assertEquals(-1, + comparator.compare(parse("/hotels/{hotel}"), parse("/hotels/*"))); + assertEquals(1, comparator.compare(parse("/hotels/*"), parse("/hotels/{hotel}"))); + + assertEquals(-1, comparator.compare(parse("/hotels/*"), parse("/hotels/*/**"))); + assertEquals(1, comparator.compare(parse("/hotels/*/**"), parse("/hotels/*"))); + + assertEquals(-1, + comparator.compare(parse("/hotels/new"), parse("/hotels/new.*"))); + + // SPR-6741 + assertEquals(-1, + comparator.compare( + parse("/hotels/{hotel}/bookings/{booking}/cutomers/{customer}"), + parse("/hotels/**"))); + assertEquals(1, comparator.compare(parse("/hotels/**"), + parse("/hotels/{hotel}/bookings/{booking}/cutomers/{customer}"))); + assertEquals(1, comparator.compare(parse("/hotels/foo/bar/**"), + parse("/hotels/{hotel}"))); + assertEquals(-1, comparator.compare(parse("/hotels/{hotel}"), + parse("/hotels/foo/bar/**"))); + + // SPR-8683 + assertEquals(1, comparator.compare(parse("/**"), parse("/hotels/{hotel}"))); + + // longer is better + assertEquals(1, comparator.compare(parse("/hotels"), parse("/hotels2"))); + + // SPR-13139 + assertEquals(-1, comparator.compare(parse("*"), parse("*/**"))); + assertEquals(1, comparator.compare(parse("*/**"), parse("*"))); + } + + @Test + public void pathPatternComparator() { + PathPatternComparator ppc = new PathPatternComparator(); + assertEquals(0,ppc.compare(null, null)); + assertEquals(1,ppc.compare(null, parse(""))); + assertEquals(-1,ppc.compare(parse(""), null)); + assertEquals(0,ppc.compare(parse(""), parse(""))); + } + + @Test + public void patternCompareTo() { + PathPatternParser p = new PathPatternParser(); + PathPattern pp = p.parse("/abc"); + assertEquals(-1,pp.compareTo(null)); + } + + @Test + public void patternComparatorSort() { + Comparator comparator = new PatternComparatorConsideringPath( + "/hotels/new"); + List paths = new ArrayList<>(3); + PathPatternParser pp = new PathPatternParser(); + paths.add(null); + paths.add(pp.parse("/hotels/new")); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertNull(paths.get(1)); + paths.clear(); + + paths.add(pp.parse("/hotels/new")); + paths.add(null); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertNull(paths.get(1)); + paths.clear(); + + paths.add(pp.parse("/hotels/*")); + paths.add(pp.parse("/hotels/new")); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertEquals("/hotels/*", paths.get(1).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/new")); + paths.add(pp.parse("/hotels/*")); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertEquals("/hotels/*", paths.get(1).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/**")); + paths.add(pp.parse("/hotels/*")); + Collections.sort(paths, comparator); + assertEquals("/hotels/*", paths.get(0).getPatternString()); + assertEquals("/hotels/**", paths.get(1).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/*")); + paths.add(pp.parse("/hotels/**")); + Collections.sort(paths, comparator); + assertEquals("/hotels/*", paths.get(0).getPatternString()); + assertEquals("/hotels/**", paths.get(1).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/{hotel}")); + paths.add(pp.parse("/hotels/new")); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertEquals("/hotels/{hotel}", paths.get(1).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/new")); + paths.add(pp.parse("/hotels/{hotel}")); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertEquals("/hotels/{hotel}", paths.get(1).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/*")); + paths.add(pp.parse("/hotels/{hotel}")); + paths.add(pp.parse("/hotels/new")); + Collections.sort(paths, comparator); + assertEquals("/hotels/new", paths.get(0).getPatternString()); + assertEquals("/hotels/{hotel}", paths.get(1).getPatternString()); + assertEquals("/hotels/*", paths.get(2).getPatternString()); + paths.clear(); + + paths.add(pp.parse("/hotels/ne*")); + paths.add(pp.parse("/hotels/n*")); + Collections.shuffle(paths); + Collections.sort(paths, comparator); + assertEquals("/hotels/ne*", paths.get(0).getPatternString()); + assertEquals("/hotels/n*", paths.get(1).getPatternString()); + paths.clear(); + + // comparator = new PatternComparatorConsideringPath("/hotels/new.html"); + // paths.add(pp.parse("/hotels/new.*")); + // paths.add(pp.parse("/hotels/{hotel}")); + // Collections.shuffle(paths); + // Collections.sort(paths, comparator); + // assertEquals("/hotels/new.*", paths.get(0).toPatternString()); + // assertEquals("/hotels/{hotel}", paths.get(1).toPatternString()); + // paths.clear(); + + comparator = new PatternComparatorConsideringPath("/web/endUser/action/login.html"); + paths.add(pp.parse("/*/login.*")); + paths.add(pp.parse("/*/endUser/action/login.*")); + Collections.sort(paths, comparator); + assertEquals("/*/endUser/action/login.*", paths.get(0).getPatternString()); + assertEquals("/*/login.*", paths.get(1).getPatternString()); + paths.clear(); + } + + @Test // SPR-13286 + public void caseInsensitive() { + PathPatternParser pp = new PathPatternParser(); + pp.setCaseSensitive(false); + PathPattern p = pp.parse("/group/{groupName}/members"); + assertTrue(p.matches("/group/sales/members")); + assertTrue(p.matches("/Group/Sales/Members")); + assertTrue(p.matches("/group/Sales/members")); + } + + @Test + public void patternmessage() { + PatternMessage[] values = PatternMessage.values(); + assertNotNull(values); + for (PatternMessage pm: values) { + String name = pm.toString(); + assertEquals(pm.ordinal(),PatternMessage.valueOf(name).ordinal()); + } + } + + private PathPattern parse(String path) { + PathPatternParser pp = new PathPatternParser(); + return pp.parse(path); + } + + private char separator = PathPatternParser.DEFAULT_SEPARATOR; + + private void checkMatches(String uriTemplate, String path) { + PathPatternParser parser = (separator == PathPatternParser.DEFAULT_SEPARATOR + ? new PathPatternParser() : new PathPatternParser(separator)); + PathPattern p = parser.parse(uriTemplate); + assertTrue(p.matches(path)); + } + + private void checkStartNoMatch(String uriTemplate, String path) { + PathPatternParser p = new PathPatternParser(); + PathPattern pattern = p.parse(uriTemplate); + assertFalse(pattern.matchStart(path)); + } + + private void checkStartMatches(String uriTemplate, String path) { + PathPatternParser p = new PathPatternParser(); + PathPattern pattern = p.parse(uriTemplate); + assertTrue(pattern.matchStart(path)); + } + + private void checkNoMatch(String uriTemplate, String path) { + PathPatternParser p = new PathPatternParser(); + PathPattern pattern = p.parse(uriTemplate); + assertFalse(pattern.matches(path)); + } + + private Map checkCapture(String uriTemplate, String path, String... keyValues) { + PathPatternParser parser = new PathPatternParser(); + PathPattern pattern = parser.parse(uriTemplate); + Map matchResults = pattern.matchAndExtract(path); + Map expectedKeyValues = new HashMap<>(); + if (keyValues != null) { + for (int i = 0; i < keyValues.length; i += 2) { + expectedKeyValues.put(keyValues[i], keyValues[i + 1]); + } + } + Map capturedVariables = matchResults; + for (Map.Entry me : expectedKeyValues.entrySet()) { + String value = capturedVariables.get(me.getKey()); + if (value == null) { + fail("Did not find key '" + me.getKey() + "' in captured variables: " + + capturedVariables); + } + if (!value.equals(me.getValue())) { + fail("Expected value '" + me.getValue() + "' for key '" + me.getKey() + + "' but was '" + value + "'"); + } + } + return capturedVariables; + } + + private void checkExtractPathWithinPattern(String pattern, String path, String expected) { + PathPatternParser ppp = new PathPatternParser(); + PathPattern pp = ppp.parse(pattern); + String s = pp.extractPathWithinPattern(path); + assertEquals(expected,s); + } + + static class TestPathCombiner { + + PathPatternParser pp = new PathPatternParser(); + + public String combine(String string1, String string2) { + PathPattern pattern1 = pp.parse(string1); + return pattern1.combine(string2); + } + + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/util/patterns/PathPatternParserTests.java b/spring-web/src/test/java/org/springframework/web/util/patterns/PathPatternParserTests.java new file mode 100644 index 0000000000..22bc6b4ac7 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/util/patterns/PathPatternParserTests.java @@ -0,0 +1,465 @@ +/* + * Copyright 2016 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 + * + * http://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.web.util.patterns; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Exercise the {@link PathPatternParser}. + * + * @author Andy Clement + */ +public class PathPatternParserTests { + + private PathPattern p; + + @Test + public void basicPatterns() { + checkStructure("/"); + checkStructure("/foo"); + checkStructure("foo"); + checkStructure("foo/"); + checkStructure("/foo/"); + checkStructure("//"); + } + + @Test + public void singleCharWildcardPatterns() { + p = checkStructure("?"); + assertPathElements(p , SingleCharWildcardedPathElement.class); + checkStructure("/?/"); + checkStructure("//?abc?/"); + } + + @Test + public void multiwildcardPattern() { + p = checkStructure("/**"); + assertPathElements(p,WildcardTheRestPathElement.class); + p = checkStructure("/**acb"); // this is not double wildcard use, it is / then **acb (an odd, unnecessary use of double *) + assertPathElements(p,SeparatorPathElement.class, RegexPathElement.class); + } + + @Test + public void toStringTests() { + assertEquals("CaptureTheRest(/{*foobar})", checkStructure("/{*foobar}").toChainString()); + assertEquals("CaptureVariable({foobar})", checkStructure("{foobar}").toChainString()); + assertEquals("Literal(abc)", checkStructure("abc").toChainString()); + assertEquals("Regex({a}_*_{b})", checkStructure("{a}_*_{b}").toChainString()); + assertEquals("Separator(/)", checkStructure("/").toChainString()); + assertEquals("SingleCharWildcarding(?a?b?c)", checkStructure("?a?b?c").toChainString()); + assertEquals("Wildcard(*)", checkStructure("*").toChainString()); + assertEquals("WildcardTheRest(/**)", checkStructure("/**").toChainString()); + } + + @Test + public void captureTheRestPatterns() { + checkError("/{*foobar}x{abc}", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + p = checkStructure("{*foobar}"); + assertPathElements(p, CaptureTheRestPathElement.class); + p = checkStructure("/{*foobar}"); + assertPathElements(p, CaptureTheRestPathElement.class); + checkError("/{*foobar}/", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + checkError("/{*foobar}abc", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + checkError("/{*f%obar}", 4, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{*foobar}abc",10,PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + checkError("/{f*oobar}",3,PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{*foobar}/abc",10,PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + checkError("/{abc}{*foobar}",1,PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); + checkError("/{abc}{*foobar}{foo}",15,PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + } + + @Test + public void equalsAndHashcode() { + PathPatternParser caseInsensitiveParser = new PathPatternParser(); + caseInsensitiveParser.setCaseSensitive(false); + PathPatternParser caseSensitiveParser = new PathPatternParser(); + PathPattern pp1 = caseInsensitiveParser.parse("/abc"); + PathPattern pp2 = caseInsensitiveParser.parse("/abc"); + PathPattern pp3 = caseInsensitiveParser.parse("/def"); + assertEquals(pp1,pp2); + assertEquals(pp1.hashCode(),pp2.hashCode()); + assertNotEquals(pp1, pp3); + assertFalse(pp1.equals("abc")); + + pp1 = caseInsensitiveParser.parse("/abc"); + pp2 = caseSensitiveParser.parse("/abc"); + assertFalse(pp1.equals(pp2)); + assertNotEquals(pp1.hashCode(),pp2.hashCode()); + + PathPatternParser alternateSeparatorParser = new PathPatternParser(':'); + pp1 = caseInsensitiveParser.parse("abc"); + pp2 = alternateSeparatorParser.parse("abc"); + assertFalse(pp1.equals(pp2)); + assertNotEquals(pp1.hashCode(),pp2.hashCode()); + } + + @Test + public void regexPathElementPatterns() { + checkError("/{var:[^/]*}", 8, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("/{var:abc", 8, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("/{var:a{{1,2}}}", 6, PatternMessage.JDK_PATTERN_SYNTAX_EXCEPTION); + + p = checkStructure("/{var:\\\\}"); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + assertTrue(p.matches("/\\")); + + p = checkStructure("/{var:\\/}"); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + assertFalse(p.matches("/aaa")); + + p = checkStructure("/{var:a{1,2}}",1); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + + p = checkStructure("/{var:[^\\/]*}",1); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + Map result = p.matchAndExtract("/foo"); + assertEquals("foo",result.get("var")); + + p = checkStructure("/{var:\\[*}",1); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + result = p.matchAndExtract("/[[["); + assertEquals("[[[",result.get("var")); + + p = checkStructure("/{var:[\\{]*}",1); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + result = p.matchAndExtract("/{{{"); + assertEquals("{{{",result.get("var")); + + p = checkStructure("/{var:[\\}]*}",1); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + result = p.matchAndExtract("/}}}"); + assertEquals("}}}",result.get("var")); + + p = checkStructure("*"); + assertEquals(WildcardPathElement.class.getName(),p.getHeadSection().getClass().getName()); + checkStructure("/*"); + checkStructure("/*/"); + checkStructure("*/"); + checkStructure("/*/"); + p = checkStructure("/*a*/"); + assertEquals(RegexPathElement.class.getName(),p.getHeadSection().next.getClass().getName()); + p = checkStructure("*/"); + assertEquals(WildcardPathElement.class.getName(),p.getHeadSection().getClass().getName()); + checkError("{foo}_{foo}", 0, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "foo"); + checkError("/{bar}/{bar}", 7, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "bar"); + checkError("/{bar}/{bar}_{foo}", 7, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "bar"); + + p = checkStructure("{symbolicName:[\\p{L}\\.]+}-sources-{version:[\\p{N}\\.]+}.jar"); + assertEquals(RegexPathElement.class.getName(),p.getHeadSection().getClass().getName()); + + + } + + @Test + public void completeCapturingPatterns() { + p = checkStructure("{foo}"); + assertEquals(CaptureVariablePathElement.class.getName(),p.getHeadSection().getClass().getName()); + checkStructure("/{foo}"); + checkStructure("//{f}/"); + checkStructure("/{foo}/{bar}/{wibble}"); + } + + @Test + public void completeCaptureWithConstraints() { + p = checkStructure("{foo:...}"); + assertPathElements(p, CaptureVariablePathElement.class); + p = checkStructure("{foo:[0-9]*}"); + assertPathElements(p, CaptureVariablePathElement.class); + checkError("{foo:}",5,PatternMessage.MISSING_REGEX_CONSTRAINT); + } + + @Test + public void partialCapturingPatterns() { + p = checkStructure("{foo}abc"); + assertEquals(RegexPathElement.class.getName(),p.getHeadSection().getClass().getName()); + checkStructure("abc{foo}"); + checkStructure("/abc{foo}"); + checkStructure("{foo}def/"); + checkStructure("/abc{foo}def/"); + checkStructure("{foo}abc{bar}"); + checkStructure("{foo}abc{bar}/"); + checkStructure("/{foo}abc{bar}/"); + } + + @Test + public void illegalCapturePatterns() { + checkError("{abc/",4,PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("{abc:}/",5,PatternMessage.MISSING_REGEX_CONSTRAINT); + checkError("{",1,PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("{abc",4,PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("{/}",1,PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("//{",3,PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("}",0,PatternMessage.MISSING_OPEN_CAPTURE); + checkError("/}",1,PatternMessage.MISSING_OPEN_CAPTURE); + checkError("def}",3,PatternMessage.MISSING_OPEN_CAPTURE); + checkError("//{/}",3,PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("//{{/}",3,PatternMessage.ILLEGAL_NESTED_CAPTURE); + checkError("//{abc{/}",6,PatternMessage.ILLEGAL_NESTED_CAPTURE); + checkError("/{0abc}/abc",2,PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR); + checkError("/{a?bc}/abc",3,PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{abc}_{abc}",1,PatternMessage.ILLEGAL_DOUBLE_CAPTURE); + checkError("/foobar/{abc}_{abc}",8,PatternMessage.ILLEGAL_DOUBLE_CAPTURE); + checkError("/foobar/{abc:..}_{abc:..}",8,PatternMessage.ILLEGAL_DOUBLE_CAPTURE); + PathPattern pp = parse("/{abc:foo(bar)}"); + try { + pp.matchAndExtract("/foo"); + fail("Should have raised exception"); + } catch (IllegalArgumentException iae) { + assertEquals("No capture groups allowed in the constraint regex: foo(bar)",iae.getMessage()); + } + try { + pp.matchAndExtract("/foobar"); + fail("Should have raised exception"); + } catch (IllegalArgumentException iae) { + assertEquals("No capture groups allowed in the constraint regex: foo(bar)",iae.getMessage()); + } + } + + @Test + public void badPatterns() { +// checkError("/{foo}{bar}/",6,PatternMessage.CANNOT_HAVE_ADJACENT_CAPTURES); + checkError("/{?}/",2,PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR,"?"); + checkError("/{a?b}/",3,PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR,"?"); + checkError("/{%%$}",2,PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR,"%"); + checkError("/{ }",2,PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR," "); + checkError("/{%:[0-9]*}",2,PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR,"%"); + } + + @Test + public void patternPropertyGetCaptureCountTests() { + // Test all basic section types + assertEquals(1,parse("{foo}").getCapturedVariableCount()); + assertEquals(0,parse("foo").getCapturedVariableCount()); + assertEquals(1,parse("{*foobar}").getCapturedVariableCount()); + assertEquals(1,parse("/{*foobar}").getCapturedVariableCount()); + assertEquals(0,parse("/**").getCapturedVariableCount()); + assertEquals(1,parse("{abc}asdf").getCapturedVariableCount()); + assertEquals(1,parse("{abc}_*").getCapturedVariableCount()); + assertEquals(2,parse("{abc}_{def}").getCapturedVariableCount()); + assertEquals(0,parse("/").getCapturedVariableCount()); + assertEquals(0,parse("a?b").getCapturedVariableCount()); + assertEquals(0,parse("*").getCapturedVariableCount()); + + // Test on full templates + assertEquals(0,parse("/foo/bar").getCapturedVariableCount()); + assertEquals(1,parse("/{foo}").getCapturedVariableCount()); + assertEquals(2,parse("/{foo}/{bar}").getCapturedVariableCount()); + assertEquals(4,parse("/{foo}/{bar}_{goo}_{wibble}/abc/bar").getCapturedVariableCount()); + } + + @Test + public void patternPropertyGetWildcardCountTests() { + // Test all basic section types + assertEquals(computeScore(1,0),parse("{foo}").getScore()); + assertEquals(computeScore(0,0),parse("foo").getScore()); + assertEquals(computeScore(0,0),parse("{*foobar}").getScore()); +// assertEquals(1,parse("/**").getScore()); + assertEquals(computeScore(1,0),parse("{abc}asdf").getScore()); + assertEquals(computeScore(1,1),parse("{abc}_*").getScore()); + assertEquals(computeScore(2,0),parse("{abc}_{def}").getScore()); + assertEquals(computeScore(0,0),parse("/").getScore()); + assertEquals(computeScore(0,0),parse("a?b").getScore()); // currently deliberate + assertEquals(computeScore(0,1),parse("*").getScore()); + + // Test on full templates + assertEquals(computeScore(0,0),parse("/foo/bar").getScore()); + assertEquals(computeScore(1,0),parse("/{foo}").getScore()); + assertEquals(computeScore(2,0),parse("/{foo}/{bar}").getScore()); + assertEquals(computeScore(4,0),parse("/{foo}/{bar}_{goo}_{wibble}/abc/bar").getScore()); + assertEquals(computeScore(4,3),parse("/{foo}/*/*_*/{bar}_{goo}_{wibble}/abc/bar").getScore()); + } + + @Test + public void multipleSeparatorPatterns() { + p = checkStructure("///aaa"); + assertEquals(4,p.getNormalizedLength()); + assertPathElements(p,SeparatorPathElement.class,LiteralPathElement.class); + p = checkStructure("///aaa////aaa/b"); + assertEquals(10,p.getNormalizedLength()); + assertPathElements(p,SeparatorPathElement.class, LiteralPathElement.class, + SeparatorPathElement.class, LiteralPathElement.class, SeparatorPathElement.class, LiteralPathElement.class); + p = checkStructure("/////**"); + assertEquals(1,p.getNormalizedLength()); + assertPathElements(p,WildcardTheRestPathElement.class); + } + + @Test + public void patternPropertyGetLengthTests() { + // Test all basic section types + assertEquals(1,parse("{foo}").getNormalizedLength()); + assertEquals(3,parse("foo").getNormalizedLength()); + assertEquals(1,parse("{*foobar}").getNormalizedLength()); + assertEquals(1,parse("/{*foobar}").getNormalizedLength()); + assertEquals(1,parse("/**").getNormalizedLength()); + assertEquals(5,parse("{abc}asdf").getNormalizedLength()); + assertEquals(3,parse("{abc}_*").getNormalizedLength()); + assertEquals(3,parse("{abc}_{def}").getNormalizedLength()); + assertEquals(1,parse("/").getNormalizedLength()); + assertEquals(3,parse("a?b").getNormalizedLength()); + assertEquals(1,parse("*").getNormalizedLength()); + + // Test on full templates + assertEquals(8,parse("/foo/bar").getNormalizedLength()); + assertEquals(2,parse("/{foo}").getNormalizedLength()); + assertEquals(4,parse("/{foo}/{bar}").getNormalizedLength()); + assertEquals(16,parse("/{foo}/{bar}_{goo}_{wibble}/abc/bar").getNormalizedLength()); + } + + @Test + public void compareTests() { + PathPattern p1,p2,p3; + + // Based purely on number of captures + p1 = parse("{a}"); + p2 = parse("{a}/{b}"); + p3 = parse("{a}/{b}/{c}"); + assertEquals(-1,p1.compareTo(p2)); // Based on number of captures + List patterns = new ArrayList<>(); + patterns.add(p2); + patterns.add(p3); + patterns.add(p1); + Collections.sort(patterns,new PathPatternComparator()); + assertEquals(p1,patterns.get(0)); + + // Based purely on length + p1 = parse("/a/b/c"); + p2 = parse("/a/boo/c/doo"); + p3 = parse("/asdjflaksjdfjasdf"); + assertEquals(1,p1.compareTo(p2)); + patterns = new ArrayList<>(); + patterns.add(p2); + patterns.add(p3); + patterns.add(p1); + Collections.sort(patterns,new PathPatternComparator()); + assertEquals(p3,patterns.get(0)); + + // Based purely on 'wildness' + p1 = parse("/*"); + p2 = parse("/*/*"); + p3 = parse("/*/*/*_*"); + assertEquals(-1,p1.compareTo(p2)); + patterns = new ArrayList<>(); + patterns.add(p2); + patterns.add(p3); + patterns.add(p1); + Collections.sort(patterns,new PathPatternComparator()); + assertEquals(p1,patterns.get(0)); + + // Based purely on catchAll + p1 = parse("{*foobar}"); + p2 = parse("{*goo}"); + assertEquals(0,p1.compareTo(p2)); + + p1 = parse("/{*foobar}"); + p2 = parse("/abc/{*ww}"); + assertEquals(+1,p1.compareTo(p2)); + assertEquals(-1,p2.compareTo(p1)); + + p3 = parse("/this/that/theother"); + assertTrue(p1.isCatchAll()); + assertTrue(p2.isCatchAll()); + assertFalse(p3.isCatchAll()); + patterns = new ArrayList<>(); + patterns.add(p2); + patterns.add(p3); + patterns.add(p1); + Collections.sort(patterns,new PathPatternComparator()); + assertEquals(p3,patterns.get(0)); + assertEquals(p2,patterns.get(1)); + + patterns = new ArrayList<>(); + patterns.add(parse("/abc")); + patterns.add(null); + patterns.add(parse("/def")); + Collections.sort(patterns,new PathPatternComparator()); + assertNull(patterns.get(2)); + } + + // --- + + private PathPattern parse(String pattern) { + PathPatternParser patternParser = new PathPatternParser(); + return patternParser.parse(pattern); + } + + /** + * Verify the parsed chain of sections matches the original pattern and the separator count + * that has been determined is correct. + */ + private PathPattern checkStructure(String pattern) { + int count = 0; + for (int i=0;i... sectionClasses) { + PathElement head = p.getHeadSection(); + for (int i=0;i handlerMap = new LinkedHashMap<>(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java index 82797a3182..b26af55ce7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java @@ -27,11 +27,11 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import org.springframework.util.AntPathMatcher; import org.springframework.util.PathMatcher; import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.support.HttpRequestPathHelper; +import org.springframework.web.util.ParsingPathMatcher; /** * A logical disjunction (' || ') request condition that matches a request @@ -90,7 +90,7 @@ public final class PatternsRequestCondition extends AbstractRequestCondition { - private PathMatcher pathMatcher = new AntPathMatcher(); + private PathMatcher pathMatcher = new ParsingPathMatcher(); @Override protected boolean isHandler(Class beanType) { diff --git a/spring-webflux/src/test/resources/org/springframework/web/reactive/handler/map.xml b/spring-webflux/src/test/resources/org/springframework/web/reactive/handler/map.xml index be0f6f067f..f2fe52c6d9 100644 --- a/spring-webflux/src/test/resources/org/springframework/web/reactive/handler/map.xml +++ b/spring-webflux/src/test/resources/org/springframework/web/reactive/handler/map.xml @@ -1,23 +1,22 @@ - + xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> + welcome.html=mainController - /**/pathmatchingTest.html=mainController - /**/pathmatching??.html=mainController - /**/path??matching.html=mainController - /**/??path??matching.html=mainController - /**/*.jsp=mainController - /administrator/**/pathmatching.html=mainController - /administrator/**/testlast*=mainController + /*pathmatchingTest.html=mainController + /pathmatching??.html=mainController + /administrator/pathmatching.html=mainController + /administrator/*/pathmatching.html=mainController + /administrator/*/testlast*=mainController + /administrator/testing/longer/*=mainController + /??path??matching.html=mainController + /path??matching.html=mainController /administrator/another/bla.xml=mainController - /administrator/testing/longer/**/**/**/**/**=mainController - /administrator/testing/longer2/**/**/bla/**=mainController /*test*.jpeg=mainController /*/test.jpeg=mainController /outofpattern*yeah=mainController diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/ResourceServlet.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/ResourceServlet.java new file mode 100644 index 0000000000..1d18ad6cc5 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/ResourceServlet.java @@ -0,0 +1,345 @@ +/* + * Copyright 2002-2012 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 + * + * http://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.web.servlet; + +import java.io.IOException; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.util.PathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.context.support.ServletContextResource; +import org.springframework.web.util.ParsingPathMatcher; + +/** + * Simple servlet that can expose an internal resource, including a + * default URL if the specified resource is not found. An alternative, + * for example, to trying and catching exceptions when using JSP include. + * + *

A further usage of this servlet is the ability to apply last-modified + * timestamps to quasi-static resources (typically JSPs). This can happen + * as bridge to parameter-specified resources, or as proxy for a specific + * target resource (or a list of specific target resources to combine). + * + *

A typical usage would map a URL like "/ResourceServlet" onto an instance + * of this servlet, and use the "JSP include" action to include this URL, + * with the "resource" parameter indicating the actual target path in the WAR. + * + *

The {@code defaultUrl} property can be set to the internal + * resource path of a default URL, to be rendered when the target resource + * is not found or not specified in the first place. + * + *

The "resource" parameter and the {@code defaultUrl} property can + * also specify a list of target resources to combine. Those resources will be + * included one by one to build the response. If last-modified determination + * is active, the newest timestamp among those files will be used. + * + *

The {@code allowedResources} property can be set to a URL + * pattern of resources that should be available via this servlet. + * If not set, any target resource can be requested, including resources + * in the WEB-INF directory! + * + *

If using this servlet for direct access rather than via includes, + * the {@code contentType} property should be specified to apply a + * proper content type. Note that a content type header in the target JSP will + * be ignored when including the resource via a RequestDispatcher include. + * + *

To apply last-modified timestamps for the target resource, set the + * {@code applyLastModified} property to true. This servlet will then + * return the file timestamp of the target resource as last-modified value, + * falling back to the startup time of this servlet if not retrievable. + * + *

Note that applying the last-modified timestamp in the above fashion + * just makes sense if the target resource does not generate content that + * depends on the HttpSession or cookies; it is just allowed to evaluate + * request parameters. + * + *

A typical case for such last-modified usage is a JSP that just makes + * minimal usage of basic means like includes or message resolution to + * build quasi-static content. Regenerating such content on every request + * is unnecessary; it can be cached as long as the file hasn't changed. + * + *

Note that this servlet will apply the last-modified timestamp if you + * tell it to do so: It's your decision whether the content of the target + * resource can be cached in such a fashion. Typical use cases are helper + * resources that are not fronted by a controller, like JavaScript files + * that are generated by a JSP (without depending on the HttpSession). + * + * @author Juergen Hoeller + * @author Rod Johnson + * @see #setDefaultUrl + * @see #setAllowedResources + * @see #setApplyLastModified + */ +@SuppressWarnings("serial") +public class ResourceServlet extends HttpServletBean { + + /** + * Any number of these characters are considered delimiters + * between multiple resource paths in a single String value. + */ + public static final String RESOURCE_URL_DELIMITERS = ",; \t\n"; + + /** + * Name of the parameter that must contain the actual resource path. + */ + public static final String RESOURCE_PARAM_NAME = "resource"; + + + private String defaultUrl; + + private String allowedResources; + + private String contentType; + + private boolean applyLastModified = false; + + private PathMatcher pathMatcher; + + private long startupTime; + + + /** + * Set the URL within the current web application from which to + * include content if the requested path isn't found, or if none + * is specified in the first place. + *

If specifying multiple URLs, they will be included one by one + * to build the response. If last-modified determination is active, + * the newest timestamp among those files will be used. + * @see #setApplyLastModified + */ + public void setDefaultUrl(String defaultUrl) { + this.defaultUrl = defaultUrl; + } + + /** + * Set allowed resources as URL pattern, e.g. "/WEB-INF/res/*.jsp", + * The parameter can be any Ant-style pattern parsable by AntPathMatcher. + * @see org.springframework.util.AntPathMatcher + */ + public void setAllowedResources(String allowedResources) { + this.allowedResources = allowedResources; + } + + /** + * Set the content type of the target resource (typically a JSP). + * Default is none, which is appropriate when including resources. + *

For directly accessing resources, for example to leverage this + * servlet's last-modified support, specify a content type here. + * Note that a content type header in the target JSP will be ignored + * when including the resource via a RequestDispatcher include. + */ + public void setContentType(String contentType) { + this.contentType = contentType; + } + + /** + * Set whether to apply the file timestamp of the target resource + * as last-modified value. Default is "false". + *

This is mainly intended for JSP targets that don't generate + * session-specific or database-driven content: Such files can be + * cached by the browser as long as the last-modified timestamp + * of the JSP file doesn't change. + *

This will only work correctly with expanded WAR files that + * allow access to the file timestamps. Else, the startup time + * of this servlet is returned. + */ + public void setApplyLastModified(boolean applyLastModified) { + this.applyLastModified = applyLastModified; + } + + + /** + * Remember the startup time, using no last-modified time before it. + */ + @Override + protected void initServletBean() { + this.pathMatcher = getPathMatcher(); + this.startupTime = System.currentTimeMillis(); + } + + /** + * Return a PathMatcher to use for matching the "allowedResources" URL pattern. + * Default is AntPathMatcher. + * @see #setAllowedResources + * @see org.springframework.util.AntPathMatcher + */ + protected PathMatcher getPathMatcher() { + return new ParsingPathMatcher(); + } + + + /** + * Determine the URL of the target resource and include it. + * @see #determineResourceUrl + */ + @Override + protected final void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + // determine URL of resource to include + String resourceUrl = determineResourceUrl(request); + + if (resourceUrl != null) { + try { + doInclude(request, response, resourceUrl); + } + catch (ServletException ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to include content of resource [" + resourceUrl + "]", ex); + } + // Try including default URL if appropriate. + if (!includeDefaultUrl(request, response)) { + throw ex; + } + } + catch (IOException ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to include content of resource [" + resourceUrl + "]", ex); + } + // Try including default URL if appropriate. + if (!includeDefaultUrl(request, response)) { + throw ex; + } + } + } + + // no resource URL specified -> try to include default URL. + else if (!includeDefaultUrl(request, response)) { + throw new ServletException("No target resource URL found for request"); + } + } + + /** + * Determine the URL of the target resource of this request. + *

Default implementation returns the value of the "resource" parameter. + * Can be overridden in subclasses. + * @param request current HTTP request + * @return the URL of the target resource, or {@code null} if none found + * @see #RESOURCE_PARAM_NAME + */ + protected String determineResourceUrl(HttpServletRequest request) { + return request.getParameter(RESOURCE_PARAM_NAME); + } + + /** + * Include the specified default URL, if appropriate. + * @param request current HTTP request + * @param response current HTTP response + * @return whether a default URL was included + * @throws ServletException if thrown by the RequestDispatcher + * @throws IOException if thrown by the RequestDispatcher + */ + private boolean includeDefaultUrl(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + if (this.defaultUrl == null) { + return false; + } + doInclude(request, response, this.defaultUrl); + return true; + } + + /** + * Include the specified resource via the RequestDispatcher. + * @param request current HTTP request + * @param response current HTTP response + * @param resourceUrl the URL of the target resource + * @throws ServletException if thrown by the RequestDispatcher + * @throws IOException if thrown by the RequestDispatcher + */ + private void doInclude(HttpServletRequest request, HttpServletResponse response, String resourceUrl) + throws ServletException, IOException { + + if (this.contentType != null) { + response.setContentType(this.contentType); + } + String[] resourceUrls = + StringUtils.tokenizeToStringArray(resourceUrl, RESOURCE_URL_DELIMITERS); + for (int i = 0; i < resourceUrls.length; i++) { + // check whether URL matches allowed resources + if (this.allowedResources != null && !this.pathMatcher.match(this.allowedResources, resourceUrls[i])) { + throw new ServletException("Resource [" + resourceUrls[i] + + "] does not match allowed pattern [" + this.allowedResources + "]"); + } + if (logger.isDebugEnabled()) { + logger.debug("Including resource [" + resourceUrls[i] + "]"); + } + RequestDispatcher rd = request.getRequestDispatcher(resourceUrls[i]); + rd.include(request, response); + } + } + + /** + * Return the last-modified timestamp of the file that corresponds + * to the target resource URL (i.e. typically the request ".jsp" file). + * Will simply return -1 if "applyLastModified" is false (the default). + *

Returns no last-modified date before the startup time of this servlet, + * to allow for message resolution etc that influences JSP contents, + * assuming that those background resources might have changed on restart. + *

Returns the startup time of this servlet if the file that corresponds + * to the target resource URL couldn't be resolved (for example, because + * the WAR is not expanded). + * @see #determineResourceUrl + * @see #getFileTimestamp + */ + @Override + protected final long getLastModified(HttpServletRequest request) { + if (this.applyLastModified) { + String resourceUrl = determineResourceUrl(request); + if (resourceUrl == null) { + resourceUrl = this.defaultUrl; + } + if (resourceUrl != null) { + String[] resourceUrls = StringUtils.tokenizeToStringArray(resourceUrl, RESOURCE_URL_DELIMITERS); + long latestTimestamp = -1; + for (int i = 0; i < resourceUrls.length; i++) { + long timestamp = getFileTimestamp(resourceUrls[i]); + if (timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + return (latestTimestamp > this.startupTime ? latestTimestamp : this.startupTime); + } + } + return -1; + } + + /** + * Return the file timestamp for the given resource. + * @param resourceUrl the URL of the resource + * @return the file timestamp in milliseconds, or -1 if not determinable + */ + protected long getFileTimestamp(String resourceUrl) { + ServletContextResource resource = new ServletContextResource(getServletContext(), resourceUrl); + try { + long lastModifiedTime = resource.lastModified(); + if (logger.isDebugEnabled()) { + logger.debug("Last-modified timestamp of " + resource + " is " + lastModifiedTime); + } + return lastModifiedTime; + } + catch (IOException ex) { + logger.warn("Couldn't retrieve last-modified timestamp of [" + resource + + "] - using ResourceServlet startup time"); + return -1; + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 3c27aa5ee7..85d5e30ace 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -55,7 +55,6 @@ import org.springframework.http.converter.support.AllEncompassingFormHttpMessage import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; import org.springframework.http.converter.xml.SourceHttpMessageConverter; -import org.springframework.util.AntPathMatcher; import org.springframework.util.ClassUtils; import org.springframework.util.PathMatcher; import org.springframework.validation.Errors; @@ -94,6 +93,7 @@ import org.springframework.web.servlet.resource.ResourceUrlProvider; import org.springframework.web.servlet.resource.ResourceUrlProviderExposingInterceptor; import org.springframework.web.servlet.view.InternalResourceViewResolver; import org.springframework.web.servlet.view.ViewResolverComposite; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; /** @@ -140,7 +140,7 @@ import org.springframework.web.util.UrlPathHelper; * exception types * * - *

Registers an {@link AntPathMatcher} and a {@link UrlPathHelper} + *

Registers an {@link ParsingPathMatcher} and a {@link UrlPathHelper} * to be used by: *

    *
  • the {@link RequestMappingHandlerMapping}, @@ -345,7 +345,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @Bean public PathMatcher mvcPathMatcher() { PathMatcher pathMatcher = getPathMatchConfigurer().getPathMatcher(); - return (pathMatcher != null ? pathMatcher : new AntPathMatcher()); + return (pathMatcher != null ? pathMatcher : new ParsingPathMatcher()); } /** diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java index a44ee50143..1ef601cec5 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java @@ -27,7 +27,6 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.core.Ordered; -import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import org.springframework.util.PathMatcher; import org.springframework.web.HttpRequestHandler; @@ -42,6 +41,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.servlet.HandlerExecutionChain; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; /** @@ -72,7 +72,7 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport private UrlPathHelper urlPathHelper = new UrlPathHelper(); - private PathMatcher pathMatcher = new AntPathMatcher(); + private PathMatcher pathMatcher = new ParsingPathMatcher(); private final List interceptors = new ArrayList<>(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/WebContentInterceptor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/WebContentInterceptor.java index 2bd9d7f62f..908e1f3efe 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/WebContentInterceptor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/WebContentInterceptor.java @@ -25,12 +25,12 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.http.CacheControl; -import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import org.springframework.util.PathMatcher; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.support.WebContentGenerator; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; /** @@ -52,7 +52,7 @@ public class WebContentInterceptor extends WebContentGenerator implements Handle private UrlPathHelper urlPathHelper = new UrlPathHelper(); - private PathMatcher pathMatcher = new AntPathMatcher(); + private PathMatcher pathMatcher = new ParsingPathMatcher(); private Map cacheMappings = new HashMap<>(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/PatternsRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/PatternsRequestCondition.java index 25f8046d06..e1c4d090ea 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/PatternsRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/PatternsRequestCondition.java @@ -27,9 +27,9 @@ import java.util.List; import java.util.Set; import javax.servlet.http.HttpServletRequest; -import org.springframework.util.AntPathMatcher; import org.springframework.util.PathMatcher; import org.springframework.util.StringUtils; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; /** @@ -104,7 +104,7 @@ public final class PatternsRequestCondition extends AbstractRequestCondition handlerMap = new LinkedHashMap<>(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/InterceptorRegistryTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/InterceptorRegistryTests.java index 4e31e6534b..ce923e8068 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/InterceptorRegistryTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/InterceptorRegistryTests.java @@ -37,6 +37,7 @@ import org.springframework.web.servlet.handler.MappedInterceptor; import org.springframework.web.servlet.handler.WebRequestHandlerInterceptorAdapter; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import org.springframework.web.servlet.theme.ThemeChangeInterceptor; +import org.springframework.web.util.ParsingPathMatcher; import static org.junit.Assert.*; @@ -152,7 +153,7 @@ public class InterceptorRegistryTests { private List getInterceptorsForPath(String lookupPath) { - PathMatcher pathMatcher = new AntPathMatcher(); + PathMatcher pathMatcher = new ParsingPathMatcher(); List result = new ArrayList<>(); for (Object interceptor : this.registry.getInterceptors()) { if (interceptor instanceof MappedInterceptor) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java index b31d35ffd4..77f1242886 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java @@ -83,6 +83,7 @@ import org.springframework.web.servlet.resource.ResourceUrlProviderExposingInter import org.springframework.web.servlet.view.BeanNameViewResolver; import org.springframework.web.servlet.view.InternalResourceViewResolver; import org.springframework.web.servlet.view.ViewResolverComposite; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; @@ -320,7 +321,7 @@ public class WebMvcConfigurationSupportTests { assertNotNull(urlPathHelper); assertNotNull(pathMatcher); - assertEquals(AntPathMatcher.class, pathMatcher.getClass()); + assertEquals(ParsingPathMatcher.class, pathMatcher.getClass()); } private ApplicationContext initContext(Class... configClasses) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java index 5a181069fe..810dc41427 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/HandlerMethodMappingTests.java @@ -38,6 +38,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.support.StaticWebApplicationContext; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.util.ParsingPathMatcher; import org.springframework.web.util.UrlPathHelper; @@ -244,7 +245,7 @@ public class HandlerMethodMappingTests { private UrlPathHelper pathHelper = new UrlPathHelper(); - private PathMatcher pathMatcher = new AntPathMatcher(); + private PathMatcher pathMatcher = new ParsingPathMatcher(); public MyHandlerMethodMapping() { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/PathMatchingUrlHandlerMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/PathMatchingUrlHandlerMappingTests.java index c2ce3e358b..14d3206a21 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/PathMatchingUrlHandlerMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/PathMatchingUrlHandlerMappingTests.java @@ -81,7 +81,7 @@ public class PathMatchingUrlHandlerMappingTests { HandlerExecutionChain hec = getHandler(req); assertTrue("Handler is null", hec != null); assertTrue("Handler is correct bean", hec.getHandler() == bean); - assertEquals("pathmatchingTest.html", req.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)); + assertEquals("/pathmatchingTest.html", req.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE)); // no match, no forward slash included req = new MockHttpServletRequest("GET", "welcome.html"); @@ -121,11 +121,6 @@ public class PathMatchingUrlHandlerMappingTests { hec = getHandler(req); assertTrue("Handler is correct bean", hec != null && hec.getHandler() == bean); - // this as well, because there's a **/in there as well - req = new MockHttpServletRequest("GET", "/testing/bla.jsp"); - hec = getHandler(req); - assertTrue("Handler is correct bean", hec != null && hec.getHandler() == bean); - // should match because exact pattern is there req = new MockHttpServletRequest("GET", "/administrator/another/bla.xml"); hec = getHandler(req); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/UrlFilenameViewControllerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/UrlFilenameViewControllerTests.java index 690ae54128..1ee1afe6f8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/UrlFilenameViewControllerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/UrlFilenameViewControllerTests.java @@ -23,11 +23,11 @@ import static org.junit.Assert.*; import org.springframework.mock.web.test.MockHttpServletRequest; import org.springframework.mock.web.test.MockHttpServletResponse; import org.springframework.ui.ModelMap; -import org.springframework.util.AntPathMatcher; import org.springframework.util.PathMatcher; import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.util.ParsingPathMatcher; /** * @author Juergen Hoeller @@ -36,7 +36,7 @@ import org.springframework.web.servlet.ModelAndView; */ public class UrlFilenameViewControllerTests { - private PathMatcher pathMatcher = new AntPathMatcher(); + private PathMatcher pathMatcher = new ParsingPathMatcher(); @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/WebContentInterceptorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/WebContentInterceptorTests.java index 06a844e13a..1ba056dceb 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/WebContentInterceptorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/WebContentInterceptorTests.java @@ -62,19 +62,21 @@ public class WebContentInterceptorTests { @Test public void mappedCacheConfigurationOverridesGlobal() throws Exception { Properties mappings = new Properties(); - mappings.setProperty("**/*handle.vm", "-1"); + mappings.setProperty("*/*handle.vm", "-1"); // was **/*handle.vm WebContentInterceptor interceptor = new WebContentInterceptor(); interceptor.setCacheSeconds(10); interceptor.setCacheMappings(mappings); - request.setRequestURI("http://localhost:7070/example/adminhandle.vm"); +// request.setRequestURI("http://localhost:7070/example/adminhandle.vm"); + request.setRequestURI("example/adminhandle.vm"); interceptor.preHandle(request, response, null); Iterable cacheControlHeaders = response.getHeaders("Cache-Control"); assertThat(cacheControlHeaders, Matchers.emptyIterable()); - request.setRequestURI("http://localhost:7070/example/bingo.html"); +// request.setRequestURI("http://localhost:7070/example/bingo.html"); + request.setRequestURI("example/bingo.html"); interceptor.preHandle(request, response, null); cacheControlHeaders = response.getHeaders("Cache-Control"); @@ -143,10 +145,11 @@ public class WebContentInterceptorTests { interceptor.setUseExpiresHeader(true); interceptor.setAlwaysMustRevalidate(true); Properties mappings = new Properties(); - mappings.setProperty("**/*.cache.html", "10"); + mappings.setProperty("*/*.cache.html", "10"); // was **/*.cache.html interceptor.setCacheMappings(mappings); - request.setRequestURI("http://example.org/foo/page.html"); +// request.setRequestURI("http://example.org/foo/page.html"); + request.setRequestURI("foo/page.html"); interceptor.preHandle(request, response, null); Iterable expiresHeaders = response.getHeaders("Expires"); @@ -157,7 +160,8 @@ public class WebContentInterceptorTests { assertThat(pragmaHeaders, Matchers.contains("no-cache")); response = new MockHttpServletResponse(); - request.setRequestURI("http://example.org/page.cache.html"); +// request.setRequestURI("http://example.org/page.cache.html"); + request.setRequestURI("foo/page.cache.html"); interceptor.preHandle(request, response, null); expiresHeaders = response.getHeaders("Expires"); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java index 3344d19809..cd072124dc 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java @@ -2364,22 +2364,22 @@ public class ServletAnnotationControllerHandlerMethodTests extends AbstractServl @Controller static class MyRelativeMethodPathDispatchingController { - @RequestMapping("**/myHandle") + @RequestMapping("*/myHandle") // was **/myHandle public void myHandle(HttpServletResponse response) throws IOException { response.getWriter().write("myView"); } - @RequestMapping("/**/*Other") + @RequestMapping("/*/*Other") // was /**/*Other public void myOtherHandle(HttpServletResponse response) throws IOException { response.getWriter().write("myOtherView"); } - @RequestMapping("**/myLang") + @RequestMapping("*/myLang") // was **/myLang public void myLangHandle(HttpServletResponse response) throws IOException { response.getWriter().write("myLangView"); } - @RequestMapping("/**/surprise") + @RequestMapping("/*/surprise") // was /**/surprise public void mySurpriseHandle(HttpServletResponse response) throws IOException { response.getWriter().write("mySurpriseView"); } @@ -2643,7 +2643,7 @@ public class ServletAnnotationControllerHandlerMethodTests extends AbstractServl @Controller public static class PathOrderingController { - @RequestMapping(value = {"/dir/myPath1.do", "/**/*.do"}) + @RequestMapping(value = {"/dir/myPath1.do", "/*/*.do"}) public void method1(Writer writer) throws IOException { writer.write("method1"); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/UriTemplateServletAnnotationControllerHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/UriTemplateServletAnnotationControllerHandlerMethodTests.java index f85faf5dca..d97046d13d 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/UriTemplateServletAnnotationControllerHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/UriTemplateServletAnnotationControllerHandlerMethodTests.java @@ -571,14 +571,14 @@ public class UriTemplateServletAnnotationControllerHandlerMethodTests extends Ab @RequestMapping("/category") public static class MultiPathController { - @RequestMapping(value = {"/{category}/page/{page}", "/**/{category}/page/{page}"}) + @RequestMapping(value = {"/{category}/page/{page}", "/*/{category}/page/{page}"}) public void category(@PathVariable String category, @PathVariable int page, Writer writer) throws IOException { writer.write("handle1-"); writer.write("category-" + category); writer.write("page-" + page); } - @RequestMapping(value = {"/{category}", "/**/{category}"}) + @RequestMapping(value = {"/{category}", "/*/{category}"}) public void category(@PathVariable String category, Writer writer) throws IOException { writer.write("handle2-"); writer.write("category-" + category); @@ -598,7 +598,7 @@ public class UriTemplateServletAnnotationControllerHandlerMethodTests extends Ab } @Controller - @RequestMapping("/*/menu/**") + @RequestMapping("/*/menu/") // was /*/menu/** public static class MenuTreeController { @RequestMapping("type/{var}") diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/handler/map3.xml b/spring-webmvc/src/test/resources/org/springframework/web/servlet/handler/map3.xml index bc7a9e49a1..3aa11bd36f 100644 --- a/spring-webmvc/src/test/resources/org/springframework/web/servlet/handler/map3.xml +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/handler/map3.xml @@ -8,16 +8,17 @@ welcome.html=mainController - /**/pathmatchingTest.html=mainController - /**/pathmatching??.html=mainController - /**/path??matching.html=mainController - /**/??path??matching.html=mainController - /**/*.jsp=mainController - /administrator/**/pathmatching.html=mainController - /administrator/**/testlast*=mainController + /path??matching.html=mainController + /pathmatchingTest.html=mainController + ??path??matching.html=mainController + /administrator/pathmatching.html=mainController + /administrator/testlast*=mainController + /administrator/testing/longer/{*foobar}=mainController + /administrator/*/testlast*=mainController + /administrator/*/pathmatching.html=mainController + /pathmatching??.html=mainController + /*.jsp=mainController /administrator/another/bla.xml=mainController - /administrator/testing/longer/**/**/**/**/**=mainController - /administrator/testing/longer2/**/**/bla/**=mainController /*test*.jpeg=mainController /*/test.jpeg=mainController /outofpattern*yeah=mainController