diff --git a/common/src/main/java/com/linecorp/centraldogma/common/DefaultPathPattern.java b/common/src/main/java/com/linecorp/centraldogma/common/DefaultPathPattern.java index c25ca2744..f29a51f7b 100644 --- a/common/src/main/java/com/linecorp/centraldogma/common/DefaultPathPattern.java +++ b/common/src/main/java/com/linecorp/centraldogma/common/DefaultPathPattern.java @@ -16,7 +16,9 @@ package com.linecorp.centraldogma.common; import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; +import java.util.Arrays; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -44,12 +46,20 @@ final class DefaultPathPattern implements PathPattern { this.patterns = patterns.stream() .peek(DefaultPathPattern::validatePathPattern) .filter(pattern -> !pattern.isEmpty()) - .map(pattern -> { - if (pattern.charAt(0) != '/') { - return "/**/" + pattern; - } - return pattern; - }).collect(Collectors.joining(",")); + .map(DefaultPathPattern::normalizePattern) + .collect(Collectors.joining(",")); + } + + DefaultPathPattern(PathPattern... verifiedPatterns) { + requireNonNull(verifiedPatterns, "verifiedPatterns"); + patterns = Arrays.stream(verifiedPatterns) + .map(PathPattern::patternString) + .collect(Collectors.joining(",")); + } + + DefaultPathPattern(String pattern) { + validatePathPattern(pattern); + patterns = normalizePattern(pattern); } private DefaultPathPattern(String patterns, String encoded) { @@ -100,6 +110,13 @@ private static String validatePathPattern(String pattern) { return pattern; } + private static String normalizePattern(String pattern) { + if (pattern.charAt(0) != '/') { + return "/**/" + pattern; + } + return pattern; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/common/src/main/java/com/linecorp/centraldogma/common/PathPattern.java b/common/src/main/java/com/linecorp/centraldogma/common/PathPattern.java index 05752b014..2387e86aa 100644 --- a/common/src/main/java/com/linecorp/centraldogma/common/PathPattern.java +++ b/common/src/main/java/com/linecorp/centraldogma/common/PathPattern.java @@ -15,6 +15,7 @@ */ package com.linecorp.centraldogma.common; +import static com.google.common.base.Preconditions.checkArgument; import static com.linecorp.centraldogma.common.DefaultPathPattern.ALL; import static com.linecorp.centraldogma.common.DefaultPathPattern.allPattern; import static java.util.Objects.requireNonNull; @@ -36,6 +37,13 @@ */ public interface PathPattern { + /** + * Returns a newly created {@link PathPatternBuilder}. + */ + static PathPatternBuilder builder() { + return new PathPatternBuilder(); + } + /** * Returns the path pattern that represents all files. */ @@ -50,6 +58,18 @@ static PathPattern of(String... patterns) { return of(ImmutableSet.copyOf(requireNonNull(patterns, "patterns"))); } + /** + * Creates a path pattern with the {@code pathPatterns}. + */ + static PathPattern of(PathPattern... pathPatterns) { + requireNonNull(pathPatterns, "pathPatterns"); + checkArgument(pathPatterns.length > 0, "pathPatterns is empty."); + if (pathPatterns.length == 1) { + return pathPatterns[0]; + } + return new DefaultPathPattern(pathPatterns); + } + /** * Creates a path pattern with the {@code patterns}. */ @@ -62,6 +82,34 @@ static PathPattern of(Iterable patterns) { return new DefaultPathPattern(ImmutableSet.copyOf(patterns)); } + /** + * Returns the path pattern for matching file(s) ending in {@code filename}. + */ + static PathPattern endsWith(String filename) { + return builder().endsWith(filename).build(); + } + + /** + * Returns the path pattern for file(s) that start with {@code dirPath}. + */ + static PathPattern startsWith(String dirPath) { + return builder().startsWith(dirPath).build(); + } + + /** + * Returns the path pattern for file(s) that contains {@code dirPath}. + */ + static PathPattern contains(String dirPath) { + return builder().contains(dirPath).build(); + } + + /** + * Returns the path pattern for file(s) with {@code extension}. + */ + static PathPattern hasExtension(String extension) { + return builder().hasExtension(extension).build(); + } + /** * Returns the path pattern that concatenates the {@code patterns} using ','. */ diff --git a/common/src/main/java/com/linecorp/centraldogma/common/PathPatternBuilder.java b/common/src/main/java/com/linecorp/centraldogma/common/PathPatternBuilder.java new file mode 100644 index 000000000..371305a7f --- /dev/null +++ b/common/src/main/java/com/linecorp/centraldogma/common/PathPatternBuilder.java @@ -0,0 +1,171 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.common; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.centraldogma.internal.Util; + +/** + * Builds a new {@link PathPattern}. + * + *

Example

+ *
{@code
+ * final PathPattern pathPattern =
+ *         PathPattern.builder()
+ *                    .startsWith("/foo/bar")
+ *                    .contains("/ext")
+ *                    .hasExtension("json")
+ *                    .build();
+ * }
+ */ +public final class PathPatternBuilder { + + private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("^\\.{0,1}[0-9a-zA-Z]+$"); + + @Nullable + private PathPattern startPattern; + private final List innerPatterns = new ArrayList<>(); + @Nullable + private PathPattern endPattern; + + PathPatternBuilder() {} + + /** + * Ensures the file name component matches the specified {@code filename}. + * For example, `endWith("foo.txt")` will match `/foo.txt`, `/alice/foo.txt` and + * `/alice/bob/foo.txt`, but not `/barfoo.txt`. + * + *

This option can only be specified once; multiple declarations will override one another. + * + *

Note: this option and {@link PathPatternBuilder#hasExtension(String)} are mutually exclusive. + * When both are specified, the latter-most option will override the former. + */ + public PathPatternBuilder endsWith(String filename) { + checkArgument(Util.isValidFileName(filename), "filename"); + // "/**" is added by the constructor of `DefaultPathPattern` + endPattern = new DefaultPathPattern(filename); + return this; + } + + /** + * Ensures the file extension component matches the specified {@code extension}. + * For example, `hasExtension("json")` will match `mux.json`, `/bar/mux.json` and + * `/alice/bar/mux.json` but not `/json.txt`. + * + *

This option can only be specified once; multiple declarations will override one another. + * + *

Note: this option and {@link PathPatternBuilder#endsWith(String)} are mutually exclusive. + * When both are specified, the latter-most option will override the former. + */ + public PathPatternBuilder hasExtension(String extension) { + checkArgument(isValidFileExtension(extension), "invalid extension."); + if (extension.startsWith(".")) { + endPattern = new DefaultPathPattern("/**/*" + extension); + } else { // add extension separator + endPattern = new DefaultPathPattern("/**/*." + extension); + } + return this; + } + + /** + * Ensures the directory path starts with the specified {@code dirPath}. + * For example, `startsWith("/foo")` will match `/foo/test.zip`, `/foo/bar/test.zip` + * but not `/nix/foo/test.zip`. + * + *

This option can only be specified once; multiple declarations will override one another. + */ + public PathPatternBuilder startsWith(String dirPath) { + checkArgument(Util.isValidDirPath(dirPath), "dir"); + // appends "/**" + startPattern = new DefaultPathPattern(dirPath + (dirPath.endsWith("/") ? "" : "/") + "**"); + return this; + } + + /** + * Ensures the directory path contains the specified {@code dirPath}. + * For example, `contains("/bar")` will match `/nix/bar/test.zip`, `/nix/quix/bar/twee/test.zip` + * but not `/bar/foo/test.zip` or `/ren/bar.json`. + * + *

This option can be specified multiple times; multiple declarations will be chained. + * For example, `contains("/bar").contains("foo")` + * creates the glob-like pattern string `/**/bar/**/foo/**". + */ + public PathPatternBuilder contains(String dirPath) { + checkArgument(Util.isValidDirPath(dirPath), "dirPath"); + // Prepends and appends "/**" + final PathPattern contain = new DefaultPathPattern("/**" + dirPath + + (dirPath.endsWith("/") ? "" : "/") + "**"); + innerPatterns.add(contain); + return this; + } + + /** + * Compose one pathPattern from a list of {@code patterns}. + */ + private static String combine(List patterns) { + final StringBuilder sb = new StringBuilder(); + for (final Iterator i = patterns.iterator(); i.hasNext();) { + if (sb.length() == 0) { + // left should end with "/**" + sb.append(i.next().patternString()); + } else { + // right should start with "/**/" + sb.append(i.next().patternString().substring(3)); + } + } + return sb.toString(); + } + + /** + * Returns a newly-created {@link PathPattern} based on the options of this builder. + */ + public PathPattern build() { + final ImmutableList.Builder optionsBuilder = ImmutableList.builder(); + if (startPattern != null) { + optionsBuilder.add(startPattern); + } + optionsBuilder.addAll(innerPatterns); + if (endPattern != null) { + optionsBuilder.add(endPattern); + } + final ImmutableList options = optionsBuilder.build(); + + checkState(!options.isEmpty(), "Requires at least one pattern to build in PathPatternBuilder"); + + if (options.size() == 1) { + return options.get(0); + } + return new DefaultPathPattern(combine(options)); + } + + private static boolean isValidFileExtension(String extension) { + requireNonNull(extension, "extension"); + checkArgument(!extension.isEmpty(), "extension is empty."); + return FILE_EXTENSION_PATTERN.matcher(extension).matches(); + } +} diff --git a/common/src/test/java/com/linecorp/centraldogma/common/DefaultPathPatternTest.java b/common/src/test/java/com/linecorp/centraldogma/common/DefaultPathPatternTest.java index 13787c2c6..a55b06f4b 100644 --- a/common/src/test/java/com/linecorp/centraldogma/common/DefaultPathPatternTest.java +++ b/common/src/test/java/com/linecorp/centraldogma/common/DefaultPathPatternTest.java @@ -38,6 +38,11 @@ void pathPattern() { pathPattern = PathPattern.of(ImmutableSet.of("/foo/*.json", "/*/foo.txt", "/**")); assertThat(pathPattern.patternString()).isEqualTo("/**"); assertThat(pathPattern.encoded()).isEqualTo("/**"); + + pathPattern = PathPattern.of(PathPattern.of("*.json"), + PathPattern.startsWith("/foo/bar")); + assertThat(pathPattern.patternString()).isEqualTo("/**/*.json,/foo/bar/**"); + assertThat(pathPattern.encoded()).isEqualTo("/**/*.json,/foo/bar/**"); } @Test diff --git a/common/src/test/java/com/linecorp/centraldogma/common/PathPatternBuilderTest.java b/common/src/test/java/com/linecorp/centraldogma/common/PathPatternBuilderTest.java new file mode 100644 index 000000000..85456ef19 --- /dev/null +++ b/common/src/test/java/com/linecorp/centraldogma/common/PathPatternBuilderTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.centraldogma.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class PathPatternBuilderTest { + @Test + void testExtensionPattern() { + assertThatNoException().isThrownBy(() -> PathPattern.builder().hasExtension(".JPG")); + assertThatNoException().isThrownBy(() -> PathPattern.builder().hasExtension(".7z")); + + assertThatThrownBy(() -> PathPattern.builder().hasExtension("가txt")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PathPattern.builder().hasExtension("..tx.t")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testSingleOption() { + assertThat(PathPattern.builder().startsWith("/foo/bar").build() + .patternString()).isEqualTo("/foo/bar/**"); + assertThat(PathPattern.builder().endsWith("foo.json").build() + .patternString()).isEqualTo("/**/foo.json"); + assertThat(PathPattern.builder().contains("/bar").build() + .patternString()).isEqualTo("/**/bar/**"); + assertThat(PathPattern.builder().hasExtension("json").build() + .patternString()).isEqualTo("/**/*.json"); + } + + @Test + void testPathPatternBuilder() { + assertThat(PathPattern.builder() + .startsWith("/foo/bar") + .endsWith("foo.txt") + .build() + .patternString()).isEqualTo("/foo/bar/**/foo.txt"); + assertThat(PathPattern.builder() + .startsWith("/foo") + .contains("/bar/") + .hasExtension("json") + .build() + .patternString()).isEqualTo("/foo/**/bar/**/*.json"); + + assertThat(PathPattern.builder() + .startsWith("/foo") + .endsWith("qux.json") + .hasExtension("json") + .build() + .patternString()).isEqualTo("/foo/**/*.json"); + assertThat(PathPattern.builder() + .startsWith("/foo") + .hasExtension("json") + .endsWith("qux.json") + .build() + .patternString()).isEqualTo("/foo/**/qux.json"); + + assertThat(PathPattern.builder() + .contains("/foo") + .contains("/bar") + .build() + .patternString()).isEqualTo("/**/foo/**/bar/**"); + assertThat(PathPattern.builder() + .startsWith("/foo/bar") + .startsWith("/override") + .hasExtension("json") + .build() + .patternString()).isEqualTo("/override/**/*.json"); + } +}