PathPatternParser encodes patterns as it parses them

Before this commit there was no special handling for URL encoding
of the path pattern string coming into the path pattern parser. No
assumptions were made about it being in an encoded form or not.

With this change it is assumed incoming path patterns are not
encoded and as part of parsing the parser builds PathPattern
objects that include encoded elements. For example parsing "/f o"
will create a path pattern of the form "/f%20o". In this form
it can then be used to match against encoded paths.

Handling encoded characters is not trivial and has resulted in
some loss in matching speed but care has been taken to
avoid unnecessary creation of additional heap objects.  When
matching variables the variable values are return in a
decoded form. It is hoped the speed can be recovered, at least
for the common case of non-encoded incoming paths.

Issue: SPR-15640
This commit is contained in:
Andy Clement 2017-06-09 11:44:32 -07:00
parent c0550f7eb6
commit ff2af660cf
9 changed files with 332 additions and 44 deletions

View File

@ -56,8 +56,8 @@ class CaptureTheRestPathElement extends PathElement {
matchingContext.remainingPathIndex = matchingContext.candidateLength;
}
if (matchingContext.extractingVariables) {
matchingContext.set(variableName, new String(matchingContext.candidate, candidateIndex,
matchingContext.candidateLength - candidateIndex));
matchingContext.set(variableName, decode(new String(matchingContext.candidate, candidateIndex,
matchingContext.candidateLength - candidateIndex)));
}
return true;
}

View File

@ -16,9 +16,13 @@
package org.springframework.web.util.pattern;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.web.util.UriUtils;
/**
* A path element representing capturing a piece of the path as a variable. In the pattern
* '/foo/{bar}/goo' the {bar} is represented as a {@link CaptureVariablePathElement}. There
@ -74,10 +78,22 @@ class CaptureVariablePathElement extends PathElement {
return false;
}
String substringForDecoding = null;
CharSequence candidateCapture = null;
if (this.constraintPattern != null) {
// TODO possible optimization - only regex match if rest of pattern matches? Benefit likely to vary pattern to pattern
candidateCapture = new SubSequence(matchingContext.candidate, candidateIndex, nextPos);
if (includesPercent(matchingContext.candidate, candidateIndex, nextPos)) {
substringForDecoding = new String(matchingContext.candidate, candidateIndex, nextPos);
try {
candidateCapture = UriUtils.decode(substringForDecoding,StandardCharsets.UTF_8.name());
}
catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
else {
candidateCapture = new SubSequence(matchingContext.candidate, candidateIndex, nextPos);
}
Matcher matcher = constraintPattern.matcher(candidateCapture);
if (matcher.groupCount() != 0) {
throw new IllegalArgumentException(
@ -115,7 +131,8 @@ class CaptureVariablePathElement extends PathElement {
if (match && matchingContext.extractingVariables) {
matchingContext.set(this.variableName,
new String(matchingContext.candidate, candidateIndex, nextPos - candidateIndex));
candidateCapture != null ? candidateCapture.toString():
decode(new String(matchingContext.candidate, candidateIndex, nextPos - candidateIndex)));
}
return match;
}

View File

@ -16,10 +16,13 @@
package org.springframework.web.util.pattern;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.PatternSyntaxException;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.pattern.PatternParseException.PatternMessage;
/**
@ -162,7 +165,7 @@ class InternalPathPatternParser {
this.variableCaptureCount++;
}
else if (ch == ':') {
if (this.insideVariableCapture) {
if (this.insideVariableCapture && !this.isCaptureTheRestVariable) {
skipCaptureRegex();
this.insideVariableCapture = false;
this.variableCaptureCount++;
@ -304,6 +307,26 @@ class InternalPathPatternParser {
resetPathElementState();
}
private char[] getPathElementText(boolean encodeElement) {
char[] pathElementText = new char[this.pos - this.pathElementStart];
if (encodeElement) {
try {
String unencoded = new String(this.pathPatternData, this.pathElementStart, this.pos - this.pathElementStart);
String encoded = UriUtils.encodeFragment(unencoded, StandardCharsets.UTF_8.name());
pathElementText = encoded.toCharArray();
}
catch (UnsupportedEncodingException ex) {
// Should never happen...
throw new IllegalStateException(ex);
}
}
else {
System.arraycopy(this.pathPatternData, this.pathElementStart, pathElementText, 0,
this.pos - this.pathElementStart);
}
return pathElementText;
}
/**
* Used the knowledge built up whilst processing since the last path element to determine what kind of path
@ -314,10 +337,7 @@ class InternalPathPatternParser {
if (this.insideVariableCapture) {
throw new PatternParseException(this.pos, this.pathPatternData, PatternMessage.MISSING_CLOSE_CAPTURE);
}
char[] pathElementText = new char[this.pos - this.pathElementStart];
System.arraycopy(this.pathPatternData, this.pathElementStart, pathElementText, 0,
this.pos - this.pathElementStart);
PathElement newPE = null;
if (this.variableCaptureCount > 0) {
@ -325,12 +345,12 @@ class InternalPathPatternParser {
this.pathPatternData[this.pos - 1] == '}') {
if (this.isCaptureTheRestVariable) {
// It is {*....}
newPE = new CaptureTheRestPathElement(pathElementStart, pathElementText, separator);
newPE = new CaptureTheRestPathElement(pathElementStart, getPathElementText(false), separator);
}
else {
// It is a full capture of this element (possibly with constraint), for example: /foo/{abc}/
try {
newPE = new CaptureVariablePathElement(this.pathElementStart, pathElementText,
newPE = new CaptureVariablePathElement(this.pathElementStart, getPathElementText(false),
this.caseSensitive, this.separator);
}
catch (PatternSyntaxException pse) {
@ -347,8 +367,9 @@ class InternalPathPatternParser {
throw new PatternParseException(this.pathElementStart, this.pathPatternData,
PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT);
}
RegexPathElement newRegexSection = new RegexPathElement(this.pathElementStart, pathElementText,
this.caseSensitive, this.pathPatternData, this.separator);
RegexPathElement newRegexSection = new RegexPathElement(this.pathElementStart,
getPathElementText(false), this.caseSensitive,
this.pathPatternData, this.separator);
for (String variableName : newRegexSection.getVariableNames()) {
recordCapturedVariable(this.pathElementStart, variableName);
}
@ -361,16 +382,16 @@ class InternalPathPatternParser {
newPE = new WildcardPathElement(this.pathElementStart, this.separator);
}
else {
newPE = new RegexPathElement(this.pathElementStart, pathElementText,
newPE = new RegexPathElement(this.pathElementStart, getPathElementText(false),
this.caseSensitive, this.pathPatternData, this.separator);
}
}
else if (this.singleCharWildcardCount != 0) {
newPE = new SingleCharWildcardedPathElement(this.pathElementStart, pathElementText,
newPE = new SingleCharWildcardedPathElement(this.pathElementStart, getPathElementText(true),
this.singleCharWildcardCount, this.caseSensitive, this.separator);
}
else {
newPE = new LiteralPathElement(this.pathElementStart, pathElementText,
newPE = new LiteralPathElement(this.pathElementStart, getPathElementText(true),
this.caseSensitive, this.separator);
}
}

View File

@ -57,7 +57,11 @@ class LiteralPathElement extends PathElement {
if (this.caseSensitive) {
for (int i = 0; i < len; i++) {
if (matchingContext.candidate[candidateIndex++] != this.text[i]) {
return false;
// TODO unfortunate performance hit here on comparison when encoded data is the less likely case
if (i < 3 || matchingContext.candidate[candidateIndex-3] != '%' ||
Character.toUpperCase(matchingContext.candidate[candidateIndex-1]) != this.text[i]) {
return false;
}
}
}
}

View File

@ -16,6 +16,10 @@
package org.springframework.web.util.pattern;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.pattern.PathPattern.MatchingContext;
/**
@ -65,7 +69,7 @@ abstract class PathElement {
public abstract boolean matches(int candidatePos, MatchingContext matchingContext);
/**
* Return the length of the path element where captures are considered to be one character long.
* @return the length of the path element where captures are considered to be one character long.
*/
public abstract int getNormalizedLength();
@ -98,4 +102,50 @@ abstract class PathElement {
matchingContext.candidate[nextIndex] == this.separator);
}
/**
* Decode an input CharSequence if necessary.
* @param toDecode the input char sequence that should be decoded if necessary
* @returns the decoded result
*/
protected String decode(CharSequence toDecode) {
CharSequence decoded = toDecode;
if (includesPercent(toDecode)) {
try {
decoded = UriUtils.decode(toDecode.toString(), StandardCharsets.UTF_8.name());
}
catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
return decoded.toString();
}
/**
* @param char sequence of characters
* @param from start position (included in check)
* @param to end position (excluded from check)
* @return true if the chars array includes a '%' character between the specified positions
*/
protected boolean includesPercent(char[] chars, int from, int to) {
for (int i = from; i < to; i++) {
if (chars[i] == '%') {
return true;
}
}
return false;
}
/**
* @param chars string that may include a '%' character indicating it is encoded
* @return true if the string contains a '%' character
*/
protected boolean includesPercent(CharSequence chars) {
for (int i = 0, max = chars.length(); i < max; i++) {
if (chars.charAt(i) == '%') {
return true;
}
}
return false;
}
}

View File

@ -16,12 +16,15 @@
package org.springframework.web.util.pattern;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.util.UriUtils;
import org.springframework.web.util.pattern.PathPattern.MatchingContext;
/**
@ -39,7 +42,7 @@ class RegexPathElement extends PathElement {
private final String DEFAULT_VARIABLE_PATTERN = "(.*)";
private final char[] regex;
private char[] regex;
private final boolean caseSensitive;
@ -61,17 +64,20 @@ class RegexPathElement extends PathElement {
public Pattern buildPattern(char[] regex, char[] completePattern) {
StringBuilder patternBuilder = new StringBuilder();
String text = new String(regex);
StringBuilder encodedRegexBuilder = new StringBuilder();
Matcher matcher = GLOB_PATTERN.matcher(text);
int end = 0;
while (matcher.find()) {
patternBuilder.append(quote(text, end, matcher.start()));
patternBuilder.append(quote(text, end, matcher.start(), encodedRegexBuilder));
String match = matcher.group();
if ("?".equals(match)) {
patternBuilder.append('.');
encodedRegexBuilder.append('?');
}
else if ("*".equals(match)) {
patternBuilder.append(".*");
encodedRegexBuilder.append('*');
int pos = matcher.start();
if (pos < 1 || text.charAt(pos-1) != '.') {
// To be compatible with the AntPathMatcher comparator,
@ -80,6 +86,7 @@ class RegexPathElement extends PathElement {
}
}
else if (match.startsWith("{") && match.endsWith("}")) {
encodedRegexBuilder.append(match);
int colonIdx = match.indexOf(':');
if (colonIdx == -1) {
patternBuilder.append(DEFAULT_VARIABLE_PATTERN);
@ -106,7 +113,8 @@ class RegexPathElement extends PathElement {
end = matcher.end();
}
patternBuilder.append(quote(text, end, text.length()));
patternBuilder.append(quote(text, end, text.length(), encodedRegexBuilder));
this.regex = encodedRegexBuilder.toString().toCharArray();
if (this.caseSensitive) {
return Pattern.compile(patternBuilder.toString());
}
@ -119,17 +127,33 @@ class RegexPathElement extends PathElement {
return this.variableNames;
}
private String quote(String s, int start, int end) {
private String quote(String s, int start, int end, StringBuilder encodedRegexBuilder) {
if (start == end) {
return "";
}
return Pattern.quote(s.substring(start, end));
String substring = s.substring(start, end);
try {
String encodedSubString = UriUtils.encodePath(substring, StandardCharsets.UTF_8.name());
encodedRegexBuilder.append(encodedSubString);
}
catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
return Pattern.quote(substring);
}
@Override
public boolean matches(int candidateIndex, MatchingContext matchingContext) {
int pos = matchingContext.scanAhead(candidateIndex);
Matcher matcher = this.pattern.matcher(new SubSequence(matchingContext.candidate, candidateIndex, pos));
CharSequence textToMatch = null;
if (includesPercent(matchingContext.candidate, candidateIndex, pos)) {
textToMatch = decode(new SubSequence(matchingContext.candidate, candidateIndex, pos));
}
else {
textToMatch = new SubSequence(matchingContext.candidate, candidateIndex, pos);
}
Matcher matcher = this.pattern.matcher(textToMatch);
boolean matches = matcher.matches();
if (matches) {

View File

@ -65,8 +65,18 @@ class SingleCharWildcardedPathElement extends PathElement {
if (this.caseSensitive) {
for (int i = 0; i <this.len; i++) {
char t = this.text[i];
if (t != '?' && candidate[candidateIndex] != t) {
return false;
if (t == '?') {
if (candidate[candidateIndex] == '%') {
// encoded value, skip next two as well!
candidateIndex += 2;
}
}
else if (candidate[candidateIndex] != t) {
// TODO unfortunate performance hit here on comparison when encoded data is the less likely case
if (i < 3 || matchingContext.candidate[candidateIndex-2] != '%' ||
Character.toUpperCase(matchingContext.candidate[candidateIndex]) != this.text[i]) {
return false;
}
}
candidateIndex++;
}
@ -74,7 +84,13 @@ class SingleCharWildcardedPathElement extends PathElement {
else {
for (int i = 0; i < this.len; i++) {
char t = this.text[i];
if (t != '?' && Character.toLowerCase(candidate[candidateIndex]) != t) {
if (t == '?') {
if (candidate[candidateIndex] == '%') {
// encoded value, skip next two as well!
candidateIndex += 2;
}
}
else if (Character.toLowerCase(candidate[candidateIndex]) != t) {
return false;
}
candidateIndex++;
@ -117,7 +133,7 @@ class SingleCharWildcardedPathElement extends PathElement {
public String toString() {
return "SingleCharWildcarding(" + String.valueOf(this.text) + ")";
return "SingleCharWildcarded(" + String.valueOf(this.text) + ")";
}
}

View File

@ -64,8 +64,6 @@ public class PathPatternMatcherTests {
checkNoMatch("foo", "foobar");
checkMatches("/foo/bar", "/foo/bar");
checkNoMatch("/foo/bar", "/foo/baz");
// TODO Need more tests for escaped separators in path patterns and paths?
checkMatches("/foo\\/bar", "/foo\\/bar"); // chain string is Separator(/) Literal(foo\) Separator(/) Literal(bar)
}
@Test
@ -254,6 +252,108 @@ public class PathPatternMatcherTests {
assertEquals("a/",parse("/").getPathRemaining("/a/").getPathRemaining());
assertEquals("/bar",parse("/a{abc}").getPathRemaining("/a/bar").getPathRemaining());
}
@Test
public void encodingAndBoundVariablesCapturePathElement() {
checkCapture("{var}","f%20o","var","f o");
checkCapture("{var1}/{var2}","f%20o/f%7Co","var1","f o","var2","f|o");
checkCapture("{var1}/{var2}","f%20o/f%7co","var1","f o","var2","f|o"); // lower case encoding
// constraints
// - constraint is expressed in non encoded form
// - returned values are decoded
checkCapture("{var:foo}","foo","var","foo");
checkCapture("{var:f o}","f%20o","var","f o"); // constraint is expressed in non encoded form
checkCapture("{var:f.o}","f%20o","var","f o");
checkCapture("{var:f\\|o}","f%7co","var","f|o");
}
@Test
public void encodingAndBoundVariablesCaptureTheRestPathElement() {
checkCapture("/{*var}","/f%20o","var","/f o");
checkCapture("{var1}/{*var2}","f%20o/f%7Co","var1","f o","var2","/f|o");
// constraints - decoding happens for constraint checking but returned value is undecoded
checkCapture("/{*var}","/foo","var","/foo");
checkCapture("/{*var}","/f%20o","var","/f o"); // constraint is expressed in non encoded form
checkCapture("/{*var}","/f%20o","var","/f o");
checkCapture("/{*var}","/f%7co","var","/f|o");
}
@Test
public void encodingWithCaseSensitivity() {
// Concern here is that regardless of case sensitivity, %7c == %7C (for example)
// Need to test all path elements that might have literal components
PathPatternParser ppp = new PathPatternParser();
ppp.setCaseSensitive(true);
// LiteralPathElement
PathPattern pp = ppp.parse("/this is a |");
assertTrue(pp.matches("/this%20is%20a%20%7C"));
assertTrue(pp.matches("/this%20is%20a%20%7c"));
assertFalse(pp.matches("/thIs%20is%20a%20%7c"));
assertFalse(pp.matches("/thIs%20is%20a%20%7C"));
assertEquals("Separator(/) Literal(this%20is%20a%20%7C)",pp.toChainString());
// RegexPathElement
pp = ppp.parse("/{foo}this is a |");
assertTrue(pp.matches("/xxxthis%20is%20a%20%7C"));
assertTrue(pp.matches("/xxxthis%20is%20a%20%7c"));
assertFalse(pp.matches("/xxxXhis%20is%20a%20%7C"));
assertFalse(pp.matches("/xxxXhis%20is%20a%20%7c"));
assertEquals("Separator(/) Regex({foo}this%20is%20a%20%7C)",pp.toChainString());
// SingleCharWildcardedPathElement
pp = ppp.parse("/th?s is a |");
assertTrue(pp.matches("/this%20is%20a%20%7C"));
assertTrue(pp.matches("/this%20is%20a%20%7c"));
assertFalse(pp.matches("/xhis%20is%20a%20%7C"));
assertFalse(pp.matches("/xhis%20is%20a%20%7c"));
assertEquals("Separator(/) SingleCharWildcarded(th?s%20is%20a%20%7C)",pp.toChainString());
ppp = new PathPatternParser();
ppp.setCaseSensitive(false);
// LiteralPathElement
pp = ppp.parse("/this is a |");
assertTrue(pp.matches("/this%20is%20a%20%7C"));
assertTrue(pp.matches("/this%20is%20a%20%7c"));
assertTrue(pp.matches("/thIs%20is%20a%20%7C"));
assertTrue(pp.matches("/tHis%20is%20a%20%7c"));
// For case insensitive matches we make all the chars lower case
assertEquals("Separator(/) Literal(this%20is%20a%20%7c)",pp.toChainString());
// RegexPathElement
pp = ppp.parse("/{foo}this is a |");
assertTrue(pp.matches("/xxxthis%20is%20a%20%7C"));
assertTrue(pp.matches("/xxxthis%20is%20a%20%7c"));
assertTrue(pp.matches("/xxxThis%20is%20a%20%7C"));
assertTrue(pp.matches("/xxxThis%20is%20a%20%7c"));
assertEquals("Separator(/) Regex({foo}this%20is%20a%20%7C)",pp.toChainString());
// SingleCharWildcardedPathElement
pp = ppp.parse("/th?s is a |");
assertTrue(pp.matches("/this%20is%20a%20%7C"));
assertTrue(pp.matches("/this%20is%20a%20%7c"));
assertTrue(pp.matches("/This%20is%20a%20%7C"));
assertTrue(pp.matches("/This%20is%20a%20%7c"));
assertEquals("Separator(/) SingleCharWildcarded(th?s%20is%20a%20%7c)",pp.toChainString());
}
@Test
public void encodingAndBoundVariablesRegexPathElement() {
checkCapture("/{var1:f o}_ _{var2}","/f%20o_%20_f%7co","var1","f o","var2","f|o");
checkCapture("/{var1}_{var2}","/f%20o_foo","var1","f o","var2","foo");
checkCapture("/{var1}_ _{var2}","/f%20o_%20_f%7co","var1","f o","var2","f|o");
checkCapture("/{var1}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o");
checkCapture("/{var1:f o}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o");
}
@Test
public void encodedPaths() {
checkMatches("/foo bar", "/foo%20bar");
checkMatches("/foo*bar", "/fooboobar");
checkMatches("/f?o","/f%7co");
}
@Test
public void pathRemainingCornerCases_spr15336() {
@ -303,6 +403,7 @@ public class PathPatternMatcherTests {
checkNoMatch("tes?", "tsst");
checkMatches(".?.a", ".a.a");
checkNoMatch(".?.a", ".aba");
checkMatches("/f?o/bar","/f%20o/bar");
}
@Test

View File

@ -22,20 +22,7 @@ import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.springframework.web.util.pattern.CaptureTheRestPathElement;
import org.springframework.web.util.pattern.CaptureVariablePathElement;
import org.springframework.web.util.pattern.LiteralPathElement;
import org.springframework.web.util.pattern.PathElement;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
import org.springframework.web.util.pattern.PatternParseException;
import org.springframework.web.util.pattern.PatternParseException.PatternMessage;
import org.springframework.web.util.pattern.RegexPathElement;
import org.springframework.web.util.pattern.SeparatorPathElement;
import org.springframework.web.util.pattern.SingleCharWildcardedPathElement;
import org.springframework.web.util.pattern.WildcardPathElement;
import org.springframework.web.util.pattern.WildcardTheRestPathElement;
import static org.junit.Assert.*;
@ -82,7 +69,7 @@ public class PathPatternParserTests {
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("SingleCharWildcarded(?a?b?c)", checkStructure("?a?b?c").toChainString());
assertEquals("Wildcard(*)", checkStructure("*").toChainString());
assertEquals("WildcardTheRest(/**)", checkStructure("/**").toChainString());
}
@ -100,6 +87,7 @@ public class PathPatternParserTests {
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("/{*foobar:.*}/abc", 9, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR);
checkError("/{abc}{*foobar}", 1, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT);
checkError("/{abc}{*foobar}{foo}", 15, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST);
}
@ -193,6 +181,73 @@ public class PathPatternParserTests {
checkStructure("/{f}/");
checkStructure("/{foo}/{bar}/{wibble}");
}
/**
* During a parse some elements of the path are encoded for use when matching an encoded path.
* The patterns a developer writes are not encoded, hence we decode them when turning them
* into PathPattern objects. The encoding is visible through the toChainString() method.
*/
@Test
public void encodingDuringParse() throws Exception {
PathPattern pp;
// CaptureTheRest
pp = parse("/{*var}");
assertEquals("CaptureTheRest(/{*var})",pp.toChainString());
// CaptureVariable
pp = parse("/{var}");
assertEquals("Separator(/) CaptureVariable({var})",pp.toChainString());
// Literal
pp = parse("/foo bar/b_oo");
assertEquals("Separator(/) Literal(foo%20bar) Separator(/) Literal(b_oo)",pp.toChainString());
pp = parse("foo:bar");
assertEquals("Literal(foo:bar)",pp.toChainString());
// Regex
pp = parse("{foo}_{bar}");
assertEquals("Regex({foo}_{bar})",pp.toChainString());
pp = parse("{foo}_ _{bar}");
assertEquals("Regex({foo}_%20_{bar})",pp.toChainString());
// Separator
pp = parse("/");
assertEquals("Separator(/)",pp.toChainString());
// SingleCharWildcarded
pp = parse("/foo?bar");
assertEquals("Separator(/) SingleCharWildcarded(foo?bar)",pp.toChainString());
pp = parse("/f o?bar");
assertEquals("Separator(/) SingleCharWildcarded(f%20o?bar)",pp.toChainString());
// Wildcard
pp = parse("/foo*bar");
assertEquals("Separator(/) Regex(foo*bar)",pp.toChainString());
pp = parse("f oo:*bar");
assertEquals("Regex(f%20oo:*bar)",pp.toChainString());
pp = parse("/f oo:*bar");
assertEquals("Separator(/) Regex(f%20oo:*bar)",pp.toChainString());
pp = parse("/f|!oo:*bar");
assertEquals("Separator(/) Regex(f%7C!oo:*bar)",pp.toChainString());
// WildcardTheRest
pp = parse("/**");
assertEquals("WildcardTheRest(/**)",pp.toChainString());
}
@Test
public void encodingWithConstraints() {
// Constraint regex expressions are not URL encoded
PathPattern pp = parse("/{var:f o}");
assertEquals("Separator(/) CaptureVariable({var:f o})",pp.toChainString());
pp = parse("/{var:f o}_");
assertEquals("Separator(/) Regex({var:f o}_)",pp.toChainString());
pp = parse("{foo:f o}_ _{bar:b\\|o}");
assertEquals("Regex({foo:f o}_%20_{bar:b\\|o})",pp.toChainString());
}
@Test
public void completeCaptureWithConstraints() {