From 140d26d4bd1d99ba3ead4dc5b78f8a3cd5cc3ec4 Mon Sep 17 00:00:00 2001 From: Mira Leung Date: Wed, 13 May 2020 15:10:12 -0700 Subject: [PATCH] chore: support complex resource identifiers (#125) * chore: support complex resource identifiers * remove debug printf * fix: clean up PathTemplate.java and tests --- .../google/api/pathtemplate/PathTemplate.java | 343 ++++++++++++------ .../api/pathtemplate/PathTemplateTest.java | 252 ++++++++++++- 2 files changed, 479 insertions(+), 116 deletions(-) diff --git a/src/main/java/com/google/api/pathtemplate/PathTemplate.java b/src/main/java/com/google/api/pathtemplate/PathTemplate.java index e21becd23..970fa510e 100644 --- a/src/main/java/com/google/api/pathtemplate/PathTemplate.java +++ b/src/main/java/com/google/api/pathtemplate/PathTemplate.java @@ -40,6 +40,7 @@ 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; @@ -139,12 +140,28 @@ public class PathTemplate { // 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. - */ + /** Specifies a path segment kind. */ enum SegmentKind { /** A literal path segment. */ LITERAL, @@ -165,37 +182,34 @@ enum SegmentKind { END_BINDING, } - /** - * Specifies a path segment. - */ + /** Specifies a path segment. */ @AutoValue abstract static class Segment { - /** - * A constant for the WILDCARD segment. - */ + /** A constant for the WILDCARD segment. */ private static final Segment WILDCARD = create(SegmentKind.WILDCARD, "*"); - /** - * A constant for the PATH_WILDCARD segment. - */ + /** A constant for the PATH_WILDCARD segment. */ private static final Segment PATH_WILDCARD = create(SegmentKind.PATH_WILDCARD, "**"); - /** - * A constant for the END_BINDING segment. - */ + /** 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. - */ + /** Creates a segment of given kind and value. */ private static Segment create(SegmentKind kind, String value) { - return new AutoValue_PathTemplate_Segment(kind, value); + return new AutoValue_PathTemplate_Segment(kind, value, ""); } - /** - * The path segment kind. - */ + 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(); /** @@ -204,9 +218,9 @@ private static Segment create(SegmentKind kind, String value) { */ abstract String value(); - /** - * Returns true of this segment is one of the wildcards, - */ + abstract String complexSeparator(); + + /** Returns true of this segment is one of the wildcards, */ boolean isAnyWildcard() { return kind() == SegmentKind.WILDCARD || kind() == SegmentKind.PATH_WILDCARD; } @@ -277,9 +291,7 @@ private PathTemplate(Iterable segments, boolean urlEncoding) { this.urlEncoding = urlEncoding; } - /** - * Returns the set of variable names used in the template. - */ + /** Returns the set of variable names used in the template. */ public Set vars() { return bindings.keySet(); } @@ -363,16 +375,12 @@ public PathTemplate subTemplate(String varName) { String.format("Variable '%s' is undefined in template '%s'", varName, this.toRawString())); } - /** - * Returns true of this template ends with a literal. - */ + /** 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. - */ + /** Returns true of this template ends with a custom verb. */ public boolean endsWithCustomVerb() { return segments.get(segments.size() - 1).kind() == SegmentKind.CUSTOM_VERB; } @@ -464,9 +472,7 @@ public Map validatedMatch(String path, String exceptionMessagePr return matchMap; } - /** - * Returns true if the template matches the path. - */ + /** Returns true if the template matches the path. */ public boolean matches(String path) { return match(path) != null; } @@ -565,7 +571,8 @@ private Map match(String path, boolean forceHostName) { return ImmutableMap.copyOf(values); } - // Aligns input to start of literal value of literal or binding segment if input contains hostname. + // 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: @@ -597,6 +604,7 @@ private boolean match( int segPos, Map values) { String currentVar = null; + List modifiableInput = new ArrayList<>(input); while (segPos < segments.size()) { Segment seg = segments.get(segPos++); switch (seg.kind()) { @@ -614,18 +622,30 @@ private boolean match( break; case LITERAL: case WILDCARD: - if (inPos >= input.size()) { + if (inPos >= modifiableInput.size()) { // End of input return false; } // Check literal match. - String next = decodeUrl(input.get(inPos++)); + 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)); @@ -645,18 +665,19 @@ private boolean match( segsToMatch++; } } - int available = (input.size() - inPos) - 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(input.get(inPos++)))); + currentVar, + concatCaptures(values.get(currentVar), decodeUrl(modifiableInput.get(inPos++)))); } } } - return inPos == input.size(); + return inPos == modifiableInput.size(); } private static String concatCaptures(@Nullable String cur, String next) { @@ -681,9 +702,7 @@ public String instantiate(Map values) { return instantiate(values, false); } - /** - * Shortcut for {@link #instantiate(Map)} with a vararg parameter for keys and values. - */ + /** 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) { @@ -850,89 +869,118 @@ private static ImmutableList parseTemplate(String template) { int pathWildCardBound = 0; for (String seg : Splitter.on('/').trimResults().split(template)) { + if (COMPLEX_DELIMITER_PATTERN.matcher(seg.substring(0, 1)).find() + || COMPLEX_DELIMITER_PATTERN.matcher(seg.substring(seg.length() - 1)).find()) { + 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); - 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 { - // Looking at something like "{name=wildcard}". - varName = seg.substring(0, i).trim(); - seg = seg.substring(i + 1).trim(); + // 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); } - builder.add(Segment.create(SegmentKind.BINDING, varName)); - } - // 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(); - } + Matcher complexPatternDelimiterMatcher = END_SEGMENT_COMPLEX_DELIMITER_PATTERN.matcher(seg); + complexDelimiterFound = complexPatternDelimiterMatcher.find(); - // 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); + // 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 { - builder.add(wildcard); + // Looking at something like "{name=wildcard}". + varName = seg.substring(0, i).trim(); + seg = seg.substring(i + 1).trim(); } - 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; - default: - builder.add(Segment.create(SegmentKind.LITERAL, seg)); + builder.add(Segment.create(SegmentKind.BINDING, varName)); + } } - // End a binding. - if (bindingEnds) { - // Reset varName to null for next binding. - varName = null; + 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(); + } - if (implicitWildcard) { - // Looking at something like "{var}". Insert an implicit wildcard, as it is the same - // as "{var=*}". - builder.add(Segment.WILDCARD); + // 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; + default: + builder.add(Segment.create(SegmentKind.LITERAL, seg)); } - 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); + // 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); + } } } @@ -942,6 +990,77 @@ private static ImmutableList parseTemplate(String template) { return builder.build(); } + 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)); + segments.add(Segment.wildcardCreate(complexDelimiter)); + segments.add(Segment.END_BINDING); + subVarName = null; + + currIteratorIndex++; + } + return segments; + } + // Helpers // ======= @@ -1003,9 +1122,7 @@ private static void restore(ListIterator segments, int index) { // Equality and String Conversion // ============================== - /** - * Returns a pretty version of the template as a string. - */ + /** Returns a pretty version of the template as a string. */ @Override public String toString() { return toSyntax(segments, true); diff --git a/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java b/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java index 45be25f3b..33b9033b8 100644 --- a/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java +++ b/src/test/java/com/google/api/pathtemplate/PathTemplateTest.java @@ -33,6 +33,9 @@ import com.google.common.collect.ImmutableMap; import com.google.common.truth.Truth; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; import org.junit.Rule; import org.junit.Test; @@ -40,9 +43,7 @@ import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -/** - * Tests for {@link PathTemplate}. - */ +/** Tests for {@link PathTemplate}. */ @RunWith(JUnit4.class) public class PathTemplateTest { @@ -171,6 +172,251 @@ public void matchWithUnboundInMiddle() { assertPositionalMatch(template.match("bar/foo/foo/foo/bar"), "foo/foo", "bar"); } + // Complex Resource ID Segments. + // ======== + + @Test + public void complexResourceIdBasicCases() { + // Separate by "~". + PathTemplate template = PathTemplate.create("projects/{project}/zones/{zone_a}~{zone_b}"); + Map match = + template.match( + "https://www.googleapis.com/compute/v1/projects/project-123/zones/europe-west3-c~us-east3-a"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get(PathTemplate.HOSTNAME_VAR)).isEqualTo("https://www.googleapis.com"); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a}~{zone_b")).isNull(); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEqualTo("us-east3-a"); + + // Separate by "-". + template = PathTemplate.create("projects/{project}/zones/{zone_a}-{zone_b}"); + match = template.match("projects/project-123/zones/europe-west3-c~us-east3-a"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe"); + Truth.assertThat(match.get("zone_b")).isEqualTo("west3-c~us-east3-a"); + + // Separate by ".". + template = PathTemplate.create("projects/{project}/zones/{zone_a}.{zone_b}"); + match = template.match("projects/project-123/zones/europe-west3-c.us-east3-a"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEqualTo("us-east3-a"); + + // Separate by "_". + template = PathTemplate.create("projects/{project}/zones/{zone_a}_{zone_b}"); + match = template.match("projects/project-123/zones/europe-west3-c_us-east3-a"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEqualTo("us-east3-a"); + } + + @Test + public void complexResourceIdEqualsWildcard() { + PathTemplate template = PathTemplate.create("projects/{project=*}/zones/{zone_a=*}~{zone_b=*}"); + Map match = + template.match("projects/project-123/zones/europe-west3-c~us-east3-a"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a}~{zone_b")).isNull(); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEqualTo("us-east3-a"); + } + + @Test + public void complexResourceIdEqualsPathWildcard() { + thrown.expect(ValidationException.class); + PathTemplate template = PathTemplate.create("projects/{project=*}/zones/{zone_a=**}~{zone_b}"); + thrown.expectMessage( + String.format( + "parse error: wildcard path not allowed in complex ID resource '%s'", "zone_a")); + + template = PathTemplate.create("projects/{project=*}/zones/{zone_a}.{zone_b=**}"); + thrown.expectMessage( + String.format( + "parse error: wildcard path not allowed in complex ID resource '%s'", "zone_b")); + } + + @Test + public void complexResourceIdMissingMatches() { + PathTemplate template = PathTemplate.create("projects/{project}/zones/{zone_a}~{zone_b}"); + Truth.assertThat(template.match("projects/project-123/zones/europe-west3-c")).isNull(); + + template = PathTemplate.create("projects/{project}/zones/{zone_a}~{zone_b}.{zone_c}"); + Map match = + template.match("projects/project-123/zones/europe-west3-c~.us-east3-a"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a}~{zone_b")).isNull(); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEmpty(); + Truth.assertThat(match.get("zone_c")).isEqualTo("us-east3-a"); + } + + @Test + public void complexResourceIdNoSeparator() { + thrown.expect(ValidationException.class); + PathTemplate.create("projects/{project}/zones/{zone_a}{zone_b}"); + thrown.expectMessage( + String.format( + "parse error: missing or 2+ consecutive delimiter characters in '%s'", + "{zone_a}{zone_b}")); + + PathTemplate.create("projects/{project}/zones/{zone_a}_{zone_b}{zone_c}"); + thrown.expectMessage( + String.format( + "parse error: missing or 2+ consecutive delimiter characters in '%s'", + "{zone_a}_{zone_b}{zone_c}")); + } + + @Test + public void complexResourceIdInvalidDelimiter() { + thrown.expect(ValidationException.class); + // Not a comprehensive set of invalid delimiters, please check the class's defined pattern. + List someInvalidDelimiters = + new ArrayList<>(Arrays.asList("|", "!", "@", "a", "1", ",", "{", ")")); + for (String invalidDelimiter : someInvalidDelimiters) { + PathTemplate.create( + String.format("projects/{project=*}/zones/{zone_a}%s{zone_b}", invalidDelimiter)); + thrown.expectMessage( + String.format( + "parse error: invalid complex resource ID delimiter character in '%s'", + String.format("{zone_a}%s{zone_b}", invalidDelimiter))); + } + } + + @Test + public void complexResourceIdMixedSeparators() { + // Separate by a mix of delimiters. + PathTemplate template = + PathTemplate.create("projects/{project}/zones/{zone_a}~{zone_b}.{zone_c}-{zone_d}"); + Map match = + template.match( + "https://www.googleapis.com/compute/v1/projects/project-123/zones/europe-west3-c~us-east3-a.us-west2-b-europe-west2-b"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get(PathTemplate.HOSTNAME_VAR)).isEqualTo("https://www.googleapis.com"); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEqualTo("us-east3-a"); + Truth.assertThat(match.get("zone_c")).isEqualTo("us"); + Truth.assertThat(match.get("zone_d")).isEqualTo("west2-b-europe-west2-b"); + + template = PathTemplate.create("projects/{project}/zones/{zone_a}.{zone_b}.{zone_c}~{zone_d}"); + match = + template.match( + "https://www.googleapis.com/compute/v1/projects/project-123/zones/europe-west3-c.us-east3-a.us-west2-b~europe-west2-b"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get(PathTemplate.HOSTNAME_VAR)).isEqualTo("https://www.googleapis.com"); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe-west3-c"); + Truth.assertThat(match.get("zone_b")).isEqualTo("us-east3-a"); + Truth.assertThat(match.get("zone_c")).isEqualTo("us-west2-b"); + Truth.assertThat(match.get("zone_d")).isEqualTo("europe-west2-b"); + } + + @Test + public void complexResourceIdInParent() { + // One parent has a complex resource ID. + PathTemplate template = + PathTemplate.create( + "projects/{project}/zones/{zone_a}-{zone_b}_{zone_c}/machines/{machine}"); + Map match = + template.match( + "https://www.googleapis.com/compute/v1/projects/project-123/zones/europe-west3-c-us-east3-a_us-west2-b/machines/roomba"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get(PathTemplate.HOSTNAME_VAR)).isEqualTo("https://www.googleapis.com"); + Truth.assertThat(match.get("project")).isEqualTo("project-123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe"); + Truth.assertThat(match.get("zone_b")).isEqualTo("west3-c-us-east3-a"); + Truth.assertThat(match.get("zone_c")).isEqualTo("us-west2-b"); + Truth.assertThat(match.get("machine")).isEqualTo("roomba"); + + // All parents and resource IDs have complex resource IDs. + template = + PathTemplate.create( + "projects/{foo}_{bar}/zones/{zone_a}-{zone_b}_{zone_c}/machines/{cell1}.{cell2}"); + match = + template.match( + "https://www.googleapis.com/compute/v1/projects/project_123/zones/europe-west3-c-us-east3-a_us-west2-b/machines/roomba.broomba"); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get(PathTemplate.HOSTNAME_VAR)).isEqualTo("https://www.googleapis.com"); + Truth.assertThat(match.get("foo")).isEqualTo("project"); + Truth.assertThat(match.get("bar")).isEqualTo("123"); + Truth.assertThat(match.get("zone_a")).isEqualTo("europe"); + Truth.assertThat(match.get("zone_b")).isEqualTo("west3-c-us-east3-a"); + Truth.assertThat(match.get("zone_c")).isEqualTo("us-west2-b"); + Truth.assertThat(match.get("cell1")).isEqualTo("roomba"); + Truth.assertThat(match.get("cell2")).isEqualTo("broomba"); + } + + @Test + public void complexResourceBasicInvalidIds() { + thrown.expect(ValidationException.class); + PathTemplate.create("projects/*/zones/~{zone_a}"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "~{zone_a}")); + + PathTemplate.create("projects/*/zones/{zone_a}~"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "{zone_a}~")); + + PathTemplate.create("projects/*/zones/.{zone_a}"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", ".{zone_a}")); + + PathTemplate.create("projects/*/zones/{zone_a}."); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "{zone_a}.")); + + PathTemplate.create("projects/*/zones/-{zone_a}"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "-{zone_a}")); + + PathTemplate.create("projects/*/zones/{zone_a}-"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "{zone_a}-")); + + PathTemplate.create("projects/*/zones/_{zone_a}"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "{zone_a}_")); + + PathTemplate.create("projects/*/zones/{zone_a}_"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", "{zone_a}_")); + } + + @Test + public void complexResourceMultipleDelimiters() { + thrown.expect(ValidationException.class); + PathTemplate.create("projects/*/zones/.-~{zone_a}"); + thrown.expectMessage( + String.format("parse error: invalid begin or end character in '%s'", ".-~{zone_a}")); + + PathTemplate.create("projects/*/zones/{zone_a}~.{zone_b}"); + thrown.expectMessage( + String.format( + "parse error: missing or 2+ consecutive delimiter characters in '%s'", + "{zone_a}~.{zone_b}")); + + PathTemplate.create("projects/*/zones/{zone_a}~{zone_b}..{zone_c}"); + thrown.expectMessage( + String.format( + "parse error: missing or 2+ consecutive delimiter characters in '%s'", + "{zone_a}~{zone_b}..{zone_c}")); + + String pathString = "projects/project_123/zones/lorum~ipsum"; + PathTemplate template = PathTemplate.create("projects/*/zones/{zone_.~-a}~{zone_b}"); + template.validate(pathString, ""); + // No assertion - success is no exception thrown from template.validate(). + Map match = template.match(pathString); + Truth.assertThat(match).isNotNull(); + Truth.assertThat(match.get("zone_.~-a")).isEqualTo("lorum"); + Truth.assertThat(match.get("zone_b")).isEqualTo("ipsum"); + } + // Validate // ========