All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.springframework.web.util.pattern.PathPattern Maven / Gradle / Ivy

/*
 * Copyright 2002-2019 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.web.util.pattern;

import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;

import org.springframework.http.server.PathContainer;
import org.springframework.http.server.PathContainer.Element;
import org.springframework.http.server.PathContainer.Separator;
import org.springframework.lang.Nullable;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;

/**
 * Representation of a parsed path pattern. Includes a chain of path elements
 * for fast matching and accumulates computed state for quick comparison of
 * patterns.
 *
 * 

{@code PathPattern} matches URL paths using the following rules:
*

    *
  • {@code ?} matches one character
  • *
  • {@code *} matches zero or more characters within a path segment
  • *
  • {@code **} matches zero or more path segments until the end of the path
  • *
  • {spring} matches a path segment and captures it as a variable named "spring"
  • *
  • {spring:[a-z]+} matches the regexp {@code [a-z]+} as a path variable named "spring"
  • *
  • {*spring} matches zero or more path segments until the end of the path * and captures it as a variable named "spring"
  • *
* *

Examples

*
    *
  • {@code /pages/t?st.html} — matches {@code /pages/test.html} as well as * {@code /pages/tXst.html} but not {@code /pages/toast.html}
  • *
  • {@code /resources/*.png} — matches all {@code .png} files in the * {@code resources} directory
  • *
  • /resources/** — matches all files * underneath the {@code /resources/} path, including {@code /resources/image.png} * and {@code /resources/css/spring.css}
  • *
  • /resources/{*path} — 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"
  • *
  • /resources/{filename:\\w+}.dat will match {@code /resources/spring.dat} * and assign the value {@code "spring"} to the {@code filename} variable
  • *
* * @author Andy Clement * @author Rossen Stoyanchev * @since 5.0 * @see PathContainer */ public class PathPattern implements Comparable { private static final PathContainer EMPTY_PATH = PathContainer.parsePath(""); /** * Comparator that sorts patterns by specificity as follows: *
    *
  1. Null instances are last. *
  2. Catch-all patterns are last. *
  3. If both patterns are catch-all, consider the length (longer wins). *
  4. Compare wildcard and captured variable count (lower wins). *
  5. Consider length (longer wins) *
*/ public static final Comparator SPECIFICITY_COMPARATOR = Comparator.nullsLast( Comparator. comparingInt(p -> p.isCatchAll() ? 1 : 0) .thenComparingInt(p -> p.isCatchAll() ? scoreByNormalizedLength(p) : 0) .thenComparingInt(PathPattern::getScore) .thenComparingInt(PathPattern::scoreByNormalizedLength) ); /** The text of the parsed pattern. */ private final String patternString; /** The parser used to construct this pattern. */ private final PathPatternParser parser; /** The options to use to parse a pattern. */ private final PathContainer.Options pathOptions; /** If this pattern has no trailing slash, allow candidates to include one and still match successfully. */ private final boolean matchOptionalTrailingSeparator; /** Will this match candidates in a case sensitive way? (case sensitivity at parse time). */ private final boolean caseSensitive; /** First path element in the parsed chain of path elements for this pattern. */ @Nullable private final PathElement head; /** 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: *
    *
  • Captured variables are worth 1 *
  • Wildcard is worth 100 *
*/ private int score; /** Does the pattern end with {*...}. */ private boolean catchAll = false; PathPattern(String patternText, PathPatternParser parser, @Nullable PathElement head) { this.patternString = patternText; this.parser = parser; this.pathOptions = parser.getPathOptions(); this.matchOptionalTrailingSeparator = parser.isMatchOptionalTrailingSeparator(); this.caseSensitive = parser.isCaseSensitive(); this.head = head; // 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 String that was parsed to create this PathPattern. */ public String getPatternString() { return this.patternString; } /** * Whether the pattern string contains pattern syntax that would require * use of {@link #matches(PathContainer)}, or if it is a regular String that * could be compared directly to others. * @since 5.2 */ public boolean hasPatternSyntax() { return (this.score > 0 || this.patternString.indexOf('?') != -1); } /** * Whether this pattern matches the given path. * @param pathContainer the candidate path to attempt to match against * @return {@code true} if the path matches this pattern */ public boolean matches(PathContainer pathContainer) { if (this.head == null) { return !hasLength(pathContainer) || (this.matchOptionalTrailingSeparator && pathContainerIsJustSeparator(pathContainer)); } else if (!hasLength(pathContainer)) { if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) { pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty } else { return false; } } MatchingContext matchingContext = new MatchingContext(pathContainer, false); return this.head.matches(0, matchingContext); } /** * Match this pattern to the given URI path and return extracted URI template * variables as well as path parameters (matrix variables). * @param pathContainer the candidate path to attempt to match against * @return info object with the extracted variables, or {@code null} for no match */ @Nullable public PathMatchInfo matchAndExtract(PathContainer pathContainer) { if (this.head == null) { return (hasLength(pathContainer) && !(this.matchOptionalTrailingSeparator && pathContainerIsJustSeparator(pathContainer)) ? null : PathMatchInfo.EMPTY); } else if (!hasLength(pathContainer)) { if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) { pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty } else { return null; } } MatchingContext matchingContext = new MatchingContext(pathContainer, true); return this.head.matches(0, matchingContext) ? matchingContext.getPathMatchResult() : null; } /** * Match the beginning of the given path and return the remaining portion * not covered by this pattern. This is useful for matching nested routes * where the path is matched incrementally at each level. * @param pathContainer the candidate path to attempt to match against * @return info object with the match result or {@code null} for no match */ @Nullable public PathRemainingMatchInfo matchStartOfPath(PathContainer pathContainer) { if (this.head == null) { return new PathRemainingMatchInfo(pathContainer); } else if (!hasLength(pathContainer)) { return null; } MatchingContext matchingContext = new MatchingContext(pathContainer, true); matchingContext.setMatchAllowExtraPath(); boolean matches = this.head.matches(0, matchingContext); if (!matches) { return null; } else { PathRemainingMatchInfo info; if (matchingContext.remainingPathIndex == pathContainer.elements().size()) { info = new PathRemainingMatchInfo(EMPTY_PATH, matchingContext.getPathMatchResult()); } else { info = new PathRemainingMatchInfo(pathContainer.subPath(matchingContext.remainingPathIndex), matchingContext.getPathMatchResult()); } return info; } } /** * Determine the pattern-mapped part for the given path. *

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}'
  • *
*

Notes: *

    *
  • Assumes that {@link #matches} returns {@code true} for * the same path but does not enforce this. *
  • Duplicate occurrences of separators within the returned result are removed *
  • Leading and trailing separators are removed from the returned result *
* @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 PathContainer extractPathWithinPattern(PathContainer path) { List pathElements = path.elements(); int pathElementsCount = pathElements.size(); int startIndex = 0; // Find first path element that is not a separator or a literal (i.e. the first pattern based element) PathElement elem = this.head; while (elem != null) { if (elem.getWildcardCount() != 0 || elem.getCaptureCount() != 0) { break; } elem = elem.next; startIndex++; } if (elem == null) { // There is no pattern piece return PathContainer.parsePath(""); } // Skip leading separators that would be in the result while (startIndex < pathElementsCount && (pathElements.get(startIndex) instanceof Separator)) { startIndex++; } int endIndex = pathElements.size(); // Skip trailing separators that would be in the result while (endIndex > 0 && (pathElements.get(endIndex - 1) instanceof Separator)) { endIndex--; } boolean multipleAdjacentSeparators = false; for (int i = startIndex; i < (endIndex - 1); i++) { if ((pathElements.get(i) instanceof Separator) && (pathElements.get(i+1) instanceof Separator)) { multipleAdjacentSeparators=true; break; } } PathContainer resultPath = null; if (multipleAdjacentSeparators) { // Need to rebuild the path without the duplicate adjacent separators StringBuilder buf = new StringBuilder(); int i = startIndex; while (i < endIndex) { Element e = pathElements.get(i++); buf.append(e.value()); if (e instanceof Separator) { while (i < endIndex && (pathElements.get(i) instanceof Separator)) { i++; } } } resultPath = PathContainer.parsePath(buf.toString(), this.pathOptions); } else if (startIndex >= endIndex) { resultPath = PathContainer.parsePath(""); } else { resultPath = path.subPath(startIndex, endIndex); } return resultPath; } /** * 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(@Nullable PathPattern otherPattern) { int result = SPECIFICITY_COMPARATOR.compare(this, otherPattern); return (result == 0 && otherPattern != null ? this.patternString.compareTo(otherPattern.patternString) : result); } /** * Combine this pattern with another. */ public PathPattern combine(PathPattern pattern2string) { // If one of them is empty the result is the other. If both empty the result is "" if (!StringUtils.hasLength(this.patternString)) { if (!StringUtils.hasLength(pattern2string.patternString)) { return this.parser.parse(""); } else { return pattern2string; } } else if (!StringUtils.hasLength(pattern2string.patternString)) { return this; } // /* + /hotel => /hotel // /*.* + /*.html => /*.html // However: // /usr + /user => /usr/user // /{foo} + /bar => /{foo}/bar if (!this.patternString.equals(pattern2string.patternString) && this.capturedVariableCount == 0 && matches(PathContainer.parsePath(pattern2string.patternString))) { return pattern2string; } // /hotels/* + /booking => /hotels/booking // /hotels/* + booking => /hotels/booking if (this.endsWithSeparatorWildcard) { return this.parser.parse(concat( this.patternString.substring(0, this.patternString.length() - 2), pattern2string.patternString)); } // /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 || getSeparator() == '.') { return this.parser.parse(concat(this.patternString, pattern2string.patternString)); } // /*.html + /hotel => /hotel.html // /*.html + /hotel.* => /hotel.html String firstExtension = this.patternString.substring(starDotPos1 + 1); // looking for the first extension String p2string = pattern2string.patternString; int dotPos2 = p2string.indexOf('.'); String file2 = (dotPos2 == -1 ? p2string : p2string.substring(0, dotPos2)); String secondExtension = (dotPos2 == -1 ? "" : p2string.substring(dotPos2)); boolean firstExtensionWild = (firstExtension.equals(".*") || firstExtension.isEmpty()); boolean secondExtensionWild = (secondExtension.equals(".*") || secondExtension.isEmpty()); if (!firstExtensionWild && !secondExtensionWild) { throw new IllegalArgumentException( "Cannot combine patterns: " + this.patternString + " and " + pattern2string); } return this.parser.parse(file2 + (firstExtensionWild ? secondExtension : firstExtension)); } @Override public boolean equals(@Nullable Object other) { if (!(other instanceof PathPattern)) { return false; } PathPattern otherPattern = (PathPattern) other; return (this.patternString.equals(otherPattern.getPatternString()) && getSeparator() == otherPattern.getSeparator() && this.caseSensitive == otherPattern.caseSensitive); } @Override public int hashCode() { return (this.patternString.hashCode() + getSeparator()) * 17 + (this.caseSensitive ? 1 : 0); } @Override public String toString() { return this.patternString; } 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.pathOptions.separator(); } int getCapturedVariableCount() { return this.capturedVariableCount; } String toChainString() { StringJoiner stringJoiner = new StringJoiner(" "); PathElement pe = this.head; while (pe != null) { stringJoiner.add(pe.toString()); pe = pe.next; } return stringJoiner.toString(); } /** * Return the string form of the pattern built from walking the path element chain. * @return the string form of the pattern */ String computePatternString() { StringBuilder buf = new StringBuilder(); PathElement pe = this.head; while (pe != null) { buf.append(pe.getChars()); pe = pe.next; } return buf.toString(); } @Nullable PathElement getHeadSection() { return this.head; } /** * 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) == getSeparator()); boolean path2StartsWithSeparator = (path2.charAt(0) == getSeparator()); if (path1EndsWithSeparator && path2StartsWithSeparator) { return path1 + path2.substring(1); } else if (path1EndsWithSeparator || path2StartsWithSeparator) { return path1 + path2; } else { return path1 + getSeparator() + path2; } } /** * Return if the container is not null and has more than zero elements. * @param container a path container * @return {@code true} has more than zero elements */ private boolean hasLength(@Nullable PathContainer container) { return container != null && container.elements().size() > 0; } private static int scoreByNormalizedLength(PathPattern pattern) { return -pattern.getNormalizedLength(); } private boolean pathContainerIsJustSeparator(PathContainer pathContainer) { return pathContainer.value().length() == 1 && pathContainer.value().charAt(0) == getSeparator(); } /** * Holder for URI variables and path parameters (matrix variables) extracted * based on the pattern for a given matched path. */ public static class PathMatchInfo { private static final PathMatchInfo EMPTY = new PathMatchInfo(Collections.emptyMap(), Collections.emptyMap()); private final Map uriVariables; private final Map> matrixVariables; PathMatchInfo(Map uriVars, @Nullable Map> matrixVars) { this.uriVariables = Collections.unmodifiableMap(uriVars); this.matrixVariables = (matrixVars != null ? Collections.unmodifiableMap(matrixVars) : Collections.emptyMap()); } /** * Return the extracted URI variables. */ public Map getUriVariables() { return this.uriVariables; } /** * Return maps of matrix variables per path segment, keyed off by URI * variable name. */ public Map> getMatrixVariables() { return this.matrixVariables; } @Override public String toString() { return "PathMatchInfo[uriVariables=" + this.uriVariables + ", " + "matrixVariables=" + this.matrixVariables + "]"; } } /** * Holder for the result of a match on the start of a pattern. * Provides access to the remaining path not matched to the pattern as well * as any variables bound in that first part that was matched. */ public static class PathRemainingMatchInfo { private final PathContainer pathRemaining; private final PathMatchInfo pathMatchInfo; PathRemainingMatchInfo(PathContainer pathRemaining) { this(pathRemaining, PathMatchInfo.EMPTY); } PathRemainingMatchInfo(PathContainer pathRemaining, PathMatchInfo pathMatchInfo) { this.pathRemaining = pathRemaining; this.pathMatchInfo = pathMatchInfo; } /** * Return the part of a path that was not matched by a pattern. */ public PathContainer getPathRemaining() { return this.pathRemaining; } /** * Return variables that were bound in the part of the path that was * successfully matched or an empty map. */ public Map getUriVariables() { return this.pathMatchInfo.getUriVariables(); } /** * Return the path parameters for each bound variable. */ public Map> getMatrixVariables() { return this.pathMatchInfo.getMatrixVariables(); } } /** * 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 { final PathContainer candidate; final List pathElements; final int pathLength; @Nullable private Map extractedUriVariables; @Nullable private Map> extractedMatrixVariables; 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(PathContainer pathContainer, boolean extractVariables) { this.candidate = pathContainer; this.pathElements = pathContainer.elements(); this.pathLength = this.pathElements.size(); this.extractingVariables = extractVariables; } public void setMatchAllowExtraPath() { this.determineRemainingPath = true; } public boolean isMatchOptionalTrailingSeparator() { return matchOptionalTrailingSeparator; } public void set(String key, String value, MultiValueMap parameters) { if (this.extractedUriVariables == null) { this.extractedUriVariables = new HashMap<>(); } this.extractedUriVariables.put(key, value); if (!parameters.isEmpty()) { if (this.extractedMatrixVariables == null) { this.extractedMatrixVariables = new HashMap<>(); } this.extractedMatrixVariables.put(key, CollectionUtils.unmodifiableMultiValueMap(parameters)); } } public PathMatchInfo getPathMatchResult() { if (this.extractedUriVariables == null) { return PathMatchInfo.EMPTY; } else { return new PathMatchInfo(this.extractedUriVariables, this.extractedMatrixVariables); } } /** * Return if element at specified index is a separator. * @param pathIndex possible index of a separator * @return {@code true} if element is a separator */ boolean isSeparator(int pathIndex) { return this.pathElements.get(pathIndex) instanceof Separator; } /** * Return the decoded value of the specified element. * @param pathIndex path element index * @return the decoded value */ String pathElementValue(int pathIndex) { Element element = (pathIndex < this.pathLength) ? this.pathElements.get(pathIndex) : null; if (element instanceof PathContainer.PathSegment) { return ((PathContainer.PathSegment)element).valueToMatch(); } return ""; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy