com.google.api.pathtemplate.PathTemplate Maven / Gradle / Ivy
Show all versions of api-common Show documentation
/*
* Copyright 2016, Google Inc.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.google.api.pathtemplate;
import com.google.auto.value.AutoValue;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* Represents a path template.
*
* Templates use the syntax of the API platform; see the protobuf of HttpRule for details. A
* template consists of a sequence of literals, wildcards, and variable bindings, where each binding
* can have a sub-path. A string representation can be parsed into an instance of {@link
* PathTemplate}, which can then be used to perform matching and instantiation.
*
*
Matching and instantiation deals with unescaping and escaping using URL encoding rules. For
* example, if a template variable for a single segment is instantiated with a string like {@code
* "a/b"}, the slash will be escaped to {@code "%2f"}. (Note that slash will not be escaped for a
* multiple-segment variable, but other characters will). The literals in the template itself are
* not escaped automatically, and must be already URL encoded.
*
*
Here is an example for a template using simple variables:
*
*
{@code
* PathTemplate template = PathTemplate.create("v1/shelves/{shelf}/books/{book}");
* assert template.matches("v2/shelves") == false;
* Map<String, String> values = template.match("v1/shelves/s1/books/b1");
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put("shelf", "s1");
* expectedValues.put("book", "b1");
* assert values.equals(expectedValues);
* assert template.instantiate(values).equals("v1/shelves/s1/books/b1");
* }
*
* Templates can use variables which match sub-paths. Example:
*
* {@code
* PathTemplate template = PathTemplate.create("v1/{name=shelves/*/books/*}"};
* assert template.match("v1/shelves/books/b1") == null;
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put("name", "shelves/s1/books/b1");
* assert template.match("v1/shelves/s1/books/b1").equals(expectedValues);
* }
*
* Path templates can also be used with only wildcards. Each wildcard is associated with an implicit
* variable {@code $n}, where n is the zero-based position of the wildcard. Example:
*
* {@code
* PathTemplate template = PathTemplate.create("shelves/*/books/*"};
* assert template.match("shelves/books/b1") == null;
* Map<String, String> values = template.match("v1/shelves/s1/books/b1");
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put("$0", s1");
* expectedValues.put("$1", "b1");
* assert values.equals(expectedValues);
* }
*
* Paths input to matching can use URL relative syntax to indicate a host name by prefixing the host
* name, as in {@code //somewhere.io/some/path}. The host name is matched into the special variable
* {@link #HOSTNAME_VAR}. Patterns are agnostic about host names, and the same pattern can be used
* for URL relative syntax and simple path syntax:
*
* {@code
* PathTemplate template = PathTemplate.create("shelves/*"};
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put(PathTemplate.HOSTNAME_VAR, "//somewhere.io");
* expectedValues.put("$0", s1");
* assert template.match("//somewhere.io/shelves/s1").equals(expectedValues);
* expectedValues.clear();
* expectedValues.put("$0", s1");
* assert template.match("shelves/s1").equals(expectedValues);
* }
*
* For the representation of a resource name see {@link TemplatedResourceName}, which is
* based on path templates.
*/
public class PathTemplate {
/**
* A constant identifying the special variable used for endpoint bindings in the result of {@link
* #matchFromFullName(String)}. It may also contain protocol string, if its provided in the input.
*/
public static final String HOSTNAME_VAR = "$hostname";
// A regexp to match a custom verb at the end of a path.
private static final Pattern CUSTOM_VERB_PATTERN = Pattern.compile(":([^/*}{=]+)$");
// A regex to match a hostname with or without protocol.
private static final Pattern HOSTNAME_PATTERN = Pattern.compile("^(\\w+:)?//");
// A splitter on slash.
private static final Splitter SLASH_SPLITTER = Splitter.on('/').trimResults();
// A regex to match the valid complex resource ID delimiters.
private static final Pattern COMPLEX_DELIMITER_PATTERN = Pattern.compile("[_\\-\\.~]");
// A regex to match multiple complex resource ID delimiters.
private static final Pattern MULTIPLE_COMPLEX_DELIMITER_PATTERN =
Pattern.compile("\\}[_\\-\\.~]{2,}\\{");
// A regex to match a missing complex resource ID delimiter.
private static final Pattern MISSING_COMPLEX_DELIMITER_PATTERN = Pattern.compile("\\}\\{");
// A regex to match invalid complex resource ID delimiters.
private static final Pattern INVALID_COMPLEX_DELIMITER_PATTERN =
Pattern.compile("\\}[^_\\-\\.~]\\{");
// A regex to match a closing segment (end brace) followed by one complex resource ID delimiter.
private static final Pattern END_SEGMENT_COMPLEX_DELIMITER_PATTERN =
Pattern.compile("\\}[_\\-\\.~]{1}");
// Helper Types
// ============
/** Specifies a path segment kind. */
enum SegmentKind {
/** A literal path segment. */
LITERAL,
/** A custom verb. Can only appear at the end of path. */
CUSTOM_VERB,
/** A simple wildcard ('*'). */
WILDCARD,
/** A path wildcard ('**'). */
PATH_WILDCARD,
/** A field binding start. */
BINDING,
/** A field binding end. */
END_BINDING,
}
/** Specifies a path segment. */
@AutoValue
abstract static class Segment {
/** A constant for the WILDCARD segment. */
private static final Segment WILDCARD = create(SegmentKind.WILDCARD, "*");
/** A constant for the PATH_WILDCARD segment. */
private static final Segment PATH_WILDCARD = create(SegmentKind.PATH_WILDCARD, "**");
/** A constant for the END_BINDING segment. */
private static final Segment END_BINDING = create(SegmentKind.END_BINDING, "");
/** Creates a segment of given kind and value. */
private static Segment create(SegmentKind kind, String value) {
return new AutoValue_PathTemplate_Segment(kind, value, "");
}
/** Creates a segment of given kind, value, and complex separator. */
private static Segment create(SegmentKind kind, String value, String complexSeparator) {
return new AutoValue_PathTemplate_Segment(kind, value, complexSeparator);
}
private static Segment wildcardCreate(String complexSeparator) {
return new AutoValue_PathTemplate_Segment(
SegmentKind.WILDCARD,
"*",
!complexSeparator.isEmpty() && COMPLEX_DELIMITER_PATTERN.matcher(complexSeparator).find()
? complexSeparator
: "");
}
/** The path segment kind. */
abstract SegmentKind kind();
/**
* The value for the segment. For literals, custom verbs, and wildcards, this reflects the value
* as it appears in the template. For bindings, this represents the variable of the binding.
*/
abstract String value();
abstract String complexSeparator();
/** Returns true of this segment is one of the wildcards, */
boolean isAnyWildcard() {
return kind() == SegmentKind.WILDCARD || kind() == SegmentKind.PATH_WILDCARD;
}
String separator() {
switch (kind()) {
case CUSTOM_VERB:
return ":";
case END_BINDING:
return "";
default:
return "/";
}
}
}
/**
* Creates a path template from a string. The string must satisfy the syntax of path templates of
* the API platform; see HttpRule's proto source.
*
* @throws ValidationException if there are errors while parsing the template.
*/
public static PathTemplate create(String template) {
return create(template, true);
}
/**
* Creates a path template from a string. The string must satisfy the syntax of path templates of
* the API platform; see HttpRule's proto source. Url encoding of template variables is disabled.
*
* @throws ValidationException if there are errors while parsing the template.
*/
public static PathTemplate createWithoutUrlEncoding(String template) {
return create(template, false);
}
private static PathTemplate create(String template, boolean urlEncoding) {
return new PathTemplate(parseTemplate(template), urlEncoding);
}
// Instance State and Methods
// ==========================
// List of segments of this template.
private final ImmutableList segments;
// Map from variable names to bindings in the template.
private final ImmutableMap bindings;
// Control use of URL encoding
private final boolean urlEncoding;
private PathTemplate(Iterable segments, boolean urlEncoding) {
this.segments = ImmutableList.copyOf(segments);
if (this.segments.isEmpty()) {
throw new ValidationException("template cannot be empty.");
}
Map bindings = Maps.newLinkedHashMap();
for (Segment seg : this.segments) {
if (seg.kind() == SegmentKind.BINDING) {
if (bindings.containsKey(seg.value())) {
throw new ValidationException("Duplicate binding '%s'", seg.value());
}
bindings.put(seg.value(), seg);
}
}
this.bindings = ImmutableMap.copyOf(bindings);
this.urlEncoding = urlEncoding;
}
/** Returns the set of variable names used in the template. */
public Set vars() {
return bindings.keySet();
}
/**
* Returns a template for the parent of this template.
*
* @throws ValidationException if the template has no parent.
*/
public PathTemplate parentTemplate() {
int i = segments.size();
Segment seg = segments.get(--i);
if (seg.kind() == SegmentKind.END_BINDING) {
while (i > 0 && segments.get(--i).kind() != SegmentKind.BINDING) {}
}
if (i == 0) {
throw new ValidationException("template does not have a parent");
}
return new PathTemplate(segments.subList(0, i), urlEncoding);
}
/**
* Returns a template where all variable bindings have been replaced by wildcards, but which is
* equivalent regards matching to this one.
*/
public PathTemplate withoutVars() {
StringBuilder result = new StringBuilder();
ListIterator iterator = segments.listIterator();
boolean start = true;
while (iterator.hasNext()) {
Segment seg = iterator.next();
switch (seg.kind()) {
case BINDING:
case END_BINDING:
break;
default:
if (!start) {
result.append(seg.separator());
} else {
start = false;
}
result.append(seg.value());
}
}
return create(result.toString(), urlEncoding);
}
/**
* Returns a path template for the sub-path of the given variable. Example:
*
* {@code
* PathTemplate template = PathTemplate.create("v1/{name=shelves/*/books/*}");
* assert template.subTemplate("name").toString().equals("shelves/*/books/*");
* }
*
* The returned template will never have named variables, but only wildcards, which are dealt with
* in matching and instantiation using '$n'-variables. See the documentation of {@link
* #match(String)} and {@link #instantiate(Map)}, respectively.
*
* For a variable which has no sub-path, this returns a path template with a single wildcard
* ('*').
*
* @throws ValidationException if the variable does not exist in the template.
*/
public PathTemplate subTemplate(String varName) {
List sub = Lists.newArrayList();
boolean inBinding = false;
for (Segment seg : segments) {
if (seg.kind() == SegmentKind.BINDING && seg.value().equals(varName)) {
inBinding = true;
} else if (inBinding) {
if (seg.kind() == SegmentKind.END_BINDING) {
return PathTemplate.create(toSyntax(sub, true), urlEncoding);
} else {
sub.add(seg);
}
}
}
throw new ValidationException(
String.format("Variable '%s' is undefined in template '%s'", varName, this.toRawString()));
}
/** Returns true of this template ends with a literal. */
public boolean endsWithLiteral() {
return segments.get(segments.size() - 1).kind() == SegmentKind.LITERAL;
}
/** Returns true of this template ends with a custom verb. */
public boolean endsWithCustomVerb() {
return segments.get(segments.size() - 1).kind() == SegmentKind.CUSTOM_VERB;
}
/**
* Creates a resource name from this template and a path.
*
* @throws ValidationException if the path does not match the template.
*/
public TemplatedResourceName parse(String path) {
return TemplatedResourceName.create(this, path);
}
/**
* Returns the name of a singleton variable used by this template. If the template does not
* contain a single variable, returns null.
*/
@Nullable
public String singleVar() {
if (bindings.size() == 1) {
return bindings.entrySet().iterator().next().getKey();
}
return null;
}
// Template Matching
// =================
/**
* Throws a ValidationException if the template doesn't match the path. The exceptionMessagePrefix
* parameter will be prepended to the ValidationException message.
*/
public void validate(String path, String exceptionMessagePrefix) {
if (!matches(path)) {
throw new ValidationException(
String.format(
"%s: Parameter \"%s\" must be in the form \"%s\"",
exceptionMessagePrefix, path, this.toString()));
}
}
/**
* Matches the path, returning a map from variable names to matched values. All matched values
* will be properly unescaped using URL encoding rules. If the path does not match the template,
* throws a ValidationException. The exceptionMessagePrefix parameter will be prepended to the
* ValidationException message.
*
* If the path starts with '//', the first segment will be interpreted as a host name and
* stored in the variable {@link #HOSTNAME_VAR}.
*
*
See the {@link PathTemplate} class documentation for examples.
*
*
For free wildcards in the template, the matching process creates variables named '$n', where
* 'n' is the wildcard's position in the template (starting at n=0). For example:
*
*
{@code
* PathTemplate template = PathTemplate.create("shelves/*/books/*");
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put("$0", "s1");
* expectedValues.put("$1", "b1");
* assert template.validatedMatch("shelves/s1/books/b2", "User exception string")
* .equals(expectedValues);
* expectedValues.clear();
* expectedValues.put(HOSTNAME_VAR, "//somewhere.io");
* expectedValues.put("$0", "s1");
* expectedValues.put("$1", "b1");
* assert template.validatedMatch("//somewhere.io/shelves/s1/books/b2", "User exception string")
* .equals(expectedValues);
* }
*
* All matched values will be properly unescaped using URL encoding rules (so long as URL encoding
* has not been disabled by the {@link #createWithoutUrlEncoding} method).
*/
public Map validatedMatch(String path, String exceptionMessagePrefix) {
Map matchMap = match(path);
if (matchMap == null) {
throw new ValidationException(
String.format(
"%s: Parameter \"%s\" must be in the form \"%s\"",
exceptionMessagePrefix, path, this.toString()));
}
return matchMap;
}
/** Returns true if the template matches the path. */
public boolean matches(String path) {
return match(path) != null;
}
/**
* Matches the path, returning a map from variable names to matched values. All matched values
* will be properly unescaped using URL encoding rules. If the path does not match the template,
* null is returned.
*
* If the path starts with '//', the first segment will be interpreted as a host name and
* stored in the variable {@link #HOSTNAME_VAR}.
*
*
See the {@link PathTemplate} class documentation for examples.
*
*
For free wildcards in the template, the matching process creates variables named '$n', where
* 'n' is the wildcard's position in the template (starting at n=0). For example:
*
*
{@code
* PathTemplate template = PathTemplate.create("shelves/*/books/*");
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put("$0", "s1");
* expectedValues.put("$1", "b1");
* assert template.match("shelves/s1/books/b2").equals(expectedValues);
* expectedValues.clear();
* expectedValues.put(HOSTNAME_VAR, "//somewhere.io");
* expectedValues.put("$0", "s1");
* expectedValues.put("$1", "b1");
* assert template.match("//somewhere.io/shelves/s1/books/b2").equals(expectedValues);
* }
*
* All matched values will be properly unescaped using URL encoding rules (so long as URL encoding
* has not been disabled by the {@link #createWithoutUrlEncoding} method).
*/
@Nullable
public Map match(String path) {
return match(path, false);
}
/**
* Matches the path, where the first segment is interpreted as the host name regardless of whether
* it starts with '//' or not. Example:
*
* {@code
* Map<String, String> expectedValues = new HashMap<>();
* expectedValues.put(HOSTNAME_VAR, "//somewhere.io");
* expectedValues.put("name", "shelves/s1");
* assert template("{name=shelves/*}").matchFromFullName("somewhere.io/shelves/s1")
* .equals(expectedValues);
* }
*/
@Nullable
public Map matchFromFullName(String path) {
return match(path, true);
}
// Matches a path.
private Map match(String path, boolean forceHostName) {
// Quick check for trailing custom verb.
Segment last = segments.get(segments.size() - 1);
if (last.kind() == SegmentKind.CUSTOM_VERB) {
Matcher matcher = CUSTOM_VERB_PATTERN.matcher(path);
if (!matcher.find() || !decodeUrl(matcher.group(1)).equals(last.value())) {
return null;
}
path = path.substring(0, matcher.start(0));
}
Matcher matcher = HOSTNAME_PATTERN.matcher(path);
boolean withHostName = matcher.find();
if (withHostName) {
path = matcher.replaceFirst("");
}
List input = SLASH_SPLITTER.splitToList(path);
int inPos = 0;
Map values = Maps.newLinkedHashMap();
if (withHostName || forceHostName) {
if (input.isEmpty()) {
return null;
}
String hostName = input.get(inPos++);
if (withHostName) {
// Put the // back, so we can distinguish this case from forceHostName.
hostName = matcher.group(0) + hostName;
}
values.put(HOSTNAME_VAR, hostName);
}
if (withHostName) {
inPos = alignInputToAlignableSegment(input, inPos, segments.get(0));
}
if (!match(input, inPos, segments, 0, values)) {
return null;
}
return ImmutableMap.copyOf(values);
}
// Aligns input to start of literal value of literal or binding segment if input contains
// hostname.
private int alignInputToAlignableSegment(List input, int inPos, Segment segment) {
switch (segment.kind()) {
case BINDING:
inPos = alignInputPositionToLiteral(input, inPos, segment.value() + "s");
return inPos + 1;
case LITERAL:
return alignInputPositionToLiteral(input, inPos, segment.value());
}
return inPos;
}
// Aligns input to start of literal value if input contains hostname.
private int alignInputPositionToLiteral(
List input, int inPos, String literalSegmentValue) {
for (; inPos < input.size(); inPos++) {
if (literalSegmentValue.equals(input.get(inPos))) {
return inPos;
}
}
return inPos;
}
// Tries to match the input based on the segments at given positions. Returns a boolean
// indicating whether the match was successful.
private boolean match(
List input,
int inPos,
List segments,
int segPos,
Map values) {
String currentVar = null;
List modifiableInput = new ArrayList<>(input);
while (segPos < segments.size()) {
Segment seg = segments.get(segPos++);
switch (seg.kind()) {
case END_BINDING:
// End current variable binding scope.
currentVar = null;
continue;
case BINDING:
// Start variable binding scope.
currentVar = seg.value();
continue;
case CUSTOM_VERB:
// This is the final segment, and this check should have already been performed by the
// caller. The matching value is no longer present in the input.
break;
case LITERAL:
case WILDCARD:
if (inPos >= modifiableInput.size()) {
// End of input
return false;
}
// Check literal match.
String next = decodeUrl(modifiableInput.get(inPos++));
if (seg.kind() == SegmentKind.LITERAL) {
if (!seg.value().equals(next)) {
// Literal does not match.
return false;
}
}
if (seg.kind() == SegmentKind.WILDCARD && !seg.complexSeparator().isEmpty()) {
// Parse the complex resource separators one by one.
int complexSeparatorIndex = next.indexOf(seg.complexSeparator());
if (complexSeparatorIndex >= 0) {
modifiableInput.add(inPos, next.substring(complexSeparatorIndex + 1));
next = next.substring(0, complexSeparatorIndex);
modifiableInput.set(inPos - 1, next);
} else {
// No complex resource ID separator found in the literal when we expected one.
return false;
}
}
if (currentVar != null) {
// Create or extend current match
values.put(currentVar, concatCaptures(values.get(currentVar), next));
}
break;
case PATH_WILDCARD:
// Compute the number of additional input the ** can consume. This
// is possible because we restrict patterns to have only one **.
int segsToMatch = 0;
for (int i = segPos; i < segments.size(); i++) {
switch (segments.get(i).kind()) {
case BINDING:
case END_BINDING:
case CUSTOM_VERB:
// These segments do not actually consume any input.
continue;
default:
segsToMatch++;
}
}
int available = (modifiableInput.size() - inPos) - segsToMatch;
// If this segment is empty, make sure it is still captured.
if (available == 0 && !values.containsKey(currentVar)) {
values.put(currentVar, "");
}
while (available-- > 0) {
values.put(
currentVar,
concatCaptures(values.get(currentVar), decodeUrl(modifiableInput.get(inPos++))));
}
}
}
return inPos == modifiableInput.size();
}
private static String concatCaptures(@Nullable String cur, String next) {
return cur == null ? next : cur + "/" + next;
}
// Template Instantiation
// ======================
/**
* Instantiate the template based on the given variable assignment. Performs proper URL escaping
* of variable assignments.
*
* Note that free wildcards in the template must have bindings of '$n' variables, where 'n' is
* the position of the wildcard (starting at 0). See the documentation of {@link #match(String)}
* for details.
*
* @throws ValidationException if a variable occurs in the template without a binding.
*/
public String instantiate(Map values) {
return instantiate(values, false);
}
/** Shortcut for {@link #instantiate(Map)} with a vararg parameter for keys and values. */
public String instantiate(String... keysAndValues) {
ImmutableMap.Builder builder = ImmutableMap.builder();
for (int i = 0; i < keysAndValues.length; i += 2) {
builder.put(keysAndValues[i], keysAndValues[i + 1]);
}
return instantiate(builder.build());
}
/**
* Same like {@link #instantiate(Map)} but allows for unbound variables, which are substituted
* using their original syntax. Example:
*
* {@code
* PathTemplate template = PathTemplate.create("v1/shelves/{shelf}/books/{book}");
* Map<String, String> partialMap = new HashMap<>();
* partialMap.put("shelf", "s1");
* assert template.instantiatePartial(partialMap).equals("v1/shelves/s1/books/{book}");
* }
*
* The result of this call can be used to create a new template.
*/
public String instantiatePartial(Map values) {
return instantiate(values, true);
}
private String instantiate(Map values, boolean allowPartial) {
StringBuilder result = new StringBuilder();
if (values.containsKey(HOSTNAME_VAR)) {
result.append(values.get(HOSTNAME_VAR));
result.append('/');
}
boolean continueLast = true; // Whether to not append separator
boolean skip = false; // Whether we are substituting a binding and segments shall be skipped.
ListIterator iterator = segments.listIterator();
String prevSeparator = "";
while (iterator.hasNext()) {
Segment seg = iterator.next();
if (!skip && !continueLast) {
String separator =
prevSeparator.isEmpty() || !iterator.hasNext() ? seg.separator() : prevSeparator;
result.append(separator);
prevSeparator = seg.complexSeparator().isEmpty() ? seg.separator() : seg.complexSeparator();
}
continueLast = false;
switch (seg.kind()) {
case BINDING:
String var = seg.value();
String value = values.get(seg.value());
if (value == null) {
if (!allowPartial) {
throw new ValidationException(
String.format("Unbound variable '%s'. Bindings: %s", var, values));
}
// Append pattern to output
if (var.startsWith("$")) {
// Eliminate positional variable.
result.append(iterator.next().value());
iterator.next();
continue;
}
result.append('{');
result.append(seg.value());
result.append('=');
continueLast = true;
continue;
}
Segment next = iterator.next();
Segment nextNext = iterator.next();
boolean pathEscape =
next.kind() == SegmentKind.PATH_WILDCARD
|| nextNext.kind() != SegmentKind.END_BINDING;
restore(iterator, iterator.nextIndex() - 2);
if (!pathEscape) {
result.append(encodeUrl(value));
} else {
// For a path wildcard or path of length greater 1, split the value and escape
// every sub-segment.
boolean first = true;
for (String subSeg : SLASH_SPLITTER.split(value)) {
if (!first) {
result.append('/');
}
first = false;
result.append(encodeUrl(subSeg));
}
}
skip = true;
continue;
case END_BINDING:
if (!skip) {
result.append('}');
}
skip = false;
continue;
default:
if (!skip) {
result.append(seg.value());
}
}
}
return result.toString();
}
// Positional Matching and Instantiation
// =====================================
/**
* Instantiates the template from the given positional parameters. The template must not be build
* from named bindings, but only contain wildcards. Each parameter position corresponds to a
* wildcard of the according position in the template.
*/
public String encode(String... values) {
ImmutableMap.Builder builder = ImmutableMap.builder();
int i = 0;
for (String value : values) {
builder.put("$" + i++, value);
}
// We will get an error if there are named bindings which are not reached by values.
return instantiate(builder.build());
}
/**
* Matches the template into a list of positional values. The template must not be build from
* named bindings, but only contain wildcards. For each wildcard in the template, a value is
* returned at corresponding position in the list.
*/
public List decode(String path) {
Map match = match(path);
if (match == null) {
throw new IllegalArgumentException(
String.format("template '%s' does not match '%s'", this, path));
}
List result = Lists.newArrayList();
for (Map.Entry entry : match.entrySet()) {
String key = entry.getKey();
if (!key.startsWith("$")) {
throw new IllegalArgumentException("template must not contain named bindings");
}
int i = Integer.parseInt(key.substring(1));
while (result.size() <= i) {
result.add("");
}
result.set(i, entry.getValue());
}
return ImmutableList.copyOf(result);
}
// Template Parsing
// ================
private static ImmutableList parseTemplate(String template) {
// Skip useless leading slash.
if (template.startsWith("/")) {
template = template.substring(1);
}
// Extract trailing custom verb.
Matcher matcher = CUSTOM_VERB_PATTERN.matcher(template);
String customVerb = null;
if (matcher.find()) {
customVerb = matcher.group(1);
template = template.substring(0, matcher.start(0));
}
ImmutableList.Builder builder = ImmutableList.builder();
String varName = null;
int freeWildcardCounter = 0;
int pathWildCardBound = 0;
for (String seg : Splitter.on('/').trimResults().split(template)) {
// Handle _deleted-topic_ for PubSub.
if (seg.equals("_deleted-topic_")) {
builder.add(Segment.create(SegmentKind.LITERAL, seg));
continue;
}
boolean isLastSegment = (template.indexOf(seg) + seg.length()) == template.length();
boolean isCollectionWildcard = !isLastSegment && (seg.equals("-") || seg.equals("-}"));
if (!isCollectionWildcard && isSegmentBeginOrEndInvalid(seg)) {
throw new ValidationException("parse error: invalid begin or end character in '%s'", seg);
}
// Disallow zero or multiple delimiters between variable names.
if (MULTIPLE_COMPLEX_DELIMITER_PATTERN.matcher(seg).find()
|| MISSING_COMPLEX_DELIMITER_PATTERN.matcher(seg).find()) {
throw new ValidationException(
"parse error: missing or 2+ consecutive delimiter characters in '%s'", seg);
}
// If segment starts with '{', a binding group starts.
boolean bindingStarts = seg.startsWith("{");
boolean implicitWildcard = false;
boolean complexDelimiterFound = false;
if (bindingStarts) {
if (varName != null) {
throw new ValidationException("parse error: nested binding in '%s'", template);
}
seg = seg.substring(1);
// Check for invalid complex resource ID delimiters.
if (INVALID_COMPLEX_DELIMITER_PATTERN.matcher(seg).find()) {
throw new ValidationException(
"parse error: invalid complex resource ID delimiter character in '%s'", seg);
}
Matcher complexPatternDelimiterMatcher = END_SEGMENT_COMPLEX_DELIMITER_PATTERN.matcher(seg);
complexDelimiterFound = !isCollectionWildcard && complexPatternDelimiterMatcher.find();
// Look for complex resource names.
// Need to handle something like "{user_a}~{user_b}".
if (complexDelimiterFound) {
builder.addAll(parseComplexResourceId(seg));
} else {
int i = seg.indexOf('=');
if (i <= 0) {
// Possibly looking at something like "{name}" with implicit wildcard.
if (seg.endsWith("}")) {
// Remember to add an implicit wildcard later.
implicitWildcard = true;
varName = seg.substring(0, seg.length() - 1).trim();
seg = seg.substring(seg.length() - 1).trim();
} else {
throw new ValidationException(
"parse error: invalid binding syntax in '%s'", template);
}
} else if (seg.indexOf('-') <= 0 && isCollectionWildcard) {
implicitWildcard = true;
} else {
// Looking at something like "{name=wildcard}".
varName = seg.substring(0, i).trim();
seg = seg.substring(i + 1).trim();
}
builder.add(Segment.create(SegmentKind.BINDING, varName));
}
}
if (!complexDelimiterFound) {
// If segment ends with '}', a binding group ends. Remove the brace and remember.
boolean bindingEnds = seg.endsWith("}");
if (bindingEnds) {
seg = seg.substring(0, seg.length() - 1).trim();
}
// Process the segment, after stripping off "{name=.." and "..}".
switch (seg) {
case "**":
case "*":
if ("**".equals(seg)) {
pathWildCardBound++;
}
Segment wildcard = seg.length() == 2 ? Segment.PATH_WILDCARD : Segment.WILDCARD;
if (varName == null) {
// Not in a binding, turn wildcard into implicit binding.
// "*" => "{$n=*}"
builder.add(Segment.create(SegmentKind.BINDING, "$" + freeWildcardCounter));
freeWildcardCounter++;
builder.add(wildcard);
builder.add(Segment.END_BINDING);
} else {
builder.add(wildcard);
}
break;
case "":
if (!bindingEnds) {
throw new ValidationException(
"parse error: empty segment not allowed in '%s'", template);
}
// If the wildcard is implicit, seg will be empty. Just continue.
break;
case "-":
builder.add(Segment.WILDCARD);
implicitWildcard = false;
break;
default:
builder.add(Segment.create(SegmentKind.LITERAL, seg));
}
// End a binding.
if (bindingEnds && !complexDelimiterFound) {
// Reset varName to null for next binding.
varName = null;
if (implicitWildcard) {
// Looking at something like "{var}". Insert an implicit wildcard, as it is the same
// as "{var=*}".
builder.add(Segment.WILDCARD);
}
builder.add(Segment.END_BINDING);
}
if (pathWildCardBound > 1) {
// Report restriction on number of '**' in the pattern. There can be only one, which
// enables non-backtracking based matching.
throw new ValidationException(
"parse error: pattern must not contain more than one path wildcard ('**') in '%s'",
template);
}
}
}
if (customVerb != null) {
builder.add(Segment.create(SegmentKind.CUSTOM_VERB, customVerb));
}
return builder.build();
}
private static boolean isSegmentBeginOrEndInvalid(String seg) {
// A segment is invalid if it contains only one character and the character is a delimiter
if (seg.length() == 1 && COMPLEX_DELIMITER_PATTERN.matcher(seg).find()) {
return true;
}
// A segment can start with a delimiter, as long as there is no { right after it.
// A segment can end with a delimiter, as long as there is no } right before it.
// e.g. Invalid: .{well}-{known}, {well}-{known}-
// Valid: .well-known, .well-{known}, .-~{well-known}, these segments are all considered as
// literals for matching
return (COMPLEX_DELIMITER_PATTERN.matcher(seg.substring(0, 1)).find() && seg.charAt(1) == '{')
|| (COMPLEX_DELIMITER_PATTERN.matcher(seg.substring(seg.length() - 1)).find()
&& seg.charAt(seg.length() - 2) == '}');
}
private static List parseComplexResourceId(String seg) {
List segments = new ArrayList<>();
List separatorIndices = new ArrayList<>();
Matcher complexPatternDelimiterMatcher = END_SEGMENT_COMPLEX_DELIMITER_PATTERN.matcher(seg);
boolean delimiterFound = complexPatternDelimiterMatcher.find();
while (delimiterFound) {
int delimiterIndex = complexPatternDelimiterMatcher.start();
if (seg.substring(delimiterIndex).startsWith("}")) {
delimiterIndex += 1;
}
String currDelimiter = seg.substring(delimiterIndex, delimiterIndex + 1);
if (!COMPLEX_DELIMITER_PATTERN.matcher(currDelimiter).find()) {
throw new ValidationException(
"parse error: invalid complex ID delimiter '%s' in '%s'", currDelimiter, seg);
}
separatorIndices.add(currDelimiter);
delimiterFound = complexPatternDelimiterMatcher.find(delimiterIndex + 1);
}
// The last entry does not have a delimiter.
separatorIndices.add("");
String subVarName = null;
Iterable complexSubsegments =
Splitter.onPattern("\\}[_\\-\\.~]").trimResults().split(seg);
boolean complexSegImplicitWildcard = false;
int currIteratorIndex = 0;
for (String complexSeg : complexSubsegments) {
boolean subsegmentBindingStarts = complexSeg.startsWith("{");
if (subsegmentBindingStarts) {
if (subVarName != null) {
throw new ValidationException("parse error: nested binding in '%s'", complexSeg);
}
complexSeg = complexSeg.substring(1);
}
subVarName = complexSeg.trim();
boolean subBindingEnds = complexSeg.endsWith("}");
int i = complexSeg.indexOf('=');
if (i <= 0) {
// Possibly looking at something like "{name}" with implicit wildcard.
if (subBindingEnds) {
// Remember to add an implicit wildcard later.
complexSegImplicitWildcard = true;
subVarName = complexSeg.substring(0, complexSeg.length() - 1).trim();
complexSeg = complexSeg.substring(complexSeg.length() - 1).trim();
}
} else {
// Looking at something like "{name=wildcard}".
subVarName = complexSeg.substring(0, i).trim();
complexSeg = complexSeg.substring(i + 1).trim();
if (complexSeg.equals("**")) {
throw new ValidationException(
"parse error: wildcard path not allowed in complex ID resource '%s'", subVarName);
}
}
String complexDelimiter =
currIteratorIndex < separatorIndices.size()
? separatorIndices.get(currIteratorIndex)
: "";
segments.add(Segment.create(SegmentKind.BINDING, subVarName, complexDelimiter));
segments.add(Segment.wildcardCreate(complexDelimiter));
segments.add(Segment.END_BINDING);
subVarName = null;
currIteratorIndex++;
}
return segments;
}
// Helpers
// =======
private String encodeUrl(String text) {
if (urlEncoding) {
try {
return URLEncoder.encode(text, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new ValidationException("UTF-8 encoding is not supported on this platform");
}
} else {
// When encoding is disabled, we accept any character except '/'
final String INVALID_CHAR = "/";
if (text.contains(INVALID_CHAR)) {
throw new ValidationException(
"Invalid character \"" + INVALID_CHAR + "\" in path section \"" + text + "\".");
}
return text;
}
}
private String decodeUrl(String url) {
if (urlEncoding) {
try {
return URLDecoder.decode(url, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new ValidationException("UTF-8 encoding is not supported on this platform");
}
} else {
return url;
}
}
// Checks for the given segments kind. On success, consumes them. Otherwise leaves
// the list iterator in its state.
private static boolean peek(ListIterator segments, SegmentKind... kinds) {
int start = segments.nextIndex();
boolean success = false;
for (SegmentKind kind : kinds) {
if (!segments.hasNext() || segments.next().kind() != kind) {
success = false;
break;
}
}
if (success) {
return true;
}
restore(segments, start);
return false;
}
// Restores a list iterator back to a given index.
private static void restore(ListIterator> segments, int index) {
while (segments.nextIndex() > index) {
segments.previous();
}
}
// Equality and String Conversion
// ==============================
/** Returns a pretty version of the template as a string. */
@Override
public String toString() {
return toSyntax(segments, true);
}
/**
* Returns a raw version of the template as a string. This renders the template in its internal,
* normalized form.
*/
public String toRawString() {
return toSyntax(segments, false);
}
private static String toSyntax(List segments, boolean pretty) {
StringBuilder result = new StringBuilder();
boolean continueLast = true; // if true, no slash is appended.
ListIterator iterator = segments.listIterator();
while (iterator.hasNext()) {
Segment seg = iterator.next();
if (!continueLast) {
result.append(seg.separator());
}
continueLast = false;
switch (seg.kind()) {
case BINDING:
if (pretty && seg.value().startsWith("$")) {
// Remove the internal binding.
seg = iterator.next(); // Consume wildcard
result.append(seg.value());
iterator.next(); // Consume END_BINDING
continue;
}
result.append('{');
result.append(seg.value());
if (pretty && peek(iterator, SegmentKind.WILDCARD, SegmentKind.END_BINDING)) {
// Reduce {name=*} to {name}.
result.append('}');
continue;
}
result.append('=');
continueLast = true;
continue;
case END_BINDING:
result.append('}');
continue;
default:
result.append(seg.value());
continue;
}
}
return result.toString();
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof PathTemplate)) {
return false;
}
PathTemplate other = (PathTemplate) obj;
return Objects.equals(segments, other.segments);
}
@Override
public int hashCode() {
return segments.hashCode();
}
}