/*
* Copyright 2002-2017 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.pattern;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.springframework.util.PathMatcher;
import static org.springframework.util.StringUtils.*;
/**
* Represents a parsed path pattern. Includes a chain of path elements
* for fast matching and accumulates computed state for quick comparison of
* patterns.
*
* <p>PathPatterns match URL paths using the following rules:<br>
* <ul>
* <li>{@code ?} matches one character</li>
* <li>{@code *} matches zero or more characters within a path segment</li>
* <li>{@code **} matches zero or more <em>path segments</em> until the end of the path</li>
* <li>{@code {spring}} matches a <em>path segment</em> and captures it as a variable named "spring"</li>
* <li>{@code {spring:[a-z]+}} matches the regexp {@code [a-z]+} as a path variable named "spring"</li>
* <li>{@code {*spring}} matches zero or more <em>path segments</em> until the end of the path
* and captures it as a variable named "spring"</li>
* </ul>
*
* <h3>Examples</h3>
* <ul>
* <li>{@code /pages/t?st.html} — matches {@code /pages/test.html} but also
* {@code /pages/tast.html} but not {@code /pages/toast.html}</li>
* <li>{@code /resources/*.png} — matches all {@code .png} files in the
* {@code resources} directory</li>
* <li><code>/resources/**</code> — matches all files
* underneath the {@code /resources/} path, including {@code /resources/image.png}
* and {@code /resources/css/spring.css}</li>
* <li><code>/resources/{*path}</code> — matches all files
* underneath the {@code /resources/} path and captures their relative path in
* a variable named "path"; {@code /resources/image.png} will match with
* "spring" -> "/image.png", and {@code /resources/css/spring.css} will match
* with "spring" -> "/css/spring.css"</li>
* <li>{@code /resources/{filename:\\w+}.dat} will match {@code /resources/spring.dat}
* and assign the value {@code "spring"} to the {@code filename} variable</li>
* </ul>
*
* @author Andy Clement
* @since 5.0
*/
public class PathPattern implements Comparable<PathPattern> {
/** 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;
/** If this pattern has no trailing slash, allow candidates to include one and still match successfully */
boolean allowOptionalTrailingSlash;
/** 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.
*/
private int normalizedLength;
/**
* Does the pattern end with '<separator>*'
*/
private 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:
* <ul>
* <li>Captured variables are worth 1
* <li>Wildcard is worth 100
* </ul>
*/
private int score;
/** Does the pattern end with {*...} */
private boolean catchAll = false;
PathPattern(String patternText, PathElement head, char separator, boolean caseSensitive,
boolean allowOptionalTrailingSlash) {
this.patternString = patternText;
this.head = head;
this.separator = separator;
this.caseSensitive = caseSensitive;
this.allowOptionalTrailingSlash = allowOptionalTrailingSlash;
// Compute fields for fast comparison
PathElement elem = head;
while (elem != null) {
this.capturedVariableCount += elem.getCaptureCount();
this.normalizedLength += elem.getNormalizedLength();
this.score += elem.getScore();
if (elem instanceof CaptureTheRestPathElement || elem instanceof WildcardTheRestPathElement) {
this.catchAll = true;
}
if (elem instanceof SeparatorPathElement && elem.next != null &&
elem.next instanceof WildcardPathElement && elem.next.next == null) {
this.endsWithSeparatorWildcard = true;
}
elem = elem.next;
}
}
/**
* Return the original pattern string that was parsed to create this PathPattern.
*/
public String getPatternString() {
return this.patternString;
}
PathElement getHeadSection() {
return this.head;
}
/**
* @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 (this.head == null) {
return !hasLength(path);
}
else if (!hasLength(path)) {
if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) {
path = ""; // Will allow CaptureTheRest to bind the variable to empty
}
else {
return false;
}
}
MatchingContext matchingContext = new MatchingContext(path, false);
return this.head.matches(0, matchingContext);
}
/**
* For a given path return the remaining piece that is not covered by this PathPattern.
* @param path a path that may or may not match this path pattern
* @return a {@link PathRemainingMatchInfo} describing the match result or null if
* the path does not match this pattern
*/
public PathRemainingMatchInfo getPathRemaining(String path) {
if (this.head == null) {
if (path == null) {
return new PathRemainingMatchInfo(null);
}
else {
return new PathRemainingMatchInfo(hasLength(path) ? path : "");
}
}
else if (!hasLength(path)) {
return null;
}
MatchingContext matchingContext = new MatchingContext(path, true);
matchingContext.setMatchAllowExtraPath();
boolean matches = this.head.matches(0, matchingContext);
if (!matches) {
return null;
}
else {
PathRemainingMatchInfo info;
if (matchingContext.remainingPathIndex == path.length()) {
info = new PathRemainingMatchInfo("", matchingContext.getExtractedVariables());
}
else {
info = new PathRemainingMatchInfo(path.substring(matchingContext.remainingPathIndex),
matchingContext.getExtractedVariables());
}
return info;
}
}
/**
* @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 (this.head == null) {
return !hasLength(path);
}
else if (!hasLength(path)) {
return true;
}
MatchingContext matchingContext = new MatchingContext(path, false);
matchingContext.setMatchStartMatching(true);
return this.head.matches(0, matchingContext);
}
/**
* @param path a path that matches this pattern from which to extract variables
* @return a map of extracted variables - an empty map if no variables extracted.
* @throws IllegalStateException if the path does not match the pattern
*/
public Map<String, String> matchAndExtract(String path) {
MatchingContext matchingContext = new MatchingContext(path, true);
if (this.head != null && this.head.matches(0, matchingContext)) {
return matchingContext.getExtractedVariables();
}
else {
if (!hasLength(path)) {
return Collections.emptyMap();
}
else {
throw new IllegalStateException("Pattern \"" + this + "\" is not a match for \"" + path + "\"");
}
}
}
/**
* Given a full path, determine the pattern-mapped part. <p>For example: <ul>
* <li>'{@code /docs/cvs/commit.html}' and '{@code /docs/cvs/commit.html} -> ''</li>
* <li>'{@code /docs/*}' and '{@code /docs/cvs/commit} -> '{@code cvs/commit}'</li>
* <li>'{@code /docs/cvs/*.html}' and '{@code /docs/cvs/commit.html} -> '{@code commit.html}'</li>
* <li>'{@code /docs/**}' and '{@code /docs/cvs/commit} -> '{@code cvs/commit}'</li>
* </ul>
* <p><b>Note:</b> Assumes that {@link #matches} returns {@code true} for '{@code pattern}' and '{@code path}', but
* does <strong>not</strong> 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 elem = head;
int separatorCount = 0;
boolean matchTheRest = false;
// Find first path element that is pattern based
while (elem != null) {
if (elem instanceof SeparatorPathElement || elem instanceof CaptureTheRestPathElement ||
elem instanceof WildcardTheRestPathElement) {
separatorCount++;
if (elem instanceof WildcardTheRestPathElement || elem instanceof CaptureTheRestPathElement) {
matchTheRest = true;
}
}
if (elem.getWildcardCount() != 0 || elem.getCaptureCount() != 0) {
break;
}
elem = elem.next;
}
if (elem == 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) {
separatorCount--;
}
}
int end = len;
// Trim trailing separators
if (!matchTheRest) {
while (end > 0 && 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 < end) {
char ch = path.charAt(c);
if (ch == separator) {
if ((c + 1) < end && path.charAt(c + 1) == separator) {
// multiple separators
if (stringWithDuplicateSeparatorsRemoved == null) {
// first time seen, need to capture all data up to this point
stringWithDuplicateSeparatorsRemoved = new StringBuilder();
stringWithDuplicateSeparatorsRemoved.append(path.substring(pos, c));
}
do {
c++;
} while ((c + 1) < end && path.charAt(c + 1) == separator);
}
}
if (stringWithDuplicateSeparatorsRemoved != null) {
stringWithDuplicateSeparatorsRemoved.append(ch);
}
c++;
}
if (stringWithDuplicateSeparatorsRemoved != null) {
return stringWithDuplicateSeparatorsRemoved.toString();
}
return (pos == len ? "" : path.substring(pos, end));
}
/**
* Compare this pattern with a supplied pattern: return -1,0,+1 if this pattern
* is more specific, the same or less specific than the supplied pattern.
* The aim is to sort more specific patterns first.
*/
@Override
public int compareTo(PathPattern otherPattern) {
// 1) null is sorted last
if (otherPattern == null) {
return -1;
}
// 2) catchall patterns are sorted last. If both catchall then the
// length is considered
if (isCatchAll()) {
if (otherPattern.isCatchAll()) {
int lenDifference = getNormalizedLength() - otherPattern.getNormalizedLength();
if (lenDifference != 0) {
return (lenDifference < 0) ? +1 : -1;
}
}
else {
return +1;
}
}
else if (otherPattern.isCatchAll()) {
return -1;
}
// 3) This will sort such that if they differ in terms of wildcards or
// captured variable counts, the one with the most will be sorted last
int score = getScore() - otherPattern.getScore();
if (score != 0) {
return (score < 0) ? -1 : +1;
}
// 4) longer is better
int lenDifference = getNormalizedLength() - otherPattern.getNormalizedLength();
return (lenDifference < 0) ? +1 : (lenDifference == 0 ? 0 : -1);
}
int getScore() {
return this.score;
}
boolean isCatchAll() {
return this.catchAll;
}
/**
* The normalized length is trying to measure the 'active' part of the pattern. It is computed
* by assuming all capture 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 getNormalizedLength() {
return this.normalizedLength;
}
char getSeparator() {
return this.separator;
}
int getCapturedVariableCount() {
return this.capturedVariableCount;
}
/**
* 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 (!hasLength(this.patternString)) {
if (!hasLength(pattern2string)) {
return "";
}
else {
return pattern2string;
}
}
else if (!hasLength(pattern2string)) {
return this.patternString;
}
// /* + /hotel => /hotel
// /*.* + /*.html => /*.html
// However:
// /usr + /user => /usr/user
// /{foo} + /bar => /{foo}/bar
if (!this.patternString.equals(pattern2string) &&this. capturedVariableCount == 0 && matches(pattern2string)) {
return pattern2string;
}
// /hotels/* + /booking => /hotels/booking
// /hotels/* + booking => /hotels/booking
if (this.endsWithSeparatorWildcard) {
return concat(this.patternString.substring(0, this.patternString.length() - 2), pattern2string);
}
// /hotels + /booking => /hotels/booking
// /hotels + booking => /hotels/booking
int starDotPos1 = this.patternString.indexOf("*."); // Are there any file prefix/suffix things to consider?
if (this.capturedVariableCount != 0 || starDotPos1 == -1 || this.separator == '.') {
return concat(this.patternString, pattern2string);
}
// /*.html + /hotel => /hotel.html
// /*.html + /hotel.* => /hotel.html
String firstExtension = this.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: " + this.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) == this.separator);
boolean path2StartsWithSeparator = (path2.charAt(0) == this.separator);
if (path1EndsWithSeparator && path2StartsWithSeparator) {
return path1 + path2.substring(1);
}
else if (path1EndsWithSeparator || path2StartsWithSeparator) {
return path1 + path2;
}
else {
return path1 + this.separator + path2;
}
}
public boolean equals(Object other) {
if (!(other instanceof PathPattern)) {
return false;
}
PathPattern otherPattern = (PathPattern) other;
return (this.patternString.equals(otherPattern.getPatternString()) &&
this.separator == otherPattern.getSeparator() &&
this.caseSensitive == otherPattern.caseSensitive);
}
public int hashCode() {
return (this.patternString.hashCode() + this.separator) * 17 + (this.caseSensitive ? 1 : 0);
}
public String toString() {
return this.patternString;
}
String toChainString() {
StringBuilder buf = new StringBuilder();
PathElement pe = this.head;
while (pe != null) {
buf.append(pe.toString()).append(" ");
pe = pe.next;
}
return buf.toString().trim();
}
/**
* A holder for the result of a {@link PathPattern#getPathRemaining(String)} call. Holds
* information on the path left after the first part has successfully matched a pattern
* and any variables bound in that first part that matched.
*/
public static class PathRemainingMatchInfo {
private final String pathRemaining;
private final Map<String, String> matchingVariables;
PathRemainingMatchInfo(String pathRemaining) {
this(pathRemaining, Collections.emptyMap());
}
PathRemainingMatchInfo(String pathRemaining, Map<String, String> matchingVariables) {
this.pathRemaining = pathRemaining;
this.matchingVariables = matchingVariables;
}
/**
* Return the part of a path that was not matched by a pattern.
*/
public String getPathRemaining() {
return this.pathRemaining;
}
/**
* Return variables that were bound in the part of the path that was successfully matched.
* Will be an empty map if no variables were bound
*/
public Map<String, String> getMatchingVariables() {
return this.matchingVariables;
}
}
/**
* Encapsulates context when attempting a match. Includes some fixed state like the
* candidate currently being considered for a match but also some accumulators for
* extracted variables.
*/
class MatchingContext {
// The candidate path to attempt a match against
char[] candidate;
// The length of the candidate path
int candidateLength;
boolean isMatchStartMatching = false;
private Map<String, String> extractedVariables;
boolean extractingVariables;
boolean determineRemainingPath = false;
// if determineRemaining is true, this is set to the position in
// the candidate where the pattern finished matching - i.e. it
// points to the remaining path that wasn't consumed
int remainingPathIndex;
public MatchingContext(String path, boolean extractVariables) {
candidate = path.toCharArray();
candidateLength = candidate.length;
this.extractingVariables = extractVariables;
}
public void setMatchAllowExtraPath() {
determineRemainingPath = true;
}
public boolean isAllowOptionalTrailingSlash() {
return allowOptionalTrailingSlash;
}
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<String, String> getExtractedVariables() {
if (this.extractedVariables == null) {
return Collections.emptyMap();
}
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;
}
}
}