diff options
author | Jon Bratseth <bratseth@gmail.com> | 2022-04-07 13:58:39 +0200 |
---|---|---|
committer | Jon Bratseth <bratseth@gmail.com> | 2022-04-07 13:58:39 +0200 |
commit | b68f4ba22e523c39e0ca734d7c8627477518c553 (patch) | |
tree | 6968efa48259acb6788c2edfab0858de8c8f4765 /container-core | |
parent | a6656689f92f2bcd45c07491aada64740669d5c5 (diff) | |
parent | 4c3de59b341522a53e3ebbf8ad40bd2b12aff86e (diff) |
Merge branch 'master' into bratseth/inputs
Diffstat (limited to 'container-core')
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")); |