aboutsummaryrefslogtreecommitdiffstats
path: root/container-core
diff options
context:
space:
mode:
authorJon Bratseth <bratseth@gmail.com>2022-04-07 13:58:39 +0200
committerJon Bratseth <bratseth@gmail.com>2022-04-07 13:58:39 +0200
commitb68f4ba22e523c39e0ca734d7c8627477518c553 (patch)
tree6968efa48259acb6788c2edfab0858de8c8f4765 /container-core
parenta6656689f92f2bcd45c07491aada64740669d5c5 (diff)
parent4c3de59b341522a53e3ebbf8ad40bd2b12aff86e (diff)
Merge branch 'master' into bratseth/inputs
Diffstat (limited to 'container-core')
-rw-r--r--container-core/src/main/java/com/yahoo/container/core/documentapi/DocumentAccessProvider.java4
-rw-r--r--container-core/src/main/java/com/yahoo/container/core/documentapi/VespaDocumentAccess.java3
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java4
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/HttpURL.java451
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/Path.java80
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApi.java4
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java8
-rw-r--r--container-core/src/test/java/com/yahoo/restapi/HttpURLTest.java202
-rw-r--r--container-core/src/test/java/com/yahoo/restapi/PathTest.java17
9 files changed, 702 insertions, 71 deletions
diff --git a/container-core/src/main/java/com/yahoo/container/core/documentapi/DocumentAccessProvider.java b/container-core/src/main/java/com/yahoo/container/core/documentapi/DocumentAccessProvider.java
index 44a70ea2f3b..be8ba669ec0 100644
--- a/container-core/src/main/java/com/yahoo/container/core/documentapi/DocumentAccessProvider.java
+++ b/container-core/src/main/java/com/yahoo/container/core/documentapi/DocumentAccessProvider.java
@@ -19,10 +19,10 @@ public class DocumentAccessProvider implements Provider<VespaDocumentAccess> {
private final VespaDocumentAccess access;
@Inject
- public DocumentAccessProvider(DocumentmanagerConfig documentmanagerConfig, LoadTypeConfig loadTypeConfig,
+ public DocumentAccessProvider(DocumentmanagerConfig documentmanagerConfig,
MessagebusConfig messagebusConfig, DocumentProtocolPoliciesConfig policiesConfig,
DistributionConfig distributionConfig) {
- this.access = new VespaDocumentAccess(documentmanagerConfig, loadTypeConfig, System.getProperty("config.id"),
+ this.access = new VespaDocumentAccess(documentmanagerConfig, System.getProperty("config.id"),
messagebusConfig, policiesConfig, distributionConfig);
}
diff --git a/container-core/src/main/java/com/yahoo/container/core/documentapi/VespaDocumentAccess.java b/container-core/src/main/java/com/yahoo/container/core/documentapi/VespaDocumentAccess.java
index 6976299cc7d..1775dbe53c1 100644
--- a/container-core/src/main/java/com/yahoo/container/core/documentapi/VespaDocumentAccess.java
+++ b/container-core/src/main/java/com/yahoo/container/core/documentapi/VespaDocumentAccess.java
@@ -43,13 +43,12 @@ public class VespaDocumentAccess extends DocumentAccess {
private boolean shutDown = false;
VespaDocumentAccess(DocumentmanagerConfig documentmanagerConfig,
- LoadTypeConfig loadTypeConfig,
String slobroksConfigId,
MessagebusConfig messagebusConfig,
DocumentProtocolPoliciesConfig policiesConfig,
DistributionConfig distributionConfig) {
super(new DocumentAccessParams().setDocumentmanagerConfig(documentmanagerConfig));
- this.parameters = new MessageBusParams(new LoadTypeSet(loadTypeConfig))
+ this.parameters = new MessageBusParams()
.setDocumentProtocolPoliciesConfig(policiesConfig, distributionConfig);
this.parameters.setDocumentmanagerConfig(documentmanagerConfig);
this.parameters.getRPCNetworkParams().setSlobrokConfigId(slobroksConfigId);
diff --git a/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java b/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java
index c97e128b170..4d6f470637b 100644
--- a/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java
+++ b/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java
@@ -1,7 +1,7 @@
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.container.logging;
-import com.yahoo.compress.ZstdOuputStream;
+import com.yahoo.compress.ZstdOutputStream;
import com.yahoo.io.NativeIO;
import com.yahoo.log.LogFileDb;
import com.yahoo.protect.Process;
@@ -401,7 +401,7 @@ class LogFileHandler <LOGTYPE> {
Path compressedFile = Paths.get(oldFile.toString() + ".zst");
int bufferSize = 2*1024*1024;
try (FileOutputStream fileOut = AtomicFileOutputStream.create(compressedFile);
- ZstdOuputStream out = new ZstdOuputStream(fileOut, bufferSize);
+ ZstdOutputStream out = new ZstdOutputStream(fileOut, bufferSize);
FileInputStream in = new FileInputStream(oldFile.toFile())) {
pageFriendlyTransfer(nativeIO, out, fileOut.getFD(), in, bufferSize);
out.flush();
diff --git a/container-core/src/main/java/com/yahoo/restapi/HttpURL.java b/container-core/src/main/java/com/yahoo/restapi/HttpURL.java
new file mode 100644
index 00000000000..e890b0fe71a
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/restapi/HttpURL.java
@@ -0,0 +1,451 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.restapi;
+
+import ai.vespa.validation.StringWrapper;
+import com.yahoo.net.DomainName;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalInt;
+import java.util.StringJoiner;
+import java.util.function.Consumer;
+import java.util.function.UnaryOperator;
+
+import static ai.vespa.validation.Validation.require;
+import static ai.vespa.validation.Validation.requireInRange;
+import static java.net.URLDecoder.decode;
+import static java.net.URLEncoder.encode;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Collections.unmodifiableMap;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * This is the best class for creating, manipulating and inspecting HTTP URLs, because:
+ * <ul>
+ * <li>It is more restrictive than {@link URI}, but with a richer construction API, reducing risk of blunder.
+ * <ul>
+ * <li>Scheme must be HTTP or HTTPS.</li>
+ * <li>Authority must be a {@link DomainName}, with an optional port.</li>
+ * <li>{@link Path} must be normalized at all times.</li>
+ * <li>Only {@link Query} is allowed, in addition to the above.</li>
+ * </ul>
+ * </li>
+ * <li>
+ * It contains all those helpful builder methods that {@link URI} has none of.
+ * <ul>
+ * <li>{@link Path} can be parsed, have segments or other paths appended, and cut.</li>
+ * <li>{@link Query} can be parsed, and keys and key-value pairs can be inserted or removed.</li>
+ * </ul>
+ * All these (except the parse methods) operate on <em>decoded</em> values.
+ * </li>
+ * <li>It makes it super-easy to use a {@link StringWrapper} for validation of path and query segments.</li>
+ * </ul>
+ *
+ * @author jonmv
+ */
+public class HttpURL {
+
+ private final Scheme scheme;
+ private final DomainName domain;
+ private final int port;
+ private final Path path;
+ private final Query query;
+
+ private HttpURL(Scheme scheme, DomainName domain, int port, Path path, Query query) {
+ this.scheme = requireNonNull(scheme);
+ this.domain = requireNonNull(domain);
+ this.port = requireInRange(port, "port number", -1, (1 << 16) - 1);
+ this.path = requireNonNull(path);
+ this.query = requireNonNull(query);
+ }
+
+ public static HttpURL create(Scheme scheme, DomainName domain, int port, Path path, Query query) {
+ return new HttpURL(scheme, domain, port, path, query);
+ }
+
+ public static HttpURL create(Scheme scheme, DomainName domain, int port, Path path) {
+ return create(scheme, domain, port, path, Query.empty());
+ }
+
+ public static HttpURL create(Scheme scheme, DomainName domain, int port) {
+ return create(scheme, domain, port, Path.empty(), Query.empty());
+ }
+
+ public static HttpURL create(Scheme scheme, DomainName domain) {
+ return create(scheme, domain, -1);
+ }
+
+ public static HttpURL from(URI uri) {
+ return from(uri, HttpURL::requirePathSegment, HttpURL::requireNothing);
+ }
+
+ public static HttpURL from(URI uri, Consumer<String> pathValidator, Consumer<String> queryValidator) {
+ if ( ! uri.normalize().equals(uri))
+ throw new IllegalArgumentException("uri should be normalized, but got: " + uri);
+
+ return create(Scheme.of(uri.getScheme()),
+ DomainName.of(requireNonNull(uri.getHost(), "URI must specify a host")),
+ uri.getPort(),
+ Path.parse(uri.getRawPath(), pathValidator),
+ Query.parse(uri.getRawQuery(), queryValidator));
+ }
+
+ public HttpURL withScheme(Scheme scheme) {
+ return create(scheme, domain, port, path, query);
+ }
+
+ public HttpURL withDomain(DomainName domain) {
+ return create(scheme, domain, port, path, query);
+ }
+
+ public HttpURL withPort(int port) {
+ return create(scheme, domain, port, path, query);
+ }
+
+ public HttpURL withoutPort() {
+ return create(scheme, domain, -1, path, query);
+ }
+
+ public HttpURL withPath(Path path) {
+ return create(scheme, domain, port, path, query);
+ }
+
+ public HttpURL withQuery(Query query) {
+ return create(scheme, domain, port, path, query);
+ }
+
+ public Scheme scheme() {
+ return scheme;
+ }
+
+ public DomainName domain() {
+ return domain;
+ }
+
+ public OptionalInt port() {
+ return port == -1 ? OptionalInt.empty() : OptionalInt.of(port);
+ }
+
+ public Path path() {
+ return path;
+ }
+
+ public Query query() {
+ return query;
+ }
+
+ /** Returns an absolute, hierarchical URI representing this HTTP URL. */
+ public URI asURI() {
+ try {
+ return new URI(scheme.name() + "://" + domain.value() + (port == -1 ? "" : ":" + port) + path.raw() + query.raw());
+ }
+ catch (URISyntaxException e) {
+ throw new IllegalStateException("invalid URI, this should not happen", e);
+ }
+ }
+
+ /** Require that the given string (possibly decoded multiple times) contains none of {@code '/', '?', '#'}, and isn't either of {@code "", ".", ".."}. */
+ public static String requirePathSegment(String value) {
+ while ( ! value.equals(value = decode(value, UTF_8)));
+ require( ! value.contains("/"), value, "path segment decoded cannot contain '/'");
+ require( ! value.contains("?"), value, "path segment decoded cannot contain '?'");
+ require( ! value.contains("#"), value, "path segment decoded cannot contain '#'");
+ return Path.requireNonNormalizable(value);
+ }
+
+ private static void requireNothing(String value) { }
+
+ public static class Path {
+
+ private final List<String> segments;
+ private final boolean trailingSlash;
+ private final UnaryOperator<String> validator;
+
+ private Path(List<String> segments, boolean trailingSlash, UnaryOperator<String> validator) {
+ this.segments = requireNonNull(segments);
+ this.trailingSlash = trailingSlash;
+ this.validator = requireNonNull(validator);
+ }
+
+ /** Creates a new, empty path, with a trailing slash, using {@link HttpURL#requirePathSegment} for segment validation. */
+ public static Path empty() {
+ return empty(HttpURL::requirePathSegment);
+ }
+
+ /** Creates a new, empty path, with a trailing slash, using the indicated validator for segments. */
+ public static Path empty(Consumer<String> validator) {
+ return new Path(List.of(), true, segmentValidator(validator));
+ }
+
+ /** Creates a new path with the given <em>decoded</em> segments. */
+ public static Path from(List<String> segments) {
+ return from(segments, __ -> { });
+ }
+
+ /** Creates a new path with the given <em>decoded</em> segments, and the validator applied to each segment. */
+ public static Path from(List<String> segments, Consumer<String> validator) {
+ return empty(validator).append(segments, true);
+ }
+
+ /** Parses the given raw, normalized path string; this ignores whether the path is absolute or relative. */
+ public static Path parse(String raw) {
+ return parse(raw, HttpURL::requirePathSegment);
+ }
+
+ /** Parses the given raw, normalized path string; this ignores whether the path is absolute or relative.) */
+ public static Path parse(String raw, Consumer<String> validator) {
+ Path base = new Path(List.of(), raw.endsWith("/"), segmentValidator(validator));
+ if (raw.startsWith("/")) raw = raw.substring(1);
+ if (raw.isEmpty()) return base;
+ List<String> segments = new ArrayList<>();
+ for (String segment : raw.split("/")) segments.add(decode(segment, UTF_8));
+ if (segments.isEmpty()) requireNonNormalizable(""); // Raw path was only slashes.
+ return base.append(segments);
+ }
+
+ private static UnaryOperator<String> segmentValidator(Consumer<String> validator) {
+ requireNonNull(validator, "segment validator cannot be null");
+ return value -> {
+ requireNonNormalizable(value);
+ validator.accept(value);
+ return value;
+ };
+ }
+
+ private static String requireNonNormalizable(String segment) {
+ return require( ! (segment.isEmpty() || segment.equals(".") || segment.equals("..")),
+ segment, "path segments cannot be \"\", \".\", or \"..\"");
+ }
+
+ /** Returns a copy of this where only the first segments are retained, and with a trailing slash. */
+ public Path head(int count) {
+ return count == segments.size() ? this : new Path(segments.subList(0, count), true, validator);
+ }
+
+ /** Returns a copy of this where only the last segments are retained. */
+ public Path tail(int count) {
+ return count == segments.size() ? this : new Path(segments.subList(segments.size() - count, segments.size()), trailingSlash, validator);
+ }
+
+ /** Returns a copy of this where the first segments are skipped. */
+ public Path skip(int count) {
+ return count == 0 ? this : new Path(segments.subList(count, segments.size()), trailingSlash, validator);
+ }
+
+ /** Returns a copy of this where the last segments are cut off, and with a trailing slash. */
+ public Path cut(int count) {
+ return count == 0 ? this : new Path(segments.subList(0, segments.size() - count), true, validator);
+ }
+
+ /** Returns a copy of this with the <em>decoded</em> segment appended at the end; it may not be either of {@code ""}, {@code "."} or {@code ".."}. */
+ public Path append(String segment) {
+ return append(List.of(segment), trailingSlash);
+ }
+
+ /** Returns a copy of this all segments of the other path appended, with a trailing slash as per the appendage. */
+ public Path append(Path other) {
+ return append(other.segments, other.trailingSlash);
+ }
+
+ /** Returns a copy of this all given segments appended, with a trailing slash as per this path. */
+ public Path append(List<String> segments) {
+ return append(segments, trailingSlash);
+ }
+
+ private Path append(List<String> segments, boolean trailingSlash) {
+ List<String> copy = new ArrayList<>(this.segments);
+ for (String segment : segments) copy.add(validator.apply(segment));
+ return new Path(copy, trailingSlash, validator);
+ }
+
+ /** Whether this path has a trailing slash. */
+ public boolean hasTrailingSlash() {
+ return trailingSlash;
+ }
+
+ /** Returns a copy of this which encodes a trailing slash. */
+ public Path withTrailingSlash() {
+ return new Path(segments, true, validator);
+ }
+
+ /** Returns a copy of this which does not encode a trailing slash. */
+ public Path withoutTrailingSlash() {
+ return new Path(segments, false, validator);
+ }
+
+ /** The <em>URL decoded</em> segments that make up this path; never {@code null}, {@code ""}, {@code "."} or {@code ".."}. */
+ public List<String> segments() {
+ return Collections.unmodifiableList(segments);
+ }
+
+ /** A raw path string which parses to this, by splitting on {@code "/"}, and then URL decoding. */
+ String raw() {
+ StringJoiner joiner = new StringJoiner("/", "/", trailingSlash ? "/" : "").setEmptyValue(trailingSlash ? "/" : "");
+ for (String segment : segments) joiner.add(encode(segment, UTF_8));
+ return joiner.toString();
+ }
+
+ /** Intentionally not usable for constructing new URIs. Use {@link HttpURL} for that instead. */
+ @Override
+ public String toString() {
+ return "path '" + raw() + "'";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Path path = (Path) o;
+ return trailingSlash == path.trailingSlash && segments.equals(path.segments);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(segments, trailingSlash);
+ }
+
+ }
+
+
+ public static class Query {
+
+ private final Map<String, String> values;
+ private final UnaryOperator<String> validator;
+
+ private Query(Map<String, String> values, UnaryOperator<String> validator) {
+ this.values = requireNonNull(values);
+ this.validator = requireNonNull(validator);
+ }
+
+ /** Creates a new, empty query part. */
+ public static Query empty() {
+ return empty(__ -> { });
+ }
+
+ /** Creates a new, empty query part, using the indicated string wrapper for keys and non-null values. */
+ public static Query empty(Consumer<String> validator) {
+ return new Query(Map.of(), entryValidator(validator));
+ }
+
+ /** Creates a new query part with the given <em>decoded</em> values. */
+ public static Query from(Map<String, String> values) {
+ return from(values, __ -> { });
+ }
+
+ /** Creates a new query part with the given <em>decoded</em> values, and the validator applied to each pair. */
+ public static Query from(Map<String, String> values, Consumer<String> validator) {
+ return empty(validator).merge(values);
+ }
+
+ /** Parses the given raw query string. */
+ public static Query parse(String raw) {
+ return parse(raw, __-> { });
+ }
+ /** Parses the given raw query string, using the indicated string wrapper to hold keys and non-null values. */
+ public static Query parse(String raw, Consumer<String> validator) {
+ if (raw == null) return empty(validator);
+ Map<String, String> values = new LinkedHashMap<>();
+ for (String pair : raw.split("&")) {
+ int split = pair.indexOf("=");
+ String key, value;
+ if (split == -1) { key = pair; value = null; }
+ else { key = pair.substring(0, split); value = pair.substring(split + 1); }
+ values.put(decode(key, UTF_8), value == null ? null : decode(value, UTF_8));
+ }
+ return empty(validator).merge(values);
+ }
+
+ private static UnaryOperator<String> entryValidator(Consumer<String> validator) {
+ requireNonNull(validator);
+ return value -> {
+ validator.accept(value);
+ return value;
+ };
+ }
+
+ /** Returns a copy of this with the <em>decoded</em> non-null key pointing to the <em>decoded</em> non-null value. */
+ public Query put(String key, String value) {
+ Map<String, String> copy = new LinkedHashMap<>(values);
+ copy.put(validator.apply(requireNonNull(key)), validator.apply(requireNonNull(value)));
+ return new Query(copy, validator);
+ }
+
+ /** Returns a copy of this with the <em>decoded</em> non-null key pointing to "nothing". */
+ public Query add(String key) {
+ Map<String, String> copy = new LinkedHashMap<>(values);
+ copy.put(validator.apply(requireNonNull(key)), null);
+ return new Query(copy, validator);
+ }
+
+ /** Returns a copy of this without any key-value pair with the <em>decoded</em> key. */
+ public Query remove(String key) {
+ Map<String, String> copy = new LinkedHashMap<>(values);
+ copy.remove(validator.apply(requireNonNull(key)));
+ return new Query(copy, validator);
+ }
+
+ /** Returns a copy of this with all mappings from the other query added to this, possibly overwriting existing mappings. */
+ public Query merge(Query other) {
+ return merge(other.values);
+ }
+
+ /** Returns a copy of this with all given mappings added to this, possibly overwriting existing mappings. */
+ public Query merge(Map<String, String> values) {
+ Map<String, String> copy = new LinkedHashMap<>(this.values);
+ values.forEach((key, value) -> copy.put(validator.apply(requireNonNull(key, "keys cannot be null")),
+ value == null ? null : validator.apply(value)));
+ return new Query(copy, validator);
+ }
+
+ /** The <em>URL decoded</em> key-value pairs that make up this query; keys and values may be {@code ""}, and values are {@code null} when only key was specified. */
+ public Map<String, String> entries() {
+ return unmodifiableMap(values);
+ }
+
+ /** A raw query string, with {@code '?'} prepended, that parses to this, by splitting on {@code "&"}, then on {@code "="}, and then URL decoding; or the empty string if this is empty. */
+ private String raw() {
+ StringJoiner joiner = new StringJoiner("&", "?", "").setEmptyValue("");
+ values.forEach((key, value) -> joiner.add(encode(key, UTF_8) +
+ (value == null ? "" : "=" + encode(value, UTF_8))));
+ return joiner.toString();
+ }
+
+ /** Intentionally not usable for constructing new URIs. Use {@link HttpURL} for that instead. */
+ @Override
+ public String toString() {
+ return "query '" + raw() + "'";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Query query = (Query) o;
+ return values.equals(query.values);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(values);
+ }
+
+ }
+
+
+ public enum Scheme {
+ http,
+ https;
+ public static Scheme of(String scheme) {
+ if (scheme.equalsIgnoreCase(http.name())) return http;
+ if (scheme.equalsIgnoreCase(https.name())) return https;
+ throw new IllegalArgumentException("scheme must be HTTP or HTTPS");
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/restapi/Path.java b/container-core/src/main/java/com/yahoo/restapi/Path.java
index b96488c6781..c639432db89 100644
--- a/container-core/src/main/java/com/yahoo/restapi/Path.java
+++ b/container-core/src/main/java/com/yahoo/restapi/Path.java
@@ -2,14 +2,10 @@
package com.yahoo.restapi;
import java.net.URI;
-import java.net.URLDecoder;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
-import java.util.Objects;
-import java.util.function.Function;
-import java.util.stream.Stream;
+import java.util.function.Consumer;
/**
* A normalized path which is able to match strings containing bracketed placeholders and return the
@@ -32,50 +28,47 @@ import java.util.stream.Stream;
public class Path {
// This path
- private final String pathString;
- private final String[] elements;
+ private final HttpURL.Path path;
// Info about the last match
private final Map<String, String> values = new HashMap<>();
- private String rest = "";
+ private HttpURL.Path rest;
+ /** Creates a new Path for matching the given URI against patterns, which uses {@link HttpURL#requirePathSegment} as a segment validator. */
public Path(URI uri) {
- this.pathString = requireNormalized(uri).getRawPath();
- this.elements = splitAbsolutePath(pathString, (part) -> URLDecoder.decode(part, StandardCharsets.UTF_8));
+ this.path = HttpURL.Path.parse(uri.getRawPath());
+ }
+
+ /** Creates a new Path for matching the given URI against patterns, with the given path segment validator. */
+ public Path(URI uri, Consumer<String> validator) {
+ this.path = HttpURL.Path.parse(uri.getRawPath(), validator);
}
private boolean matchesInner(String pathSpec) {
values.clear();
- String[] specElements = splitAbsolutePath(pathSpec, Function.identity());
+ List<String> specElements = HttpURL.Path.parse(pathSpec).segments();
boolean matchPrefix = false;
- if (specElements.length > 1 && specElements[specElements.length-1].equals("{*}")) {
+ if (specElements.size() > 1 && specElements.get(specElements.size() - 1).equals("{*}")) {
matchPrefix = true;
- specElements = Arrays.copyOf(specElements, specElements.length-1);
+ specElements = specElements.subList(0, specElements.size() - 1);
}
if (matchPrefix) {
- if (this.elements.length < specElements.length) return false;
+ if (path.segments().size() < specElements.size()) return false;
}
else { // match exact
- if (this.elements.length != specElements.length) return false;
+ if (path.segments().size() != specElements.size()) return false;
}
- for (int i = 0; i < specElements.length; i++) {
- if (specElements[i].startsWith("{") && specElements[i].endsWith("}")) // placeholder
- values.put(specElements[i].substring(1, specElements[i].length()-1), elements[i]);
- else if ( ! specElements[i].equals(this.elements[i]))
+ for (int i = 0; i < specElements.size(); i++) {
+ if (specElements.get(i).startsWith("{") && specElements.get(i).endsWith("}")) // placeholder
+ values.put(specElements.get(i).substring(1, specElements.get(i).length() - 1), path.segments().get(i));
+ else if ( ! specElements.get(i).equals(path.segments().get(i)))
return false;
}
-
- if (matchPrefix) {
- StringBuilder rest = new StringBuilder();
- for (int i = specElements.length; i < this.elements.length; i++)
- rest.append(elements[i]).append("/");
- if ( ! pathString.endsWith("/") && rest.length() > 0)
- rest.setLength(rest.length() - 1);
- this.rest = rest.toString();
- }
-
+
+ rest = matchPrefix ? path.skip(specElements.size()) : null;
+
return true;
}
@@ -104,34 +97,15 @@ public class Path {
}
/**
- * Returns the rest of the last matched path.
- * This is always the empty string (never null) unless the path spec ends with {*}
+ * Returns the rest of the last matched path, or {@code null} if the path spec didn't end with {*}.
*/
- public String getRest() { return rest; }
-
- public String asString() {
- return pathString;
+ public HttpURL.Path getRest() {
+ return rest;
}
@Override
public String toString() {
- return "path '" + String.join("/", elements) + "'";
- }
-
- private static URI requireNormalized(URI uri) {
- Objects.requireNonNull(uri);
- if (!uri.normalize().equals(uri)) throw new IllegalArgumentException("Expected normalized URI, got '" + uri + "'");
- return uri;
+ return path.toString();
}
- private static String[] splitAbsolutePath(String path, Function<String, String> partParser) {
- String[] parts = Stream.of(path.split("/"))
- .map(partParser)
- .toArray(String[]::new);
- for (var part : parts) {
- if (part.equals("..")) throw new IllegalArgumentException("Expected absolute path, got '" + path + "'");
- }
- return parts;
- }
-
}
diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApi.java b/container-core/src/main/java/com/yahoo/restapi/RestApi.java
index d6cc0cccf3f..353ac3eb5cc 100644
--- a/container-core/src/main/java/com/yahoo/restapi/RestApi.java
+++ b/container-core/src/main/java/com/yahoo/restapi/RestApi.java
@@ -153,7 +153,9 @@ public interface RestApi {
default double getDoubleOrThrow(String name) { return Double.parseDouble(getStringOrThrow(name)); }
}
- interface PathParameters extends Parameters {}
+ interface PathParameters extends Parameters {
+ Optional<HttpURL.Path> getRest();
+ }
interface QueryParameters extends Parameters {
List<String> getStringList(String name);
}
diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java b/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java
index 09de7ffa133..cc243a3e92b 100644
--- a/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java
+++ b/container-core/src/main/java/com/yahoo/restapi/RestApiImpl.java
@@ -429,16 +429,16 @@ class RestApiImpl implements RestApi {
private class PathParametersImpl implements RestApi.RequestContext.PathParameters {
@Override
public Optional<String> getString(String name) {
- if (name.equals("*")) {
- String rest = pathMatcher.getRest();
- return rest.isEmpty() ? Optional.empty() : Optional.of(rest);
- }
return Optional.ofNullable(pathMatcher.get(name));
}
@Override public String getStringOrThrow(String name) {
return getString(name)
.orElseThrow(() -> new RestApiException.BadRequest("Path parameter '" + name + "' is missing"));
}
+ @Override public Optional<HttpURL.Path> getRest() {
+ return Optional.ofNullable(pathMatcher.getRest());
+ }
+
}
private class QueryParametersImpl implements RestApi.RequestContext.QueryParameters {
diff --git a/container-core/src/test/java/com/yahoo/restapi/HttpURLTest.java b/container-core/src/test/java/com/yahoo/restapi/HttpURLTest.java
new file mode 100644
index 00000000000..858513c2a69
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/restapi/HttpURLTest.java
@@ -0,0 +1,202 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.restapi;
+
+import ai.vespa.validation.Name;
+import com.yahoo.net.DomainName;
+import com.yahoo.restapi.HttpURL.Query;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+import java.util.function.Consumer;
+
+import static com.yahoo.net.DomainName.localhost;
+import static com.yahoo.restapi.HttpURL.Scheme.http;
+import static com.yahoo.restapi.HttpURL.Scheme.https;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * @author jonmv
+ */
+class HttpURLTest {
+
+ @Test
+ void testConversionBackAndForth() {
+ for (String uri : List.of("http://minimal",
+ "http://empty.query?",
+ "http://zero-port:0?no=path",
+ "http://only-path/",
+ "https://strange/queries?=&foo",
+ "https://weirdness?=foo",
+ "https://encoded/%3F%3D%26%2F?%3F%3D%26%2F=%3F%3D%26%2F",
+ "https://host.at.domain:123/one/two/?three=four&five")) {
+ Consumer<String> pathValidator = __ -> { };
+ assertEquals(uri, HttpURL.from(URI.create(uri), pathValidator, pathValidator).asURI().toString(),
+ "uri '" + uri + "' should be returned unchanged");
+ }
+ }
+
+ @Test
+ void testModification() {
+ HttpURL url = HttpURL.create(http, localhost).withPath(HttpURL.Path.empty(Name::of));
+ assertEquals(http, url.scheme());
+ assertEquals(localhost, url.domain());
+ assertEquals(OptionalInt.empty(), url.port());
+ assertEquals(HttpURL.Path.empty(Name::of), url.path());
+ assertEquals(HttpURL.Query.empty(Name::of), url.query());
+
+ url = url.withScheme(https)
+ .withDomain(DomainName.of("domain"))
+ .withPort(0)
+ .withPath(url.path().append("foo").withoutTrailingSlash())
+ .withQuery(url.query().put("boo", "bar").add("baz"));
+ assertEquals(https, url.scheme());
+ assertEquals(DomainName.of("domain"), url.domain());
+ assertEquals(OptionalInt.of(0), url.port());
+ assertEquals(HttpURL.Path.parse("/foo", Name::of), url.path());
+ assertEquals(HttpURL.Query.parse("boo=bar&baz", Name::of), url.query());
+ }
+
+ @Test
+ void testInvalidURIs() {
+ assertEquals("scheme must be HTTP or HTTPS",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("file:/txt"))).getMessage());
+
+ assertEquals("URI must specify a host",
+ assertThrows(NullPointerException.class,
+ () -> HttpURL.from(URI.create("http:///foo"))).getMessage());
+
+ assertEquals("port number must be at least '-1' and at most '65535', but got: '65536'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo:65536/bar"))).getMessage());
+
+ assertEquals("uri should be normalized, but got: http://foo//",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo//"))).getMessage());
+
+ assertEquals("uri should be normalized, but got: http://foo/./",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo/./"))).getMessage());
+
+ assertEquals("path segments cannot be \"\", \".\", or \"..\", but got: '..'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo/.."))).getMessage());
+
+ assertEquals("path segments cannot be \"\", \".\", or \"..\", but got: '..'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo/.%2E"))).getMessage());
+
+ assertEquals("name must match '[A-Za-z][A-Za-z0-9_-]{0,63}', but got: '/'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo/%2F"), Name::of, Name::of)).getMessage());
+
+ assertEquals("name must match '[A-Za-z][A-Za-z0-9_-]{0,63}', but got: '/'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo?%2F"), Name::of, Name::of)).getMessage());
+
+ assertEquals("name must match '[A-Za-z][A-Za-z0-9_-]{0,63}', but got: ''",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.from(URI.create("http://foo?"), Name::of, Name::of)).getMessage());
+ }
+
+ @Test
+ void testPath() {
+ HttpURL.Path path = HttpURL.Path.parse("foo/bar/baz", Name::of);
+ List<String> expected = List.of("foo", "bar", "baz");
+ assertEquals(expected, path.segments());
+
+ assertEquals(expected.subList(1, 3), path.skip(1).segments());
+ assertEquals(expected.subList(0, 2), path.cut(1).segments());
+ assertEquals(expected.subList(1, 2), path.skip(1).cut(1).segments());
+
+ assertEquals("path '/foo/bar/baz/'", path.withTrailingSlash().toString());
+ assertEquals(path, path.withoutTrailingSlash().withoutTrailingSlash());
+
+ assertEquals(List.of("one", "foo", "bar", "baz", "two"),
+ HttpURL.Path.from(List.of("one")).append(path).append("two").segments());
+
+ assertEquals(List.of(expected.get(2), expected.get(0)),
+ path.append(path).cut(2).skip(2).segments());
+
+ for (int i = 0; i < 3; i++) {
+ assertEquals(path.head(i), path.cut(3 - i));
+ assertEquals(path.tail(i), path.skip(3 - i));
+ }
+
+ assertThrows(NullPointerException.class,
+ () -> path.append((String) null));
+
+ List<String> names = new ArrayList<>();
+ names.add(null);
+ assertThrows(NullPointerException.class,
+ () -> path.append(names));
+
+ assertEquals("name must match '[A-Za-z][A-Za-z0-9_-]{0,63}', but got: '???'",
+ assertThrows(IllegalArgumentException.class,
+ () -> path.append("???")).getMessage());
+
+ assertEquals("fromIndex(2) > toIndex(1)",
+ assertThrows(IllegalArgumentException.class,
+ () -> path.cut(2).skip(2)).getMessage());
+
+ assertEquals("path segment decoded cannot contain '/', but got: '/'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.Path.empty().append("%2525252525252525%2525252525253%25252532%252525%252534%36")).getMessage());
+
+ assertEquals("path segment decoded cannot contain '?', but got: '?'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.Path.empty().append("?")).getMessage());
+
+ assertEquals("path segment decoded cannot contain '#', but got: '#'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.Path.empty().append("#")).getMessage());
+
+ assertEquals("path segments cannot be \"\", \".\", or \"..\", but got: '..'",
+ assertThrows(IllegalArgumentException.class,
+ () -> HttpURL.Path.empty().append("%2E%25252E")).getMessage());
+ }
+
+ @Test
+ void testQuery() {
+ Query query = Query.parse("foo=bar&baz", Name::of);
+ Map<String, String> expected = new LinkedHashMap<>();
+ expected.put("foo", "bar");
+ expected.put("baz", null);
+ assertEquals(expected, query.entries());
+
+ expected.remove("baz");
+ assertEquals(expected, query.remove("baz").entries());
+
+ expected.put("baz", null);
+ expected.remove("foo");
+ assertEquals(expected, query.remove("foo").entries());
+ assertEquals(expected, Query.empty(Name::of).add("baz").entries());
+
+ assertEquals("query '?foo=bar&baz=bax&quu=fez&moo'",
+ query.put("baz", "bax").merge(Query.from(Map.of("quu", "fez"))).add("moo").toString());
+
+ assertThrows(NullPointerException.class,
+ () -> query.remove(null));
+
+ assertThrows(NullPointerException.class,
+ () -> query.add(null));
+
+ assertThrows(NullPointerException.class,
+ () -> query.put(null, "hax"));
+
+ assertThrows(NullPointerException.class,
+ () -> query.put("hax", null));
+
+ Map<String, String> names = new LinkedHashMap<>();
+ names.put(null, "hax");
+ assertThrows(NullPointerException.class,
+ () -> query.merge(names));
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/restapi/PathTest.java b/container-core/src/test/java/com/yahoo/restapi/PathTest.java
index 5cbf80ff2ad..4786eb9775c 100644
--- a/container-core/src/test/java/com/yahoo/restapi/PathTest.java
+++ b/container-core/src/test/java/com/yahoo/restapi/PathTest.java
@@ -6,6 +6,7 @@ import org.junit.Test;
import java.net.URI;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@@ -34,7 +35,7 @@ public class PathTest {
assertTrue(path.matches("/a/{foo}/bar/{b}/{*}"));
assertEquals("1", path.get("foo"));
assertEquals("fuz", path.get("b"));
- assertEquals("", path.getRest());
+ assertEquals("/", path.getRest().raw());
}
{
@@ -42,7 +43,7 @@ public class PathTest {
assertTrue(path.matches("/a/{foo}/bar/{b}/{*}"));
assertEquals("1", path.get("foo"));
assertEquals("fuz", path.get("b"));
- assertEquals("kanoo", path.getRest());
+ assertEquals("/kanoo", path.getRest().raw());
}
{
@@ -50,7 +51,7 @@ public class PathTest {
assertTrue(path.matches("/a/{foo}/bar/{b}/{*}"));
assertEquals("1", path.get("foo"));
assertEquals("fuz", path.get("b"));
- assertEquals("kanoo/trips", path.getRest());
+ assertEquals("/kanoo/trips", path.getRest().raw());
}
{
@@ -58,17 +59,19 @@ public class PathTest {
assertTrue(path.matches("/a/{foo}/bar/{b}/{*}"));
assertEquals("1", path.get("foo"));
assertEquals("fuz", path.get("b"));
- assertEquals("kanoo/trips/", path.getRest());
+ assertEquals("/kanoo/trips/", path.getRest().raw());
}
}
@Test
public void testUrlEncodedPath() {
assertTrue(new Path(URI.create("/a/%62/c")).matches("/a/b/c"));
- assertFalse(new Path(URI.create("/a/b%2fc")).matches("/a/b/c"));
- assertFalse(new Path(URI.create("/foo")).matches("/foo/bar/%2e%2e"));
+ assertFalse(new Path(URI.create("/a/b%2fc"), __ -> { }).matches("/a/b/c"));
+ assertThrows("path segments cannot be \"\", \".\", or \"..\", but got: '..'",
+ IllegalArgumentException.class,
+ () -> new Path(URI.create("/foo")).matches("/foo/bar/%2e%2e"));
- Path path = new Path(URI.create("/%61/%2f/%63"));
+ Path path = new Path(URI.create("/%61/%2f/%63"), __ -> { });
assertTrue(path.matches("/a/{slash}/{c}"));
assertEquals("/", path.get("slash"));
assertEquals("c", path.get("c"));