From 4548da04b60cffffc23935f38d571b8de63d456b Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Mon, 4 Nov 2024 18:34:18 +0100 Subject: [PATCH 01/36] Attempt to fix infinite await in Jersey output writer. (#9460) The count down latch is now counted down in close method of the output stream, which should cover all the possible cases (unless close is not called, which would cause other major issues as well, so the case is not handled). Signed-off-by: Tomas Langer --- .../microprofile/server/JaxRsService.java | 48 ++++++++++++++++++- .../webserver/http1/Http1ServerResponse.java | 5 +- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/microprofile/server/src/main/java/io/helidon/microprofile/server/JaxRsService.java b/microprofile/server/src/main/java/io/helidon/microprofile/server/JaxRsService.java index 114619a70eb..5acf12c4a75 100644 --- a/microprofile/server/src/main/java/io/helidon/microprofile/server/JaxRsService.java +++ b/microprofile/server/src/main/java/io/helidon/microprofile/server/JaxRsService.java @@ -335,7 +335,8 @@ public OutputStream writeResponseStatusAndHeaders(long contentLengthParam, if (contentLength > 0) { res.header(HeaderValues.create(HeaderNames.CONTENT_LENGTH, String.valueOf(contentLength))); } - this.outputStream = res.outputStream(); + // in case there is an exception during close operation, we would lose the information and wait indefinitely + this.outputStream = new ReleaseLatchStream(cdl, res.outputStream()); return outputStream; } @@ -364,6 +365,10 @@ public void commit() { } catch (IOException e) { cdl.countDown(); throw new UncheckedIOException(e); + } catch (Throwable e) { + // always release on commit, regardless of what happened + cdl.countDown(); + throw e; } } @@ -382,7 +387,7 @@ public boolean enableResponseBuffering() { return true; // enable buffering in Jersey } - public void await() { + void await() { try { cdl.await(); } catch (InterruptedException e) { @@ -391,6 +396,45 @@ public void await() { } } + private static class ReleaseLatchStream extends OutputStream { + private final CountDownLatch cdl; + private final OutputStream delegate; + + private ReleaseLatchStream(CountDownLatch cdl, OutputStream delegate) { + this.cdl = cdl; + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + delegate.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + delegate.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + delegate.write(b, off, len); + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + try { + delegate.close(); + } finally { + cdl.countDown(); + } + } + } + private static class BaseUriRequestUri { private final URI baseUri; private final URI requestUri; diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java index ea41127d14c..03abb2ce312 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java @@ -19,6 +19,7 @@ import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Objects; @@ -843,7 +844,7 @@ public void close() { closingDelegate.closing(); // inform of imminent call to close for last flush try { delegate.close(); - } catch (IOException e) { + } catch (IOException | UncheckedIOException e) { throw new ServerConnectionException("Failed to close server output stream", e); } } @@ -856,7 +857,7 @@ void commit() { try { flush(); closingDelegate.commit(); - } catch (IOException e) { + } catch (IOException | UncheckedIOException e) { throw new ServerConnectionException("Failed to flush server output stream", e); } } From b21ae3c295811d250aebfe9278ddd7b75e8f47b1 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Thu, 7 Nov 2024 11:09:16 +0100 Subject: [PATCH 02/36] URI Validation updates (#9469) * Validation of URI Query * Moving host validation to UriValidator, as it belongs to URI concept (and is part of URI specification). The UriValidator now has methods to validate scheme, host, query, and fragment. Path validation is retained in `UriPath`. Added a new configuration option to disable validation of prologue for HTTP/1. Signed-off-by: Tomas Langer --- .../io/helidon/common/uri/UriFragment.java | 8 +- .../java/io/helidon/common/uri/UriQuery.java | 18 +- .../io/helidon/common/uri/UriQueryImpl.java | 5 + .../common/uri/UriValidationException.java | 209 ++++++ .../io/helidon/common/uri/UriValidator.java | 653 ++++++++++++++++++ .../io/helidon/common/uri/UriQueryTest.java | 1 - .../helidon/common/uri/UriValidatorTest.java | 297 ++++++++ .../java/io/helidon/http/HostValidator.java | 310 +-------- .../java/io/helidon/http/HttpPrologue.java | 4 +- .../io/helidon/http/HostValidatorTest.java | 110 +-- .../webserver/tests/BadPrologueTest.java | 125 ++++ .../webserver/http1/Http1ConfigBlueprint.java | 13 +- .../webserver/http1/Http1Connection.java | 36 +- .../http1/ValidateHostHeaderTest.java | 7 +- 14 files changed, 1433 insertions(+), 363 deletions(-) create mode 100644 common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java create mode 100644 common/uri/src/main/java/io/helidon/common/uri/UriValidator.java create mode 100644 common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java create mode 100644 webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java b/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java index eb44bb1c0c0..11b1f5d19e1 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriFragment.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ private UriFragment(String encoded, String fragment) { * @return a new instance */ public static UriFragment create(String rawFragment) { + Objects.requireNonNull(rawFragment); return new UriFragment(rawFragment); } @@ -104,6 +105,9 @@ public boolean hasValue() { * @return encoded fragment */ public String rawValue() { + if (rawFragment == null) { + throw new IllegalStateException("UriFragment does not have a value, guard with hasValue()"); + } return rawFragment; } @@ -114,7 +118,7 @@ public String rawValue() { */ public String value() { if (decodedFragment == null) { - decodedFragment = UriEncoding.decodeUri(rawFragment); + decodedFragment = UriEncoding.decodeUri(rawValue()); } return decodedFragment; } diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java b/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java index 2b41d70578a..f1e24fa43a2 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriQuery.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,17 +31,33 @@ public interface UriQuery extends Parameters { /** * Create a new HTTP query from the query string. + * This method does not validate the raw query against specification. * * @param query raw query string * @return HTTP query instance + * @see #create(String, boolean) */ static UriQuery create(String query) { + return create(query, false); + } + + /** + * Create a new HTTP query from the query string, validating if requested. + * + * @param query raw query string + * @param validate whether to validate that the query is according to the specification + * @return HTTP query instance + */ + static UriQuery create(String query, boolean validate) { Objects.requireNonNull(query, "Raw query string cannot be null, use create(URI) or empty()"); if (query.isEmpty()) { return empty(); } + if (validate) { + return new UriQueryImpl(query).validate(); + } return new UriQueryImpl(query); } diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java b/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java index fd6544cc808..cba7853848b 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriQueryImpl.java @@ -190,6 +190,11 @@ public String toString() { return "?" + rawValue(); } + UriQuery validate() { + UriValidator.validateQuery(query); + return this; + } + private void ensureDecoded() { if (decodedQueryParams == null) { Map> newQueryParams = new HashMap<>(); diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java b/common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java new file mode 100644 index 00000000000..8393d91297d --- /dev/null +++ b/common/uri/src/main/java/io/helidon/common/uri/UriValidationException.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.common.uri; + +import java.util.Objects; + +import static io.helidon.common.uri.UriValidator.encode; +import static io.helidon.common.uri.UriValidator.print; + +/** + * A URI validation exception. + *

+ * This type provides access to the invalid value that is not cleaned, through {@link #invalidValue()}. + * The exception message is cleaned and can be logged and returned to users ({@link #getMessage()}). + * + * @see #invalidValue() + */ +public class UriValidationException extends IllegalArgumentException { + /** + * Segment that failed validation. + */ + private final Segment segment; + /** + * The value (containing illegal characters) that failed validation. + */ + private final char[] invalidValue; + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * The message provided will be appended with cleaned invalid value in double quotes. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param message descriptive message + */ + public UriValidationException(Segment segment, char[] invalidValue, String message) { + super(toMessage(invalidValue, message)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param validated a validated section of the full value + * @param message descriptive message + */ + UriValidationException(Segment segment, char[] invalidValue, char[] validated, String message) { + super(toMessage(invalidValue, validated, message)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param validated a validated section of the full value + * @param message descriptive message + * @param index index in the {@code validated} array that failed + * @param c character that was invalid + */ + UriValidationException(Segment segment, char[] invalidValue, char[] validated, String message, int index, char c) { + super(toMessage(invalidValue, validated, message, index, c)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * Create a new validation exception that uses a descriptive message and the failed chars. + * + * @param segment segment that caused this exception + * @param invalidValue value that failed validation + * @param message descriptive message + * @param index index in the {@code invalidValue} array that failed + * @param c character that was invalid + */ + UriValidationException(Segment segment, char[] invalidValue, String message, int index, char c) { + super(toMessage(invalidValue, message, index, c)); + + this.segment = segment; + this.invalidValue = invalidValue; + } + + /** + * The value that did not pass validation. + * This value is as it was received over the network, so it is not safe to log or return to the user! + * + * @return invalid value that failed validation + */ + public char[] invalidValue() { + return invalidValue; + } + + /** + * Segment that caused this validation exception. + * + * @return segment of the URI + */ + public Segment segment() { + return segment; + } + + private static String toMessage(char[] value, String message) { + Objects.requireNonNull(value); + Objects.requireNonNull(message); + + if (value.length == 0) { + return message; + } + return message + ": " + encode(value); + } + + private static String toMessage(char[] value, char[] validated, String message) { + Objects.requireNonNull(value); + Objects.requireNonNull(message); + Objects.requireNonNull(validated); + + if (validated.length == 0) { + if (value.length == 0) { + return message; + } + return message + ". Value: " + encode(value); + } + if (value.length == 0) { + return message + ": " + encode(validated); + } + return message + ": " + encode(validated) + + ". Value: " + encode(value); + } + + private static String toMessage(char[] value, char[] validated, String message, int index, char c) { + Objects.requireNonNull(value); + Objects.requireNonNull(validated); + Objects.requireNonNull(message); + + return message + ": " + encode(validated) + ", index: " + index + + ", char: " + print(c) + + ". Value: " + encode(value); + } + + private static String toMessage(char[] value, String message, int index, char c) { + Objects.requireNonNull(value); + Objects.requireNonNull(message); + + return message + ": " + encode(value) + ", index: " + index + + ", char: " + print(c); + } + + /** + * Segment of the URI that caused this validation failure. + */ + public enum Segment { + /** + * URI Scheme. + */ + SCHEME("Scheme"), + /** + * URI Host. + */ + HOST("Host"), + /** + * URI Path. + */ + PATH("Path"), + /** + * URI Query. + */ + QUERY("Query"), + /** + * URI Fragment. + */ + FRAGMENT("Fragment"); + private final String name; + + Segment(String name) { + this.name = name; + } + + /** + * Human-readable text that describes this segment. + * + * @return segment text + */ + public String text() { + return name; + } + } +} diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriValidator.java b/common/uri/src/main/java/io/helidon/common/uri/UriValidator.java new file mode 100644 index 00000000000..c2032a8ba67 --- /dev/null +++ b/common/uri/src/main/java/io/helidon/common/uri/UriValidator.java @@ -0,0 +1,653 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.common.uri; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.common.uri.UriValidationException.Segment; + +/** + * Validate parts of the URI. + *

+ * Validation is based on + * RFC-3986. + *

+ * The following list provides an overview of parts of URI and how/if we validate it: + *

    + *
  • scheme - {@link #validateScheme(String)}
  • + *
  • authority - {@link #validateHost(String)}, port is validated in HTTP processing
  • + *
  • path - see {@link io.helidon.common.uri.UriPath#validate()}
  • + *
  • query - {@link #validateQuery(String)}
  • + *
  • fragment - {@link #validateFragment(String)}
  • + *
+ */ +public final class UriValidator { + private static final Pattern IP_V4_PATTERN = + Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$"); + private static final boolean[] HEXDIGIT = new boolean[256]; + private static final boolean[] UNRESERVED = new boolean[256]; + private static final boolean[] SUB_DELIMS = new boolean[256]; + // characters (in addition to hex, unreserved and sub-delims) that can be safely printed + private static final boolean[] PRINTABLE = new boolean[256]; + + static { + // digits + for (int i = '0'; i <= '9'; i++) { + UNRESERVED[i] = true; + } + // alpha + for (int i = 'a'; i <= 'z'; i++) { + UNRESERVED[i] = true; + } + for (int i = 'A'; i <= 'Z'; i++) { + UNRESERVED[i] = true; + } + UNRESERVED['-'] = true; + UNRESERVED['.'] = true; + UNRESERVED['_'] = true; + UNRESERVED['~'] = true; + + // hexdigits + // digits + for (int i = '0'; i <= '9'; i++) { + HEXDIGIT[i] = true; + } + // alpha + for (int i = 'a'; i <= 'f'; i++) { + HEXDIGIT[i] = true; + } + for (int i = 'A'; i <= 'F'; i++) { + HEXDIGIT[i] = true; + } + + // sub-delim set + SUB_DELIMS['!'] = true; + SUB_DELIMS['$'] = true; + SUB_DELIMS['&'] = true; + SUB_DELIMS['\''] = true; + SUB_DELIMS['('] = true; + SUB_DELIMS[')'] = true; + SUB_DELIMS['*'] = true; + SUB_DELIMS['+'] = true; + SUB_DELIMS[','] = true; + SUB_DELIMS[';'] = true; + SUB_DELIMS['='] = true; + + PRINTABLE[':'] = true; + PRINTABLE['/'] = true; + PRINTABLE['?'] = true; + PRINTABLE['@'] = true; + PRINTABLE['%'] = true; + PRINTABLE['#'] = true; + PRINTABLE['['] = true; + PRINTABLE[']'] = true; + } + + private UriValidator() { + } + + /** + * Validate a URI scheme. + * + * @param scheme scheme to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the scheme + */ + public static void validateScheme(String scheme) { + if ("http".equals(scheme)) { + return; + } + if ("https".equals(scheme)) { + return; + } + // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + char[] chars = scheme.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + validateAscii(Segment.SCHEME, chars, i, c); + if (Character.isLetterOrDigit(c)) { + continue; + } + if (c == '+') { + continue; + } + if (c == '-') { + continue; + } + if (c == '.') { + continue; + } + failInvalidChar(Segment.SCHEME, chars, i, c); + } + } + + /** + * Validate a URI Query raw string. + * + * @param rawQuery query to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the query + */ + public static void validateQuery(String rawQuery) { + Objects.requireNonNull(rawQuery); + + // empty query is valid + if (rawQuery.isEmpty()) { + return; + } + + // query = *( pchar / "/" / "?" ) + // pchar = unreserved / pct-encoded / sub-delims / "@" + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + // pct-encoded = "%" HEXDIG HEXDIG + // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + + char[] chars = rawQuery.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + validateAscii(Segment.QUERY, chars, i, c); + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == '@') { + continue; + } + if (c == '/') { + continue; + } + if (c == '?') { + continue; + } + // done with pchar validation except for percent encoded + if (c == '%') { + // percent encoding + validatePercentEncoding(Segment.QUERY, rawQuery, chars, i); + i += 2; + continue; + } + failInvalidChar(Segment.QUERY, chars, i, c); + } + } + + /** + * Validate a host string. + * + * @param host host to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the host + */ + public static void validateHost(String host) { + Objects.requireNonNull(host); + if (host.indexOf('[') == 0 && host.indexOf(']') == host.length() - 1) { + validateIpLiteral(host); + } else { + validateNonIpLiteral(host); + } + } + + /** + * An IP literal starts with {@code [} and ends with {@code ]}. + * + * @param ipLiteral host literal string, may be an IPv6 address, or IP version future + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the host + */ + public static void validateIpLiteral(String ipLiteral) { + Objects.requireNonNull(ipLiteral); + checkNotBlank(Segment.HOST, "IP Literal", ipLiteral, ipLiteral); + + // IP-literal = "[" ( IPv6address / IPvFuture ) "]" + if (ipLiteral.charAt(0) != '[') { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Invalid IP literal, missing square bracket(s)", + 0, + ipLiteral.charAt(0)); + } + int lastIndex = ipLiteral.length() - 1; + if (ipLiteral.charAt(lastIndex) != ']') { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Invalid IP literal, missing square bracket(s)", + lastIndex, + ipLiteral.charAt(lastIndex)); + } + + String host = ipLiteral.substring(1, ipLiteral.length() - 1); + checkNotBlank(Segment.HOST, "Host", ipLiteral, host); + if (host.charAt(0) == 'v') { + // IP future - starts with version `v1` etc. + validateIpFuture(ipLiteral, host); + return; + } + // IPv6 + /* + IPv6address = 6( h16 ":" ) ls32 + / "::" 5( h16 ":" ) ls32 + / [ h16 ] "::" 4( h16 ":" ) ls32 + / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 + / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 + / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 + / [ *4( h16 ":" ) h16 ] "::" ls32 + / [ *5( h16 ":" ) h16 ] "::" h16 + / [ *6( h16 ":" ) h16 ] "::" + + ls32 = ( h16 ":" h16 ) / IPv4address + h16 = 1*4HEXDIG + */ + if (host.equals("::")) { + // all empty + return; + } + if (host.equals("::1")) { + // localhost + return; + } + boolean skipped = false; + int segments = 0; // max segments is 8 (full IPv6 address) + String inProgress = host; + while (!inProgress.isEmpty()) { + if (inProgress.length() == 1) { + segments++; + validateH16(ipLiteral, inProgress); + break; + } + if (inProgress.charAt(0) == ':' && inProgress.charAt(1) == ':') { + // :: means skip everything that was before (or everything that is after) + if (skipped) { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Host IPv6 contains more than one skipped segment"); + } + skipped = true; + segments++; + inProgress = inProgress.substring(2); + continue; + } + if (inProgress.charAt(0) == ':') { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + inProgress.toCharArray(), + "Host IPv6 contains excessive colon"); + } + // this must be h16 (or an IPv4 address) + int nextColon = inProgress.indexOf(':'); + if (nextColon == -1) { + // the rest of the string + if (inProgress.indexOf('.') == -1) { + segments++; + validateH16(ipLiteral, inProgress); + } else { + Matcher matcher = IP_V4_PATTERN.matcher(inProgress); + if (matcher.matches()) { + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(1)); + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(2)); + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(3)); + validateIpOctet("Host IPv6 dual address contains invalid IPv4 address", ipLiteral, matcher.group(4)); + } else { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Host IPv6 dual address contains invalid IPv4 address"); + } + } + break; + } + validateH16(ipLiteral, inProgress.substring(0, nextColon)); + segments++; + if (inProgress.length() >= nextColon + 2) { + if (inProgress.charAt(nextColon + 1) == ':') { + // double colon, keep it there + inProgress = inProgress.substring(nextColon); + continue; + } + } + inProgress = inProgress.substring(nextColon + 1); + if (inProgress.isBlank()) { + // this must fail on empty segment + validateH16(ipLiteral, inProgress); + } + } + + if (segments > 8) { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "Host IPv6 address contains too many segments"); + } + } + + /** + * Validate IPv4 address or a registered name. + * + * @param host string with either an IPv4 address, or a registered name + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the host + */ + public static void validateNonIpLiteral(String host) { + Objects.requireNonNull(host); + checkNotBlank(Segment.HOST, "Host", host, host); + + // Ipv4 address: 127.0.0.1 + Matcher matcher = IP_V4_PATTERN.matcher(host); + if (matcher.matches()) { + /* + IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet + dec-octet = DIGIT ; 0-9 + / %x31-39 DIGIT ; 10-99 + / "1" 2DIGIT ; 100-199 + / "2" %x30-34 DIGIT ; 200-249 + / "25" %x30-35 ; 250-255 + */ + + // we have found an IPv4 address, or a valid registered name (555.555.555.555 is a valid name...) + return; + } + + // everything else is a registered name + + // registered name + /* + reg-name = *( unreserved / pct-encoded / sub-delims ) + pct-encoded = "%" HEXDIG HEXDIG + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + / "*" / "+" / "," / ";" / "=" + */ + char[] chars = host.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + validateAscii(Segment.HOST, chars, i, c); + + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == '%') { + // percent encoding + validatePercentEncoding(Segment.HOST, host, chars, i); + i += 2; + continue; + } + failInvalidChar(Segment.HOST, chars, i, c); + } + } + + /** + * Validate URI fragment. + * + * @param rawFragment fragment to validate + * @throws io.helidon.common.uri.UriValidationException in case there are invalid characters in the fragment + */ + public static void validateFragment(String rawFragment) { + Objects.requireNonNull(rawFragment); + + if (rawFragment.isEmpty()) { + return; + } + char[] chars = rawFragment.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + + validateAscii(Segment.FRAGMENT, chars, i, c); + + // *( pchar / "/" / "?" ) + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == '@') { + continue; + } + if (c == ':') { + continue; + } + + // done with pchar validation except for percent encoded + if (c == '%') { + // percent encoding + validatePercentEncoding(Segment.FRAGMENT, rawFragment, chars, i); + i += 2; + continue; + } + failInvalidChar(Segment.FRAGMENT, chars, i, c); + } + } + + static String print(char c) { + if (printable(c)) { + return "'" + c + "'"; + } + return "0x" + hex(c); + } + + static String encode(char[] chars) { + StringBuilder result = new StringBuilder(chars.length); + + for (char aChar : chars) { + if (aChar > 254) { + result.append('?'); + continue; + } + if (printable(aChar)) { + result.append(aChar); + continue; + } + result.append('?'); + } + + return result.toString(); + } + + private static void failInvalidChar(Segment segment, char[] chars, int i, char c) { + throw new UriValidationException(segment, + chars, + segment.text() + " contains invalid char", + i, + c); + } + + private static void validateAscii(Segment segment, char[] chars, int i, char c) { + if (c > 254) { + // in general only ASCII characters are allowed + throw new UriValidationException(segment, + chars, + segment.text() + " contains invalid char (non-ASCII)", + i, + c); + } + } + + /** + * Validate percent encoding sequence. + * + * @param segment segment of the URI + * @param chars characters of the part + * @param i index of the percent + */ + private static void validatePercentEncoding(Segment segment, String value, char[] chars, int i) { + if (i + 2 >= chars.length) { + throw new UriValidationException(segment, + chars, + segment.text() + + " contains invalid % encoding, not enough chars left at index " + + i); + } + char p1 = chars[i + 1]; + char p2 = chars[i + 2]; + // %p1p2 + validateHex(segment, value, chars, p1, segment.text(), i + 1, true); + validateHex(segment, value, chars, p2, segment.text(), i + 2, true); + } + + private static void validateHex(Segment segment, + String fullValue, + char[] chars, + char c, + String type, + int index, + boolean isPercentEncoding) { + if (c > 255 || !HEXDIGIT[c]) { + if (fullValue.length() == chars.length) { + if (isPercentEncoding) { + throw new UriValidationException(segment, + chars, + type + " has non hexadecimal char in % encoding", + index, + c); + } + throw new UriValidationException(segment, + chars, + type + " has non hexadecimal char", + index, + c); + } else { + if (isPercentEncoding) { + throw new UriValidationException(segment, + fullValue.toCharArray(), + chars, + type + " has non hexadecimal char in % encoding", + index, + c); + } + throw new UriValidationException(segment, + fullValue.toCharArray(), + chars, + type + " has non hexadecimal char", + index, + c); + } + } + } + + private static String hex(char c) { + String hexString = Integer.toHexString(c); + if (hexString.length() == 1) { + return "0" + hexString; + } + return hexString; + } + + private static void validateH16(String host, String inProgress) { + if (inProgress.isBlank()) { + throw new UriValidationException(Segment.HOST, + host.toCharArray(), + "IPv6 segment is empty"); + } + if (inProgress.length() > 4) { + throw new UriValidationException(Segment.HOST, + host.toCharArray(), + inProgress.toCharArray(), + "IPv6 segment has more than 4 chars"); + } + validateHexDigits(Segment.HOST, "IPv6 segment", host, inProgress); + } + + private static void validateHexDigits(Segment segment, + String description, + String host, + String section) { + char[] chars = section.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + + validateHex(segment, host, chars, c, description, i, false); + } + } + + private static void validateIpOctet(String message, String host, String octet) { + int octetInt = Integer.parseInt(octet); + // cannot be negative, as the regexp will not match + if (octetInt > 255) { + throw new UriValidationException(Segment.HOST, host.toCharArray(), message); + } + } + + private static void validateIpFuture(String ipLiteral, String host) { + /* + IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + */ + int dot = host.indexOf('.'); + if (dot == -1) { + throw new UriValidationException(Segment.HOST, + ipLiteral.toCharArray(), + "IP Future must contain 'v.'"); + } + // always starts with v + String version = host.substring(1, dot); + checkNotBlank(Segment.HOST, "Version", ipLiteral, version); + validateHexDigits(Segment.HOST, "Future version", ipLiteral, version); + + String address = host.substring(dot + 1); + checkNotBlank(Segment.HOST, "IP Future", ipLiteral, address); + + char[] chars = address.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + + validateAscii(Segment.HOST, chars, i, c); + if (UNRESERVED[c]) { + continue; + } + if (SUB_DELIMS[c]) { + continue; + } + if (c == ':') { + continue; + } + failInvalidChar(Segment.HOST, ipLiteral.toCharArray(), i + dot + 1, c); + } + } + + private static void checkNotBlank(Segment segment, + String message, + String ipLiteral, + String toValidate) { + if (toValidate.isBlank()) { + if (ipLiteral.equals(toValidate)) { + throw new UriValidationException(segment, ipLiteral.toCharArray(), message + " cannot be blank"); + } else { + throw new UriValidationException(segment, + ipLiteral.toCharArray(), + toValidate.toCharArray(), + message + " cannot be blank"); + } + } + } + + private static boolean printable(char c) { + if (c > 254) { + return false; + } + if (UNRESERVED[c]) { + return true; + } + if (SUB_DELIMS[c]) { + return true; + } + if (PRINTABLE[c]) { + return true; + } + return false; + } +} diff --git a/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java b/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java index cdbee2474a7..3c011ac81d0 100644 --- a/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java +++ b/common/uri/src/test/java/io/helidon/common/uri/UriQueryTest.java @@ -103,5 +103,4 @@ void testFromQueryString() { assertThat(query.get("p4"), is("a b c")); assertThat(query.getRaw("p4"), is("a%20b%20c")); } - } \ No newline at end of file diff --git a/common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java b/common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java new file mode 100644 index 00000000000..659e5a98249 --- /dev/null +++ b/common/uri/src/test/java/io/helidon/common/uri/UriValidatorTest.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.common.uri; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import static io.helidon.common.uri.UriValidator.validateFragment; +import static io.helidon.common.uri.UriValidator.validateHost; +import static io.helidon.common.uri.UriValidator.validateIpLiteral; +import static io.helidon.common.uri.UriValidator.validateScheme; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UriValidatorTest { + @Test + void testSchemeValidation() { + validateScheme("http"); + validateScheme("https"); + validateScheme("ws"); + validateScheme("abc123+-."); + + assertThrows(NullPointerException.class, () -> UriValidator.validateScheme(null)); + + validateBadScheme("čttp", + "Scheme contains invalid char (non-ASCII): ?ttp, index: 0, char: 0x10d"); + validateBadScheme("h_ttp", + "Scheme contains invalid char: h_ttp, index: 1, char: '_'"); + validateBadScheme("h~ttp", + "Scheme contains invalid char: h~ttp, index: 1, char: '~'"); + validateBadScheme("h!ttp", + "Scheme contains invalid char: h!ttp, index: 1, char: '!'"); + } + + @Test + void testFragmentValidation() { + assertThrows(NullPointerException.class, () -> validateFragment(null)); + validateFragment(""); + validateFragment("fragment"); + validateFragment("frag_ment"); // unreserved + validateFragment("frag~ment"); // unreserved + validateFragment("frag=ment"); // sub-delim + validateFragment("frag=!"); // sub-delim + validateFragment("frag%61ment"); // pct-encoded + validateFragment("frag@ment"); // at sign + validateFragment("frag:ment"); // colon + + validateBadFragment("fragčment", + "Fragment contains invalid char (non-ASCII): frag?ment, index: 4, char: 0x10d"); + validateBadFragment("frag%6147%4", + "Fragment contains invalid % encoding, not enough chars left at index 9: frag%6147%4"); + // percent encoded: first char is invalid + validateBadFragment("frag%6147%X1", + "Fragment has non hexadecimal char in % encoding: frag%6147%X1, index: 10, char: 'X'"); + validateBadFragment("frag%6147%č1", + "Fragment has non hexadecimal char in % encoding: frag%6147%?1, index: 10, char: 0x10d"); + // percent encoded: second char is invalid + validateBadFragment("frag%6147%1X", + "Fragment has non hexadecimal char in % encoding: frag%6147%1X, index: 11, char: 'X'"); + validateBadFragment("frag%6147%1č", + "Fragment has non hexadecimal char in % encoding: frag%6147%1?, index: 11, char: 0x10d"); + // character not in allowed sets + validateBadFragment("frag%6147{", + "Fragment contains invalid char: frag%6147?, index: 9, char: 0x7b"); + validateBadFragment("frag%6147\t", + "Fragment contains invalid char: frag%6147?, index: 9, char: 0x09"); + } + + @Test + void testQueryValidation() { + assertThrows(NullPointerException.class, () -> UriQuery.create((String) null)); + assertThrows(NullPointerException.class, () -> UriQuery.create((URI) null)); + assertThrows(NullPointerException.class, () -> UriQuery.create(null, true)); + assertThrows(NullPointerException.class, () -> UriValidator.validateQuery(null)); + + UriQuery.create("", true); + UriValidator.validateQuery(""); + UriQuery.create("a=b&c=d&a=e", true); + // validate all rules + // must be an ASCII (lower than 255) + validateBadQuery("a=@/?%6147č", + "Query contains invalid char (non-ASCII): a=@/?%6147?, index: 10, char: 0x10d"); + // percent encoded: must be full percent encoding + validateBadQuery("a=@/?%6147%4", + "Query contains invalid % encoding, not enough chars left at index 10: a=@/?%6147%4"); + // percent encoded: first char is invalid + validateBadQuery("a=@/?%6147%X1", + "Query has non hexadecimal char in % encoding: a=@/?%6147%X1, index: 11, char: 'X'"); + validateBadQuery("a=@/?%6147%č1", + "Query has non hexadecimal char in % encoding: a=@/?%6147%?1, index: 11, char: 0x10d"); + // percent encoded: second char is invalid + validateBadQuery("a=@/?%6147%1X", + "Query has non hexadecimal char in % encoding: a=@/?%6147%1X, index: 12, char: 'X'"); + validateBadQuery("a=@/?%6147%1č", + "Query has non hexadecimal char in % encoding: a=@/?%6147%1?, index: 12, char: 0x10d"); + // character not in allowed sets + validateBadQuery("a=@/?%6147{", + "Query contains invalid char: a=@/?%6147?, index: 10, char: 0x7b"); + validateBadQuery("a=@/?%6147\t", + "Query contains invalid char: a=@/?%6147?, index: 10, char: 0x09"); + } + + @Test + void testGoodHostname() { + // sanity + validateHost("localhost"); + // host names + validateHost("www.example.com"); + // percent encoded + validateHost("%65%78%61%6D%70%6C%65"); + validateHost("%65%78%61%6D%70%6C%65.com"); + // with underscores + validateHost("www.exa_mple.com"); + // with sub-delims + validateHost("www.exa$mple.com"); + } + + @Test + void testGoodIp4() { + // IPv4 + validateHost("192.167.1.1"); + } + + @Test + void testGoodIpLiteral6() { + // IPv6 + validateHost("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]"); + validateHost("[::1]"); + validateHost("[2001:db8:3333:4444:5555:6666:7777:8888]"); + validateHost("[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]"); + validateHost("[::]"); + validateHost("[2001:db8::]"); + validateHost("[::1234:5678]"); + validateHost("[::1234:5678:1]"); + validateHost("[2001:db8::1234:5678]"); + validateHost("[2001:db8:1::ab9:C0A8:102]"); + } + + @Test + void testGoodIpLiteral6Dual() { + // IPv6 + validateHost("[2001:db8:3333:4444:5555:6666:1.2.3.4]"); + validateHost("[::11.22.33.44]"); + validateHost("[2001:db8::123.123.123.123]"); + validateHost("[::1234:5678:91.123.4.56]"); + validateHost("[::1234:5678:1.2.3.4]"); + validateHost("[2001:db8::1234:5678:5.6.7.8]"); + } + + @Test + void testGoodIpLiteralFuture() { + // IPvFuture + validateHost("[v9.abc:def]"); + validateHost("[v9.abc:def*]"); + } + + @Test + void testBadHosts() { + // just empty + invokeExpectFailure("Host cannot be blank", ""); + // invalid brackets + invokeExpectFailure("Host contains invalid char: [start.but.not.end, index: 0, char: '['", + "[start.but.not.end"); + invokeExpectFailure("Host contains invalid char: end.but.not.start], index: 17, char: ']'", + "end.but.not.start]"); + invokeExpectFailure("Host contains invalid char: int.the[.middle], index: 7, char: '['", + "int.the[.middle]"); + // invalid escape + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%ZAxample.com, index: 5, char: 'Z'", + "www.%ZAxample.com"); + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%AZxample.com, index: 6, char: 'Z'", + "www.%AZxample.com"); + // invalid character (non-ASCII + invokeExpectFailure("Host contains invalid char (non-ASCII): www.?example.com, index: 4, char: 0x10d", + "www.čexample.com"); + // wrong trailing escape (must be two chars); + invokeExpectFailure("Host contains invalid % encoding, not enough chars left at index 15: www.example.com%4", + "www.example.com%4"); + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%?4, index: 16, char: 0x10d", + "www.example.com%č4"); + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%4?, index: 17, char: 0x10d", + "www.example.com%4č"); + } + + @Test + void testBadLiteral6() { + // IPv6 + // empty segment + invokeExpectFailure("Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", + "[2001:db8::85a3::7334]"); + // wrong segment (G is not a hexadecimal number) + invokeExpectFailure("IPv6 segment has non hexadecimal char: GGGG, index: 0, char: 'G'. " + + "Value: [GGGG:FFFF:0000:0000:0000:0000:0000:0000]", + "[GGGG:FFFF:0000:0000:0000:0000:0000:0000]"); + // non-ASCII character + invokeExpectFailure("IPv6 segment has non hexadecimal char: ?, index: 0, char: 0x10d. " + + "Value: [?:FFFF:0000:0000:0000:0000:0000:0000]", + "[č:FFFF:0000:0000:0000:0000:0000:0000]"); + // wrong segment (too many characters) + invokeExpectFailure("IPv6 segment has more than 4 chars: aaaaa. " + + "Value: [aaaaa:FFFF:0000:0000:0000:0000:0000:0000]", + "[aaaaa:FFFF:0000:0000:0000:0000:0000:0000]"); + // empty segment + invokeExpectFailure("IPv6 segment is empty: [aaaa:FFFF:0000:0000:0000:0000:0000:]", + "[aaaa:FFFF:0000:0000:0000:0000:0000:]"); + // wrong number of segments + invokeExpectFailure("Host IPv6 address contains too many segments: " + + "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]", + "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]"); + // missing everything + invokeExpectFailure("Host cannot be blank. Value: []", + "[]"); + // wrong start (leading colon) + invokeExpectFailure("Host IPv6 contains excessive colon: :1:0::. Value: [:1:0::]", + "[:1:0::]"); + // wrong end, colon instead of value + invokeExpectFailure("IPv6 segment has non hexadecimal char: :, index: 0, char: ':'. Value: [1:0:::]", + "[1:0:::]"); + + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::, index: 2, char: ':'", + "[::"); + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::], index: 0, char: ':'", + "::]"); + } + + @Test + void testBadLiteralDual() { + invokeLiteralExpectFailure("Host IPv6 dual address contains invalid IPv4 address: [::14.266.44.74]", + "[::14.266.44.74]"); + invokeLiteralExpectFailure("Host IPv6 dual address contains invalid IPv4 address: [::14.266.44]", + "[::14.266.44]"); + invokeLiteralExpectFailure("Host IPv6 dual address contains invalid IPv4 address: [::14.123.-44.147]", + "[::14.123.-44.147]"); + } + + @Test + void testBadLiteralFuture() { + // IPv future + // version must be present + invokeExpectFailure("Version cannot be blank. Value: [v.abc:def]", + "[v.abc:def]"); + // missing address + invokeExpectFailure("IP Future must contain 'v.': [v2]", + "[v2]"); + invokeExpectFailure("IP Future cannot be blank. Value: [v2.]", + "[v2.]"); + // invalid character in the host (valid future) + invokeExpectFailure("Host contains invalid char: [v2./0:::], index: 3, char: '/'", + "[v2./0:::]"); + invokeExpectFailure("Host contains invalid char (non-ASCII): 0:?, index: 2, char: 0x10d", + "[v2.0:č]"); + } + + private static void validateBadQuery(String query, String expected) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> UriQuery.create(query, true)); + assertThat(exception.getMessage(), is(expected)); + } + + private static void validateBadScheme(String scheme, String expected) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> validateScheme(scheme)); + assertThat(exception.getMessage(), is(expected)); + } + + private static void validateBadFragment(String fragment, String expected) { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> validateFragment(fragment)); + assertThat(exception.getMessage(), is(expected)); + } + + private static void invokeExpectFailure(String message, String host) { + var t = assertThrows(IllegalArgumentException.class, () -> validateHost(host), "Testing host: " + host); + assertThat(t.getMessage(), is(message)); + } + + private static void invokeLiteralExpectFailure(String message, String host) { + var t = assertThrows(IllegalArgumentException.class, () -> validateIpLiteral(host), "Testing host: " + host); + assertThat(t.getMessage(), is(message)); + } +} \ No newline at end of file diff --git a/http/http/src/main/java/io/helidon/http/HostValidator.java b/http/http/src/main/java/io/helidon/http/HostValidator.java index 834a27b0b5a..3fac173f952 100644 --- a/http/http/src/main/java/io/helidon/http/HostValidator.java +++ b/http/http/src/main/java/io/helidon/http/HostValidator.java @@ -16,67 +16,18 @@ package io.helidon.http; -import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import io.helidon.common.uri.UriValidator; /** * Validate the host string (maybe from the {@code Host} header). *

* Validation is based on * RFC-3986. + * + * @deprecated use {@link io.helidon.common.uri.UriValidator} instead */ +@Deprecated(since = "4.1.5", forRemoval = true) public final class HostValidator { - private static final Pattern IP_V4_PATTERN = - Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$"); - private static final boolean[] HEXDIGIT = new boolean[256]; - private static final boolean[] UNRESERVED = new boolean[256]; - private static final boolean[] SUB_DELIMS = new boolean[256]; - - static { - // digits - for (int i = '0'; i <= '9'; i++) { - UNRESERVED[i] = true; - } - // alpha - for (int i = 'a'; i <= 'z'; i++) { - UNRESERVED[i] = true; - } - for (int i = 'A'; i <= 'Z'; i++) { - UNRESERVED[i] = true; - } - UNRESERVED['-'] = true; - UNRESERVED['.'] = true; - UNRESERVED['_'] = true; - UNRESERVED['~'] = true; - - // hexdigits - // digits - for (int i = '0'; i <= '9'; i++) { - HEXDIGIT[i] = true; - } - // alpha - for (int i = 'a'; i <= 'f'; i++) { - HEXDIGIT[i] = true; - } - for (int i = 'A'; i <= 'F'; i++) { - HEXDIGIT[i] = true; - } - - // sub-delim set - SUB_DELIMS['!'] = true; - SUB_DELIMS['$'] = true; - SUB_DELIMS['&'] = true; - SUB_DELIMS['\''] = true; - SUB_DELIMS['('] = true; - SUB_DELIMS[')'] = true; - SUB_DELIMS['*'] = true; - SUB_DELIMS['+'] = true; - SUB_DELIMS[','] = true; - SUB_DELIMS[';'] = true; - SUB_DELIMS['='] = true; - } - private HostValidator() { } @@ -84,264 +35,35 @@ private HostValidator() { * Validate a host string. * * @param host host to validate - * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded + * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is percent encoded + * @deprecated use {@link io.helidon.common.uri.UriValidator#validateHost(String)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") public static void validate(String host) { - Objects.requireNonNull(host); - if (host.indexOf('[') == 0 && host.indexOf(']') == host.length() - 1) { - validateIpLiteral(host); - } else { - validateNonIpLiteral(host); - } + UriValidator.validateHost(host); } /** * An IP literal starts with {@code [} and ends with {@code ]}. * * @param ipLiteral host literal string, may be an IPv6 address, or IP version future - * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded + * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is percent encoded + * @deprecated use {@link io.helidon.common.uri.UriValidator#validateIpLiteral(String)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") public static void validateIpLiteral(String ipLiteral) { - Objects.requireNonNull(ipLiteral); - checkNotBlank("IP Literal", ipLiteral, ipLiteral); - - // IP-literal = "[" ( IPv6address / IPvFuture ) "]" - if (ipLiteral.charAt(0) != '[' || ipLiteral.charAt(ipLiteral.length() - 1) != ']') { - throw new IllegalArgumentException("Invalid IP literal, missing square bracket(s): " + HtmlEncoder.encode(ipLiteral)); - } - - String host = ipLiteral.substring(1, ipLiteral.length() - 1); - checkNotBlank("Host", ipLiteral, host); - if (host.charAt(0) == 'v') { - // IP future - starts with version `v1` etc. - validateIpFuture(ipLiteral, host); - return; - } - // IPv6 - /* - IPv6address = 6( h16 ":" ) ls32 - / "::" 5( h16 ":" ) ls32 - / [ h16 ] "::" 4( h16 ":" ) ls32 - / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 - / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 - / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 - / [ *4( h16 ":" ) h16 ] "::" ls32 - / [ *5( h16 ":" ) h16 ] "::" h16 - / [ *6( h16 ":" ) h16 ] "::" - - ls32 = ( h16 ":" h16 ) / IPv4address - h16 = 1*4HEXDIG - */ - if (host.equals("::")) { - // all empty - return; - } - if (host.equals("::1")) { - // localhost - return; - } - boolean skipped = false; - int segments = 0; // max segments is 8 (full IPv6 address) - String inProgress = host; - while (!inProgress.isEmpty()) { - if (inProgress.length() == 1) { - segments++; - validateH16(ipLiteral, inProgress); - break; - } - if (inProgress.charAt(0) == ':' && inProgress.charAt(1) == ':') { - // :: means skip everything that was before (or everything that is after) - if (skipped) { - throw new IllegalArgumentException("Host IPv6 contains more than one skipped segment: " - + HtmlEncoder.encode(ipLiteral)); - } - skipped = true; - segments++; - inProgress = inProgress.substring(2); - continue; - } - if (inProgress.charAt(0) == ':') { - throw new IllegalArgumentException("Host IPv6 contains excessive colon: " + HtmlEncoder.encode(ipLiteral)); - } - // this must be h16 (or an IPv4 address) - int nextColon = inProgress.indexOf(':'); - if (nextColon == -1) { - // the rest of the string - if (inProgress.indexOf('.') == -1) { - segments++; - validateH16(ipLiteral, inProgress); - } else { - Matcher matcher = IP_V4_PATTERN.matcher(inProgress); - if (matcher.matches()) { - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(1)); - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(2)); - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(3)); - validateIpOctet("Host IPv6 dual address contains invalid IPv4 address:", ipLiteral, matcher.group(4)); - } else { - throw new IllegalArgumentException("Host IPv6 dual address contains invalid IPv4 address: " - + HtmlEncoder.encode(ipLiteral)); - } - } - break; - } - validateH16(ipLiteral, inProgress.substring(0, nextColon)); - segments++; - if (inProgress.length() >= nextColon + 2) { - if (inProgress.charAt(nextColon + 1) == ':') { - // double colon, keep it there - inProgress = inProgress.substring(nextColon); - continue; - } - } - inProgress = inProgress.substring(nextColon + 1); - if (inProgress.isBlank()) { - // this must fail on empty segment - validateH16(ipLiteral, inProgress); - } - } - - if (segments > 8) { - throw new IllegalArgumentException("Host IPv6 address contains too many segments: " + HtmlEncoder.encode(ipLiteral)); - } + UriValidator.validateIpLiteral(ipLiteral); } /** * Validate IPv4 address or a registered name. * * @param host string with either an IPv4 address, or a registered name - * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is HTML encoded + * @throws java.lang.IllegalArgumentException in case the host is not valid, the message is percent encoded + * @deprecated use {@link io.helidon.common.uri.UriValidator#validateNonIpLiteral(String)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") public static void validateNonIpLiteral(String host) { - Objects.requireNonNull(host); - checkNotBlank("Host", host, host); - - // Ipv4 address: 127.0.0.1 - Matcher matcher = IP_V4_PATTERN.matcher(host); - if (matcher.matches()) { - /* - IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet - dec-octet = DIGIT ; 0-9 - / %x31-39 DIGIT ; 10-99 - / "1" 2DIGIT ; 100-199 - / "2" %x30-34 DIGIT ; 200-249 - / "25" %x30-35 ; 250-255 - */ - - // we have found an IPv4 address, or a valid registered name (555.555.555.555 is a valid name...) - return; - } - - // everything else is a registered name - - // registered name - /* - reg-name = *( unreserved / pct-encoded / sub-delims ) - pct-encoded = "%" HEXDIG HEXDIG - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - / "*" / "+" / "," / ";" / "=" - */ - char[] charArray = host.toCharArray(); - for (int i = 0; i < charArray.length; i++) { - char c = charArray[i]; - if (c > 255) { - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(host)); - } - if (UNRESERVED[c]) { - continue; - } - if (SUB_DELIMS[c]) { - continue; - } - if (c == '%') { - // percent encoding - if (i + 2 >= charArray.length) { - throw new IllegalArgumentException("Host contains invalid % encoding: " + HtmlEncoder.encode(host)); - } - char p1 = charArray[++i]; - char p2 = charArray[++i]; - // %p1p2 - if (p1 > 255 || p2 > 255) { - throw new IllegalArgumentException("Host contains invalid character in % encoding: " - + HtmlEncoder.encode(host)); - } - if (HEXDIGIT[p1] && HEXDIGIT[p2]) { - continue; - } - throw new IllegalArgumentException("Host contains non-hexadecimal character in % encoding: " - + HtmlEncoder.encode(host)); - } - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(host)); - } - } - - private static void validateH16(String host, String inProgress) { - if (inProgress.isBlank()) { - throw new IllegalArgumentException("IPv6 segment is empty: " + HtmlEncoder.encode(host)); - } - if (inProgress.length() > 4) { - throw new IllegalArgumentException("IPv6 segment has more than 4 characters: " + HtmlEncoder.encode(host)); - } - validateHexDigits("IPv6 segment", host, inProgress); - } - - private static void validateHexDigits(String description, String host, String segment) { - for (char c : segment.toCharArray()) { - if (c > 255) { - throw new IllegalArgumentException(description + " non hexadecimal character: " + HtmlEncoder.encode(host)); - } - if (!HEXDIGIT[c]) { - throw new IllegalArgumentException(description + " non hexadecimal character: " + HtmlEncoder.encode(host)); - } - } - } - - private static void validateIpOctet(String message, String host, String octet) { - int octetInt = Integer.parseInt(octet); - // cannot be negative, as the regexp will not match - if (octetInt > 255) { - throw new IllegalArgumentException(message + " " + HtmlEncoder.encode(host)); - } - } - - private static void validateIpFuture(String ipLiteral, String host) { - /* - IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) - unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" - */ - int dot = host.indexOf('.'); - if (dot == -1) { - throw new IllegalArgumentException("IP Future must contain 'v.': " + HtmlEncoder.encode(ipLiteral)); - } - // always starts with v - String version = host.substring(1, dot); - checkNotBlank("Version", ipLiteral, version); - validateHexDigits("Future version", ipLiteral, version); - - String address = host.substring(dot + 1); - checkNotBlank("IP Future", ipLiteral, address); - - for (char c : address.toCharArray()) { - if (c > 255) { - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(ipLiteral)); - } - if (UNRESERVED[c]) { - continue; - } - if (SUB_DELIMS[c]) { - continue; - } - if (c == ':') { - continue; - } - throw new IllegalArgumentException("Host contains invalid character: " + HtmlEncoder.encode(ipLiteral)); - } - } - - private static void checkNotBlank(String message, String ipLiteral, String toValidate) { - if (toValidate.isBlank()) { - throw new IllegalArgumentException(message + " cannot be blank: " + HtmlEncoder.encode(ipLiteral)); - } + UriValidator.validateNonIpLiteral(host); } } diff --git a/http/http/src/main/java/io/helidon/http/HttpPrologue.java b/http/http/src/main/java/io/helidon/http/HttpPrologue.java index 0f241b154d0..517fbe459fd 100644 --- a/http/http/src/main/java/io/helidon/http/HttpPrologue.java +++ b/http/http/src/main/java/io/helidon/http/HttpPrologue.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,7 +75,7 @@ private HttpPrologue(String rawProtocol, this.method = httpMethod; this.uriPath = uriPath; this.rawQuery = uriQuery.rawValue(); - this.rawFragment = uriFragment.rawValue(); + this.rawFragment = uriFragment.hasValue() ? uriFragment.rawValue() : null; this.fragment = uriFragment; this.query = uriQuery; diff --git a/http/http/src/test/java/io/helidon/http/HostValidatorTest.java b/http/http/src/test/java/io/helidon/http/HostValidatorTest.java index f0144f9e787..83c1efb9887 100644 --- a/http/http/src/test/java/io/helidon/http/HostValidatorTest.java +++ b/http/http/src/test/java/io/helidon/http/HostValidatorTest.java @@ -18,92 +18,91 @@ import org.junit.jupiter.api.Test; -import static io.helidon.http.HostValidator.validate; -import static io.helidon.http.HostValidator.validateIpLiteral; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +@SuppressWarnings("removal") class HostValidatorTest { @Test void testGoodHostname() { // sanity - validate("localhost"); + io.helidon.http.HostValidator.validate("localhost"); // host names - validate("www.example.com"); + io.helidon.http.HostValidator.validate("www.example.com"); // percent encoded - validate("%65%78%61%6D%70%6C%65"); - validate("%65%78%61%6D%70%6C%65.com"); + io.helidon.http.HostValidator.validate("%65%78%61%6D%70%6C%65"); + io.helidon.http.HostValidator.validate("%65%78%61%6D%70%6C%65.com"); // with underscores - validate("www.exa_mple.com"); + io.helidon.http.HostValidator.validate("www.exa_mple.com"); // with sub-delims - validate("www.exa$mple.com"); + io.helidon.http.HostValidator.validate("www.exa$mple.com"); } @Test void testGoodIp4() { // IPv4 - validate("192.167.1.1"); + io.helidon.http.HostValidator.validate("192.167.1.1"); } @Test void testGoodIpLiteral6() { // IPv6 - validate("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]"); - validate("[::1]"); - validate("[2001:db8:3333:4444:5555:6666:7777:8888]"); - validate("[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]"); - validate("[::]"); - validate("[2001:db8::]"); - validate("[::1234:5678]"); - validate("[::1234:5678:1]"); - validate("[2001:db8::1234:5678]"); - validate("[2001:db8:1::ab9:C0A8:102]"); + io.helidon.http.HostValidator.validate("[2001:0db8:0001:0000:0000:0ab9:C0A8:0102]"); + io.helidon.http.HostValidator.validate("[::1]"); + io.helidon.http.HostValidator.validate("[2001:db8:3333:4444:5555:6666:7777:8888]"); + io.helidon.http.HostValidator.validate("[2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF]"); + io.helidon.http.HostValidator.validate("[::]"); + io.helidon.http.HostValidator.validate("[2001:db8::]"); + io.helidon.http.HostValidator.validate("[::1234:5678]"); + io.helidon.http.HostValidator.validate("[::1234:5678:1]"); + io.helidon.http.HostValidator.validate("[2001:db8::1234:5678]"); + io.helidon.http.HostValidator.validate("[2001:db8:1::ab9:C0A8:102]"); } @Test void testGoodIpLiteral6Dual() { // IPv6 - validate("[2001:db8:3333:4444:5555:6666:1.2.3.4]"); - validate("[::11.22.33.44]"); - validate("[2001:db8::123.123.123.123]"); - validate("[::1234:5678:91.123.4.56]"); - validate("[::1234:5678:1.2.3.4]"); - validate("[2001:db8::1234:5678:5.6.7.8]"); + io.helidon.http.HostValidator.validate("[2001:db8:3333:4444:5555:6666:1.2.3.4]"); + io.helidon.http.HostValidator.validate("[::11.22.33.44]"); + io.helidon.http.HostValidator.validate("[2001:db8::123.123.123.123]"); + io.helidon.http.HostValidator.validate("[::1234:5678:91.123.4.56]"); + io.helidon.http.HostValidator.validate("[::1234:5678:1.2.3.4]"); + io.helidon.http.HostValidator.validate("[2001:db8::1234:5678:5.6.7.8]"); } @Test void testGoodIpLiteralFuture() { // IPvFuture - validate("[v9.abc:def]"); - validate("[v9.abc:def*]"); + io.helidon.http.HostValidator.validate("[v9.abc:def]"); + io.helidon.http.HostValidator.validate("[v9.abc:def*]"); } @Test void testBadHosts() { // just empty - invokeExpectFailure("Host cannot be blank: ", ""); + invokeExpectFailure("Host cannot be blank", ""); // invalid brackets - invokeExpectFailure("Host contains invalid character: [start.but.not.end", + invokeExpectFailure("Host contains invalid char: [start.but.not.end, index: 0, char: '['", "[start.but.not.end"); - invokeExpectFailure("Host contains invalid character: end.but.not.start]", + invokeExpectFailure("Host contains invalid char: end.but.not.start], index: 17, char: ']'", "end.but.not.start]"); - invokeExpectFailure("Host contains invalid character: int.the[.middle]", + invokeExpectFailure("Host contains invalid char: int.the[.middle], index: 7, char: '['", "int.the[.middle]"); // invalid escape - invokeExpectFailure("Host contains non-hexadecimal character in % encoding: www.%ZAxample.com", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%ZAxample.com, index: 5, char: 'Z'", "www.%ZAxample.com"); - invokeExpectFailure("Host contains non-hexadecimal character in % encoding: www.%AZxample.com", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.%AZxample.com, index: 6, char: 'Z'", "www.%AZxample.com"); // invalid character (non-ASCII - invokeExpectFailure("Host contains invalid character: www.čexample.com", + invokeExpectFailure("Host contains invalid char (non-ASCII): www.?example.com, index: 4, char: 0x10d", "www.čexample.com"); // wrong trailing escape (must be two chars); - invokeExpectFailure("Host contains invalid % encoding: www.example.com%4", + invokeExpectFailure("Host contains invalid % encoding, not enough chars left at index 15: www.example.com%4", "www.example.com%4"); - invokeExpectFailure("Host contains invalid character in % encoding: www.example.com%č4", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%?4, index: 16, char: 0x10d", "www.example.com%č4"); - invokeExpectFailure("Host contains invalid character in % encoding: www.example.com%4č", + invokeExpectFailure("Host has non hexadecimal char in % encoding: www.example.com%4?, index: 17, char: 0x10d", "www.example.com%4č"); } @@ -114,15 +113,16 @@ void testBadLiteral6() { invokeExpectFailure("Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", "[2001:db8::85a3::7334]"); // wrong segment (G is not a hexadecimal number) - invokeExpectFailure("IPv6 segment non hexadecimal character: " - + "[GGGG:FFFF:0000:0000:0000:0000:0000:0000]", + invokeExpectFailure("IPv6 segment has non hexadecimal char: GGGG, index: 0, char: 'G'. " + + "Value: [GGGG:FFFF:0000:0000:0000:0000:0000:0000]", "[GGGG:FFFF:0000:0000:0000:0000:0000:0000]"); // non-ASCII character - invokeExpectFailure("IPv6 segment non hexadecimal character: " - + "[č:FFFF:0000:0000:0000:0000:0000:0000]", + invokeExpectFailure("IPv6 segment has non hexadecimal char: ?, index: 0, char: 0x10d. " + + "Value: [?:FFFF:0000:0000:0000:0000:0000:0000]", "[č:FFFF:0000:0000:0000:0000:0000:0000]"); // wrong segment (too many characters) - invokeExpectFailure("IPv6 segment has more than 4 characters: [aaaaa:FFFF:0000:0000:0000:0000:0000:0000]", + invokeExpectFailure("IPv6 segment has more than 4 chars: aaaaa. " + + "Value: [aaaaa:FFFF:0000:0000:0000:0000:0000:0000]", "[aaaaa:FFFF:0000:0000:0000:0000:0000:0000]"); // empty segment invokeExpectFailure("IPv6 segment is empty: [aaaa:FFFF:0000:0000:0000:0000:0000:]", @@ -132,18 +132,18 @@ void testBadLiteral6() { + "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]", "[0000:0000:0000:0000:0000:0000:0000:0000:0000:0000]"); // missing everything - invokeExpectFailure("Host cannot be blank: []", + invokeExpectFailure("Host cannot be blank. Value: []", "[]"); // wrong start (leading colon) - invokeExpectFailure("Host IPv6 contains excessive colon: [:1:0::]", + invokeExpectFailure("Host IPv6 contains excessive colon: :1:0::. Value: [:1:0::]", "[:1:0::]"); // wrong end, colon instead of value - invokeExpectFailure("IPv6 segment non hexadecimal character: [1:0:::]", + invokeExpectFailure("IPv6 segment has non hexadecimal char: :, index: 0, char: ':'. Value: [1:0:::]", "[1:0:::]"); - invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::", + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): [::, index: 2, char: ':'", "[::"); - invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::]", + invokeLiteralExpectFailure("Invalid IP literal, missing square bracket(s): ::], index: 0, char: ':'", "::]"); } @@ -161,27 +161,31 @@ void testBadLiteralDual() { void testBadLiteralFuture() { // IPv future // version must be present - invokeExpectFailure("Version cannot be blank: [v.abc:def]", + invokeExpectFailure("Version cannot be blank. Value: [v.abc:def]", "[v.abc:def]"); // missing address invokeExpectFailure("IP Future must contain 'v.': [v2]", "[v2]"); - invokeExpectFailure("IP Future cannot be blank: [v2.]", + invokeExpectFailure("IP Future cannot be blank. Value: [v2.]", "[v2.]"); // invalid character in the host (valid future) - invokeExpectFailure("Host contains invalid character: [v2./0:::]", + invokeExpectFailure("Host contains invalid char: [v2./0:::], index: 3, char: '/'", "[v2./0:::]"); - invokeExpectFailure("Host contains invalid character: [v2.0:č]", + invokeExpectFailure("Host contains invalid char (non-ASCII): 0:?, index: 2, char: 0x10d", "[v2.0:č]"); } private static void invokeExpectFailure(String message, String host) { - var t = assertThrows(IllegalArgumentException.class, () -> validate(host), "Testing host: " + host); + var t = assertThrows(IllegalArgumentException.class, + () -> io.helidon.http.HostValidator.validate(host), + "Testing host: " + host); assertThat(t.getMessage(), is(message)); } private static void invokeLiteralExpectFailure(String message, String host) { - var t = assertThrows(IllegalArgumentException.class, () -> validateIpLiteral(host), "Testing host: " + host); + var t = assertThrows(IllegalArgumentException.class, + () -> io.helidon.http.HostValidator.validateIpLiteral(host), + "Testing host: " + host); assertThat(t.getMessage(), is(message)); } } \ No newline at end of file diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java new file mode 100644 index 00000000000..80b83a712b0 --- /dev/null +++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/BadPrologueTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.tests; + +import java.util.List; + +import io.helidon.common.testing.http.junit5.SocketHttpClient; +import io.helidon.http.HttpPrologue; +import io.helidon.http.Method; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http1.Http1Route; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +@ServerTest +class BadPrologueTest { + private final Http1Client client; + private final SocketHttpClient socketClient; + + BadPrologueTest(Http1Client client, SocketHttpClient socketClient) { + this.client = client; + this.socketClient = socketClient; + } + + @SetUpRoute + static void routing(HttpRouting.Builder builder) { + builder.route(Http1Route.route(Method.GET, + "/", + (req, res) -> { + HttpPrologue prologue = req.prologue(); + String fragment = prologue.fragment().hasValue() + ? prologue.fragment().rawValue() + : ""; + res.send("path: " + prologue.uriPath().rawPath() + + ", query: " + prologue.query().rawValue() + + ", fragment: " + fragment); + })); + } + + @Test + void testOk() { + String response = client.method(Method.GET) + .requestEntity(String.class); + + assertThat(response, is("path: /, query: , fragment: ")); + } + + @Test + void testBadQuery() { + String response = socketClient.sendAndReceive(Method.GET, + "/?a=bad", + null, + List.of()); + + assertThat(response, containsString("400 Bad Request")); + // beginning of message to the first double quote + assertThat(response, containsString("Query contains invalid char: ")); + // end of message from double quote, index of bad char, and bad char + assertThat(response, containsString(", index: 2, char: 0x3c")); + assertThat(response, not(containsString(">"))); + } + + @Test + void testBadQueryCurly() { + String response = socketClient.sendAndReceive(Method.GET, + "/?name=test1{{", + null, + List.of()); + + assertThat(response, containsString("400 Bad Request")); + // beginning of message to the first double quote + assertThat(response, containsString("Query contains invalid char: ")); + // end of message from double quote, index of bad char, and bad char + assertThat(response, containsString(", index: 10, char: 0x7b")); + } + + @Test + void testBadPath() { + String response = socketClient.sendAndReceive(Method.GET, + "/name{{{{{{{Sdsds", + null, + List.of()); + + assertThat(response, containsString("400 Bad Request")); + // beginning of message to the first double quote + assertThat(response, containsString("Fragment contains invalid char: ")); + // end of message from double quote, index of bad char, and bad char + assertThat(response, containsString(", index: 16, char: 0x3e")); + assertThat(response, not(containsString(">"))); + } +} diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java index dc8a93daf42..915e69954d2 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ConfigBlueprint.java @@ -77,7 +77,7 @@ interface Http1ConfigBlueprint extends ProtocolConfig { * Request host header validation. * When host header is invalid, we return {@link io.helidon.http.Status#BAD_REQUEST_400}. *

- * The validation is done according to RFC-3986 (see {@link io.helidon.http.HostValidator}). This is a requirement of + * The validation is done according to RFC-3986 (see {@link io.helidon.common.uri.UriValidator}). This is a requirement of * the HTTP specification. *

* This option allows you to disable the "full-blown" validation ("simple" validation is still in - the port must be @@ -108,6 +108,17 @@ interface Http1ConfigBlueprint extends ProtocolConfig { @Option.DefaultBoolean(false) boolean validateResponseHeaders(); + /** + * If set to false, any query and fragment is accepted (even containing illegal characters). + * Validation of path is controlled by {@link #validatePath()}. + * + * @return whether to validate prologue query and fragment + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean validatePrologue(); + + /** * If set to false, any path is accepted (even containing illegal characters). * diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java index f0c4b13c37a..acc07c4c963 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1Connection.java @@ -37,13 +37,13 @@ import io.helidon.common.mapper.MapperException; import io.helidon.common.task.InterruptableTask; import io.helidon.common.tls.TlsUtils; +import io.helidon.common.uri.UriValidator; import io.helidon.http.BadRequestException; import io.helidon.http.DateTime; import io.helidon.http.DirectHandler; import io.helidon.http.DirectHandler.EventType; import io.helidon.http.HeaderNames; import io.helidon.http.HeaderValues; -import io.helidon.http.HostValidator; import io.helidon.http.HtmlEncoder; import io.helidon.http.HttpPrologue; import io.helidon.http.InternalServerException; @@ -160,6 +160,10 @@ public void handle(Limit limit) throws InterruptedException { currentEntitySize = 0; currentEntitySizeRead = 0; + if (http1Config.validatePrologue()) { + validatePrologue(prologue); + } + WritableHeaders headers = http1headers.readHeaders(prologue); if (http1Config.validateRequestHeaders()) { validateHostHeader(prologue, headers, http1Config.validateRequestHostHeader()); @@ -318,6 +322,26 @@ static void validateHostHeader(HttpPrologue prologue, WritableHeaders headers } } + private void validatePrologue(HttpPrologue prologue) { + try { + // scheme is not validated, as it is fixed and validated by the prologue reader + UriValidator.validateQuery(prologue.query().rawValue()); + if (prologue.fragment().hasValue()) { + UriValidator.validateFragment(prologue.fragment().rawValue()); + } + } catch (IllegalArgumentException e) { + throw RequestException.builder() + .type(EventType.BAD_REQUEST) + .status(Status.BAD_REQUEST_400) + .request(DirectTransportRequest.create(prologue, ServerRequestHeaders.create())) + .setKeepAlive(false) + .message(e.getMessage()) + .safeMessage(true) + .cause(e) + .build(); + } + } + private static void simpleHostHeaderValidation(HttpPrologue prologue, WritableHeaders headers) { if (headers.contains(HeaderNames.HOST)) { String host = headers.get(HeaderNames.HOST).get(); @@ -384,17 +408,17 @@ private static void doValidateHostHeader(HttpPrologue prologue, WritableHeaders< int endLiteral = host.lastIndexOf(']'); if (startLiteral == 0 && endLiteral == host.length() - 1) { // this is most likely an IPv6 address without a port - HostValidator.validateIpLiteral(host); + UriValidator.validateIpLiteral(host); return; } if (startLiteral == 0 && endLiteral == -1) { - HostValidator.validateIpLiteral(host); + UriValidator.validateIpLiteral(host); return; } int colon = host.lastIndexOf(':'); if (colon == -1) { // only host - HostValidator.validateNonIpLiteral(host); + UriValidator.validateNonIpLiteral(host); return; } @@ -414,11 +438,11 @@ private static void doValidateHostHeader(HttpPrologue prologue, WritableHeaders< // can be // IP-literal [..::] if (startLiteral == 0 && endLiteral == hostString.length() - 1) { - HostValidator.validateIpLiteral(hostString); + UriValidator.validateIpLiteral(hostString); return; } - HostValidator.validateNonIpLiteral(hostString); + UriValidator.validateNonIpLiteral(hostString); } private BufferData readEntityFromPipeline(HttpPrologue prologue, WritableHeaders headers) { diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java index d817e044ada..d78090d29e7 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/http1/ValidateHostHeaderTest.java @@ -114,7 +114,7 @@ void testBadPortSimpleValidation() { void testBadHosts() { // just empty invokeExpectFailure("Host header must not be empty", ""); - invokeExpectFailure("Invalid Host header: Host contains invalid character: int.the[.middle]", + invokeExpectFailure("Invalid Host header: Host contains invalid char: int.the[.middle], index: 7, char: '['", "int.the[.middle]:8080"); } @@ -122,7 +122,8 @@ void testBadHosts() { void testBadLiteral6() { // IPv6 // empty segment - invokeExpectFailure("Invalid Host header: Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", + invokeExpectFailure("Invalid Host header: " + + "Host IPv6 contains more than one skipped segment: [2001:db8::85a3::7334]", "[2001:db8::85a3::7334]"); } @@ -130,7 +131,7 @@ void testBadLiteral6() { void testBadLiteralFuture() { // IPv future // version must be present - invokeExpectFailure("Invalid Host header: Version cannot be blank: [v.abc:def]", + invokeExpectFailure("Invalid Host header: Version cannot be blank. Value: [v.abc:def]", "[v.abc:def]"); // missing address } From bf5e61ff826062e2abd54e739eea3ef7ea1a56ae Mon Sep 17 00:00:00 2001 From: Andrei Arlou Date: Thu, 7 Nov 2024 19:12:08 +0200 Subject: [PATCH 03/36] 4.x: Remove workaround for Parsson unicode detection issue (#8260) (#9472) --- .../io/helidon/security/jwt/jwk/JwkKeys.java | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/security/jwt/src/main/java/io/helidon/security/jwt/jwk/JwkKeys.java b/security/jwt/src/main/java/io/helidon/security/jwt/jwk/JwkKeys.java index 9e339384cee..9b8a037e890 100644 --- a/security/jwt/src/main/java/io/helidon/security/jwt/jwk/JwkKeys.java +++ b/security/jwt/src/main/java/io/helidon/security/jwt/jwk/JwkKeys.java @@ -19,9 +19,6 @@ import java.io.IOException; import java.io.InputStream; import java.lang.System.Logger.Level; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.nio.charset.UnsupportedCharsetException; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -30,7 +27,6 @@ import java.util.Objects; import java.util.Optional; -import io.helidon.common.NativeImageHelper; import io.helidon.common.configurable.Resource; import jakarta.json.Json; @@ -59,26 +55,6 @@ public final class JwkKeys { private final Map keyMap = new HashMap<>(); private final List noKeyIdKeys = new LinkedList<>(); - private static final boolean AUTOMATIC_CHARSET_DETECTION; - - // Workaround for https://github.com/eclipse-ee4j/parsson/issues/121 - static { - boolean utf32Available = false; - try { - Charset.forName("UTF-32LE"); - Charset.forName("UTF-32BE"); - utf32Available = true; - } catch (UnsupportedCharsetException e) { - if (NativeImageHelper.isNativeImage()) { - LOGGER.log(Level.TRACE, "Automatic JSON unicode detection not available." - + " Add -H:+AddAllCharsets to build your native image with UTF-32 support.", e); - } else { - LOGGER.log(Level.TRACE, "Automatic JSON unicode detection not available.", e); - } - } - AUTOMATIC_CHARSET_DETECTION = utf32Available; - } - private JwkKeys(Builder builder) { this.keyMap.putAll(builder.keyMap); this.noKeyIdKeys.addAll(builder.noKeyIdKeys); @@ -171,9 +147,7 @@ public Builder addKey(Jwk key) { public Builder resource(Resource resource) { Objects.requireNonNull(resource, "Json resource must not be null"); try (InputStream is = resource.stream()) { - JsonObject jsonObject = AUTOMATIC_CHARSET_DETECTION - ? JSON.createReader(is).readObject() - : JSON.createReader(is, StandardCharsets.UTF_8).readObject(); + JsonObject jsonObject = JSON.createReader(is).readObject(); addKeys(jsonObject); } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to close input stream on resource: " + resource); From 831cc18ad6a729f02d8231b5a6110153a0a8480f Mon Sep 17 00:00:00 2001 From: Andrei Arlou Date: Fri, 8 Nov 2024 22:31:22 +0200 Subject: [PATCH 04/36] 4.x: Replace deprecated VM memory flags in docs for building container images (#8876) (#9479) --- docs/src/main/asciidoc/guides/jib.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/main/asciidoc/guides/jib.adoc b/docs/src/main/asciidoc/guides/jib.adoc index eb9ba05790f..022031c4bdc 100644 --- a/docs/src/main/asciidoc/guides/jib.adoc +++ b/docs/src/main/asciidoc/guides/jib.adoc @@ -72,9 +72,9 @@ Add the following plugin declaration to your pom.xml: -Djava.awt.headless=true -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap - -XX:InitialRAMFraction=2 - -XX:MinRAMFraction=2 - -XX:MaxRAMFraction=2 + -XX:InitialRAMPercentage=50 + -XX:MinRAMPercentage=50 + -XX:MaxRAMPercentage=50 -XX:+UseG1GC ${mainClass} From 58e644aae70feefb25fbd7925c8241810ffe6538 Mon Sep 17 00:00:00 2001 From: Thibault Vallin Date: Wed, 13 Nov 2024 14:33:21 +0100 Subject: [PATCH 05/36] 4.x Add missing dependencies from OIDC guides (#9478) --- .../main/asciidoc/mp/guides/security-oidc.adoc | 6 +++++- .../main/asciidoc/se/guides/security-oidc.adoc | 16 +++++++++++++++- .../docs/mp/guides/SecurityOidcSnippets.java | 10 +++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/src/main/asciidoc/mp/guides/security-oidc.adoc b/docs/src/main/asciidoc/mp/guides/security-oidc.adoc index 94080f6cd90..05f66ab88bf 100644 --- a/docs/src/main/asciidoc/mp/guides/security-oidc.adoc +++ b/docs/src/main/asciidoc/mp/guides/security-oidc.adoc @@ -233,8 +233,12 @@ cd helidon-quickstart-mp Update the pom.xml file and add the following Helidon dependency to the `` section. [source,xml] -.Add the following dependency to `pom.xml`: +.Add the following dependencies to `pom.xml`: ---- + + io.helidon.microprofile + helidon-microprofile-security + io.helidon.microprofile helidon-microprofile-oidc diff --git a/docs/src/main/asciidoc/se/guides/security-oidc.adoc b/docs/src/main/asciidoc/se/guides/security-oidc.adoc index b940b811f52..ebb79dd0e5c 100644 --- a/docs/src/main/asciidoc/se/guides/security-oidc.adoc +++ b/docs/src/main/asciidoc/se/guides/security-oidc.adoc @@ -230,14 +230,28 @@ cd helidon-quickstart-se Update the pom.xml file and add the following Helidon dependency to the `` section. [source,xml] -.Add the following dependency to `pom.xml`: +.Add the following dependencies to `pom.xml`: ---- + + io.helidon.webserver + helidon-webserver-security + io.helidon.security.providers helidon-security-providers-oidc ---- +[source,xml] +.Remove the `test` scope from `helidon-webclient` dependency +---- + + io.helidon.webclient + helidon-webclient + test + +---- + === Add OIDC Security Properties The OIDC security provider configuration can be joined to helidon configuration file. diff --git a/docs/src/main/java/io/helidon/docs/mp/guides/SecurityOidcSnippets.java b/docs/src/main/java/io/helidon/docs/mp/guides/SecurityOidcSnippets.java index b89a416b22d..0f2221815a9 100644 --- a/docs/src/main/java/io/helidon/docs/mp/guides/SecurityOidcSnippets.java +++ b/docs/src/main/java/io/helidon/docs/mp/guides/SecurityOidcSnippets.java @@ -36,12 +36,12 @@ class SecurityOidcSnippets { // stub - static GreetingMessage createResponse(String str) { + static Message createResponse(String str) { return null; } // stub - record GreetingMessage() { + record Message() { String getMessage() { return ""; } @@ -51,7 +51,7 @@ String getMessage() { @Authenticated @GET @Produces(MediaType.APPLICATION_JSON) - public GreetingMessage getDefaultMessage() { + public Message getDefaultMessage() { return createResponse("World"); } // end::snippet_1[] @@ -76,11 +76,11 @@ void snippet_5(WebTarget target) { void snippet_6(WebTarget target) { // tag::snippet_4[] String encoding = Base64.getEncoder().encodeToString("jack:changeit".getBytes()); - GreetingMessage jsonMessage = target + Message jsonMessage = target .path("greet") .request() .header(HttpHeaders.AUTHORIZATION, "Basic " + encoding) - .get(GreetingMessage.class); + .get(Message.class); assertThat(jsonMessage.getMessage(), is("Hello World!")); // end::snippet_4[] From 51b942a90aa9f2416ce6b32687748ee2b7701e75 Mon Sep 17 00:00:00 2001 From: Tim Quinn Date: Wed, 13 Nov 2024 08:26:47 -0600 Subject: [PATCH 06/36] Fully initialize OpenTelemetry items during start-up (#9489) * Fully initialize OpenTelemetry items during start-up * Invoke method on CDI proxy for tracer from extension observer method to ensure the bean is fully created --------- Signed-off-by: Tim Quinn --- .../telemetry/TelemetryCdiExtension.java | 15 ++++++++ .../microprofile/telemetry/TestExtension.java | 32 ++++++++++++++++ .../telemetry/TestTracerAtStartup.java | 37 +++++++++++++++++++ .../jakarta.enterprise.inject.spi.Extension | 1 + 4 files changed, 85 insertions(+) create mode 100644 microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestExtension.java create mode 100644 microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestTracerAtStartup.java create mode 100644 microprofile/telemetry/src/test/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension diff --git a/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/TelemetryCdiExtension.java b/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/TelemetryCdiExtension.java index a087a85bcb2..a4767bd01cd 100644 --- a/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/TelemetryCdiExtension.java +++ b/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/TelemetryCdiExtension.java @@ -15,13 +15,19 @@ */ package io.helidon.microprofile.telemetry; +import io.helidon.tracing.Tracer; + import io.opentelemetry.instrumentation.annotations.WithSpan; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Initialized; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; import jakarta.enterprise.inject.spi.Extension; import jakarta.enterprise.inject.spi.ProcessAnnotatedType; import jakarta.enterprise.inject.spi.WithAnnotations; import jakarta.enterprise.inject.spi.configurator.AnnotatedMethodConfigurator; +import jakarta.interceptor.Interceptor; /** * CDI extension for Microprofile Telemetry implementation. @@ -62,4 +68,13 @@ void processAnnotations(@Observes @WithAnnotations(WithSpan.class) ProcessAnnota } } } + + void finish(@Observes @Priority(Interceptor.Priority.LIBRARY_BEFORE) @Initialized(ApplicationScoped.class) Object startup, + Tracer tracer) { + // Forcing CDI to get us a tracer and then invoking one of the bean's methods triggers the producer to do its + // initialization, including setting the global tracer as part of start up. + tracer.enabled(); + LOGGER.log(System.Logger.Level.TRACE, + () -> "Global tracer set to " + tracer.unwrap(io.opentelemetry.api.trace.Tracer.class)); + } } diff --git a/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestExtension.java b/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestExtension.java new file mode 100644 index 00000000000..db4707fbca2 --- /dev/null +++ b/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestExtension.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.telemetry; + +import io.helidon.tracing.Tracer; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Initialized; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.spi.Extension; + +public class TestExtension implements Extension { + + static Tracer globalTracerAtStartup; + + void startup(@Observes @Initialized(ApplicationScoped.class) Object startup) { + globalTracerAtStartup = Tracer.global(); + } +} diff --git a/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestTracerAtStartup.java b/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestTracerAtStartup.java new file mode 100644 index 00000000000..b8a4556a476 --- /dev/null +++ b/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestTracerAtStartup.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.telemetry; + +import io.helidon.microprofile.testing.junit5.AddConfig; +import io.helidon.microprofile.testing.junit5.HelidonTest; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +@HelidonTest +@AddConfig(key = "otel.sdk.disabled", value = "false") +class TestTracerAtStartup { + + @Test + void checkForFullFeaturedTracerAtStartup() { + assertThat("Global tracer from start-up extension", + TestExtension.globalTracerAtStartup.unwrap(io.opentelemetry.api.trace.Tracer.class).getClass().getName(), + not(containsString("Default"))); + } +} diff --git a/microprofile/telemetry/src/test/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension b/microprofile/telemetry/src/test/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension new file mode 100644 index 00000000000..4fe1f6c72f9 --- /dev/null +++ b/microprofile/telemetry/src/test/resources/META-INF/services/jakarta.enterprise.inject.spi.Extension @@ -0,0 +1 @@ +io.helidon.microprofile.telemetry.TestExtension \ No newline at end of file From 86122fc1c04b4d0ae723a149cbc5fb84fc5ebeb5 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Thu, 14 Nov 2024 14:37:23 +0100 Subject: [PATCH 07/36] Updates to builder documentation. (#9487) --- builder/README.md | 290 +++++++++++++++--- builder/api/README.md | 149 +-------- .../java/io/helidon/builder/api/Option.java | 2 +- 3 files changed, 245 insertions(+), 196 deletions(-) diff --git a/builder/README.md b/builder/README.md index 14edca49d24..e7285949400 100644 --- a/builder/README.md +++ b/builder/README.md @@ -1,87 +1,281 @@ # Helidon Builder -This module is used by Helidon to generate types with builders (Prototypes) to be used in API of modules from a blueprint interface. +This module is used by Helidon to generate types with builders (Prototypes) to be used in API of modules from a blueprint +interface. There are two modules that are used: -- `helidon-builder-api` - module required in `compile` scope, contains annotations and APIs needed to write blueprints, and to build the generated code -- `helidon-builder-processor` - module to be placed on annotation processor path, generates the sources + +- `helidon-builder-api` - module required in `compile` scope, contains annotations and APIs needed to write blueprints, to compile + the generated code, and at runtime +- `helidon-builder-codegen` - module to be placed on annotation processor path, generates the sources There is one module useful for internal development -- `helidon-builder-tests-common-types` (located under `tests/common-types`) that contains blueprints for the types we use in `helidon-common-types`. As the common types module is used by the processor, we would end up with a cyclic dependency, so this allows us to generate the next iteration of common types (requires manual copying of the generated types) + +- `helidon-builder-tests-common-types` (located under `tests/common-types`) that contains blueprints for the types we use in + `helidon-common-types`. As the common types module is used by the processor, we would end up with a cyclic dependency, so this + allows us to generate the next iteration of common types (requires manual copying of the generated types) + +This document describes the main features and usage, there are further customization option. Kindly check usages of +`Prototype.Blueprint` in this repository, to see examples... + +Table of contents: + +- [Goals](#goals) - what we do +- [Non-Goals](#non-goals) - what we decided not to do +- [Rules](#rules) - what are the rules when using this module +- [Use Cases](#use-cases) - supported use cases +- [Getting Started](#getting-started) - set up your `pom.xml` and use this module +- [API](#api) - more details on available annotations and interfaces ## Goals Generate all required types for Helidon APIs with builders, that follow the same style (method names, required validation etc.). +Support for builders that can read options from Helidon configuration (`helidon-common-config`, and of course `helidon-config`). -The following list of features is currently supported: -- `Builder` also implements the interface of the type (all getters are available also on builder) -- `Type` options - interface returns `Type`, such an option MUST NOT be null in the built instance, there is a validation in place on calling the `build` or `buildPrototype` methods. Getters MAY return null on a builder -- `Optional` options - interface returns `Optional`, setters use just `Type`, there is a package local setter that accepts `Optional` as well, to support updating a builder from an existing instance -- `List` options - interface returns `List`, never null - if there is no configured value, empty string is returned -- `Set` options - similar to list -- `Map` options - key/value map, builders support any key/value types, but if configuration is used, the key must be a string -- "Singular" for collection based options, which adds setter for a single value (for `List algorithms()`, there would be the following setters: `algorithms(List)`, `addAlgorithms(List)`, `addAlgorithm(String)`) -- A type can be `@Configured`, which adds integration with Helidon common Config module, by adding a static factory method `create(io.helidon.common.Config)` to the generated type, as well as `config(Config)` method to the generated builder, that sets all options annotated with `@ConfiguredOption` from configuration (if present in the Config instance) -- Capability to update the builder before validation (decorator) -- Support for custom methods (`@Prototype.CustomMethods`) for factory methods, prototype methods, and builder methods +- We MUST NOT change bytecode of user classes +- We MUST NOT use reflection (everything is code generated) +- Support inheritance of prototypes (and of blueprints if in the same module) +- The generated code is the public API (and must have javadoc generated) +- The annotated blueprint interface is used to generate configuration metadata, and configuration documentation ( + see [Config metadata](../config/metadata/README.md)) +- Support prototypes configured from Helidon configuration (as an optional feature) +- Support additional methods to be generated (factory methods, prototype methods, builder methods) +- Support the following collections: `List`, `Set`, and `Map` +- Support for default values, for the most commonly used types to be typed explicitly (String, int, long, boolean etc.) +- Support for `enum` options ## Non-Goals -We are not building a general purpose solution, there are limitations that are known and will not be targetted: -- the solution expects that everything is single package - blueprints are required to be package local, which does not allow using built types across packages within a single module +We are not building a general purpose solution, there are limitations that are known and will not be targeted: + +- the solution expects that everything is single package - blueprints are required to be package local, which does not allow using + built types across packages within a single module - we only support interface based definition of blueprints (no classes) - we only support non-nullable options, instead of nullable, use `Optional` getters - implementation types of collections are fixed to `java.util.ArrayList`, `java.util.LinkedHashSet` and `java.util.LinkedHashMap` +## Rules + +There are a few rules we required and enforce: + +1. Blueprint MUST be an interface +2. Blueprint interface MUST be package private +3. Blueprint interface must have a name that ends with `Blueprint`; the name before `Blueprint` will be the name of the prototype +4. In case we use the blueprint -> prototype -> runtime type use case (see below): + 1. The blueprint must extend `Prototype.Factory` where `RuntimeType` is the type of the runtime object + 2. The runtime type must be annotated with `@RuntimeType.PrototypedBy(PrototypeBlueprint.class)` + 3. The runtime type must implement `RuntimeType.Api` + 4. The runtime type must have a `public static Prototype.Builder builder()` method implemented by user + 5. The runtime type must have a `public static RuntimeType create(Prototype)` method implemented by user + 6. The runtime type must have a `public static RuntimeType create(Consumer)` method implemented by user + +## Use Cases + +There are two use cases we cover: + +1. We need a type with a builder (we will use `Keys` as an example) +2. We need a runtime object, with a prototype with a builder (we will use `Retry` as an example) + +For both use cases, we need to understand how to create instances, obtain builders etc. + +### Type with a builder + +For this simple approach, the user facing API will look as: + +```java +Keys keys = Keys.builder() + .name("name") + .build(); +``` + +Configuration based API: + +```java +// the location of config is arbitrary, the API expects in ono the Keys node +Keys keys = Keys.create(config.get("keys")); +``` + +The "blueprint" of such type: + +```java +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; + +@Prototype.Blueprint +@Prototype.Configured // support method config(Config) on the builder, and a static create(Config) +interface KeysBlueprint { + @Option.Configured + String name(); +} +``` + +This will generate: + +- `Keys extends KeysBlueprint` interface +- `Keys.BuilderBase implements Keys` base builder, to support extensibility of `Keys` +- `Keys.Builder extends Keys.BuilderBase, io.helidon.common.Builder` inner class - the fluent API builder + for `Keys` +- `Keys.BuilderBase.KeysImpl implements Keys` implementation of `Keys` + +### Runtime object, blueprint, builder + +For this approach, the user facing API will be similar to: + +```java +Retry retry = Retry.builder() // method builder is not generated, must be hand coded, and will return "RetryPrototype.builder()" + .build(); // generated, creates a Retry instance through a factory method defined on Retry or on RetryPrototypeBlueprint + +RetryPrototype prototype = RetryPrototype.builder() + .buildPrototype(); // alternative build method to obtain the intermediate prototype object + +Retry retryFromPrototype = prototype.build(); // to runtime type +``` + +The "blueprint" of such type: + +```java +@Prototype.Blueprint +@Prototype.Configured // support method config(Config) on the builder, and a static create(Config) method if desired +intrerface RetryPrototypeBlueprint extends Prototype.Factory + + { + @Option.Configured + String name (); +} +``` + ## Getting Started -1. Write your interface that you want to have a builder for. + +1. Write your interface that you want to have a builder for + ```java interface MyConfigBeanBlueprint { String getName(); + boolean isEnabled(); + int getPort(); } ``` -2. Annotate your interface definition with `@Blueprint`, and optionally use `@ConfiguredOption`, `Singular` etc. to customize the getter methods. Remember to review the annotation attributes javadoc for any customizations. + +2. Annotate your interface definition with `@Blueprint`, and optionally use `@Prototype.Configured` and `@Option.Configured`, + `@Option.Singular` etc. to customize the getter methods. Remember to review the annotation attributes javadoc for any + customizations 3. Update your pom file to add annotation processor + ```xml ... - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - io.helidon.builder - helidon-builder-processor - ${helidon.version} - - - - - - + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + io.helidon.builder - helidon-builder-processor + helidon-builder-codegen ${helidon.version} - - - - - - ... + + + + + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + + + + ... ``` Generated types will be available under `./target/generated-sources/annotations` + * MyConfigBean (in the same package as MyConfigBeanBlueprint), with inner classes `BuilderBase` (for inheritance), `Builder`, * Support for `toString()`, `hashCode()`, and `equals()` are always included. * Support for `builder(MyConfigBean)` to create a new builder from an existing instance * Support for `from(MyConfigBean)` and `from(MyConfigBean.BuilderBase)` to update builder from an instance or builder -* Support for validation of required and non-nullable options (required options are options that have `@ConfiguredOption(required=true)`, non-nullable option is any option that is not primitive, collection, and does not return an `Optional`) +* Support for validation of required and non-nullable options (required options are options that have `@Option.Required` and are + primitive), non-nullable option is any option that is not primitive, collection, and does not return an `Optional`) * Support for builder decorator (`@Bluprint(decorator = MyDecorator.class)`), `class MyDecorator implements BuilderDecorator` + +## API + +The API has to sections: + +1. Inner types of `Prototype` class to configure `Blueprints`, and of `RuntimeType` to configure runtime types +2. Inner types of `Option` class to configure options + +### Prototype + +Annotations: + +| Annotation | Required | Description | +|-----------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Prototype.Blueprint` | `true` | Annotation on the blueprint interface is required to trigger annotation processing | +| `Prototype.Implement` | `false` | Add additional implemented types to the generated prototype | +| `Prototype.Annotated` | `false` | Allows adding an annotation (or annotations) to the generated class or methods | +| `Prototype.FactoryMethod` | `false` | Use in generated code to mark static factory methods, also can be used on blueprint factory methods to be used during code generation, and on custom methods to mark static methods to be added to prototype | +| `Prototype.Singular` | `false` | Used for lists, sets, and maps to add methods `add*`/`put*` in addition to the full collection setters | +| `Prototype.SameGeneric` | `false` | Use for maps, where we want a setter method to use the same generic type for key and for value (such as `Class key, T valuel`) | +| `Prototype.Redundant` | `false` | A redundant option will not be part of generated `toString`, `hashCode`, and `equals` methods (allows finer grained control) | +| `Prototype.Confidential` | `false` | A confidential option will not have value visible when `toString` is called, only if it is `null` or it has a value (`****`) | +| `Prototype.CustomMethods` | `false` | reference a class that will contain declarations (all static) of custom methods to be added to the generated code, can add prototype, builder, and factory methods | +| `Prototype.BuilderMethod` | `false` | Annotation to be placed on factory methods that are to be added to builder, first parameter is the `BuilderBase` of the prototype | +| `Prototype.PrototypeMethod` | `false` | Annotation to be placed on factory methods that are to be added to prototype, first parameter is the prototype instance | +| `RuntimeType.PrototypedBy` | `true` | Annotation on runtime type that is created from a `Prototype`, to map it to the prototype it can be created from, used to trigger annotation processor for validation | + +Interfaces: + +| Interface | Generated | Description | +|-------------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `RuntimeType.Api` | `false` | runtime type must implement this interface to mark which prototype is used to create it | +| `Prototype.Factory` | `false` | if blueprint implements factory, it means the prototype is used to create a single runtime type and will have methods `build` and `get` both on builder an on prototype interface that create a new instance of the runtime object | +| `Prototype.BuilderDecorator` | `false` | custom decorator to modify builder before validation is done in method `build` | +| `Prototype.Api` | `true` | all prototypes implement this interface | +| `Prototype.Builder` | `true` | all prototype builders implement this interface, defines method `buildPrototype` | +| `Prototype.ConfiguredBuilder` | `true` | all prototype builders that support configuration implement this interface, defines method `config(Config)` | + +### Option + +| Annotation | Description | +|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@Option.Singular` | For collection based options. Adds setter for a single value (for `List algorithms()`, there would be the following setters: `algorithms(List)`, `addAlgorithms(List)`, `addAlgorithm(String)`) | +| `@Option.Configured` | For options that are configured from config (must be explicitly marked, default is not-configured), also ignored unless `@Prototype.Configured` is specified on the blueprint interface | +| `@Option.Required` | We can recognize required options through signature in most cases (any option that does not return an `Optional` and does not have a default value); this option is useful for primitive types, where we need an explicit value set, rather than using the primitive's default value | +| `@Option.Provider` | Satisfied by a provider implementation, see javadoc for details | +| `@Option.AllowedValues` | Allowed values for the property, not required for `enum`, where we create this automatically, though we can configure description of each value (works automatically for `enum` defined in the same module); the description is used for generated documentation | +| `@Option.SameGeneric` | Advanced configuration of a Map, where the map accepts two typed values, and we must use the same generic on setters (such as `Map, Object>` - ` Builder put(Class, T)`) | +| `@Option.Redundant` | Marks an option that is not used by equals and hashCode methods | +| `@Option.Confidential` | Marks an option that will not be visible in `toString()` | +| `@Option.Deprecated` | Marks a deprecated option that has a replacement option in this builder, use Java's deprecation for other cases, they will be honored in the generated code | +| `@Option.Type` | Explicitly defined type of a property (may include generics), in case the type is code generated in the current module, and we cannot obtain the correct information from the annotation processing environment | +| `@Option.Decorator` | Support for field decoration (to do side-effects on setter call) | + +To configure default value(s) of an option, one of the following annotations can be used (mutually exclusive!). +Most defaults support an array, to provide default values for collections. + +| Annotation | Description | +|--------------------------|----------------------------------------------------------------------------------------------------| +| `@Option.Default` | Default value(s) that are `String` or we support coercion to the correct type (`enum`, `Duration`) | +| `@Option.DefaultInt` | Default value(s) that are `int` | +| `@Option.DefaultLong` | Default value(s) that are `long` | +| `@Option.DefaultDouble` | Default value(s) that are `double` | +| `@Option.DefaultBoolean` | Default value(s) that are `boolean` | +| `@Option.DefaultMethod` | Static method to invoke to obtain a default value | +| `@Option.DefaultCode` | Source code to add to the generated assignment, single line only supported | diff --git a/builder/api/README.md b/builder/api/README.md index e48166f5d40..fc01ae5cf0e 100644 --- a/builder/api/README.md +++ b/builder/api/README.md @@ -1,148 +1,3 @@ -# Builder +# Builder API -## Description - -There are two use cases we cover: - -1. We need a type with a builder (we will use `Keys` as an example) -2. We need a runtime object, with a prototype with a builder (we will use `Retry` as an example) - -For both use cases, we need to understand how to create instances, obtain builders etc. - -### Type with a builder - -For this simple approach, the user facing API will look as it does now: - -```java -Keys keys=Keys.builder() - .name("name") - .build(); -``` - -The "blueprint" of such type: - -```java -import io.helidon.config.metadata.Configured; -import io.helidon.config.metadata.ConfiguredOption; - -@Prototype.Blueprint -@Configured // support method config(Config) on the builder, and a static create(Config) -interface KeysBlueprint{ - @ConfiguredOption(required = true) - String name(); - } -``` - -This will generate: - -- `Keys extends KeysBlueprint` interface -- `Keys.BuilderBase implements Keys` base builder, to support extensibility of `Keys` -- `Keys.Builder extends Keys.BuilderBase, io.helidon.common.Builder` inner class - the fluent API builder - for `Keys` -- `Keys.BuilderBase.KeysImpl implements Keys` implementation of `Keys` - -### Runtime object, blueprint, builder - -For this approach, the user facing API will be similar to what we do now: - -```java -Retry retry=Retry.builder() // method builder is not generated, must be hand coded, and will return "RetryPrototype.builder()" - .build(); // generated, creates a Retry instance through a factory method defined on Retry or on RetryPrototypeBlueprint - - RetryPrototype prototype=RetryPrototype.builder() - .buildPrototype(); // alternative build method to obtain the intermediate prototype object - - Retry retryFromSetup=prototype.build(); // to runtime type -``` - -The "blueprint" of such type: - -```java -@Prototype.Blueprint -@Configured // support method config(Config) on the builder, and a static create(Config) method if desired -intrerface RetryPrototypeBlueprint extends Prototype.Factory { -@ConfiguredOption(required = true) - String name(); - } -``` - -## Types, interfaces - -Annotations: - -| Annotation | Required | Description | -|-----------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Prototype.Blueprint` | `true` | Annotation on the blueprint interface is required to trigger annotation processing | -| `Prototype.Implement` | `false` | Add additional implemented types to the generated prototype | -| `Prototype.Annotated` | `false` | Allows adding an annotation (or annotations) to the generated class or methods | -| `Prototype.FactoryMethod` | `false` | Use in generated code to mark static factory methods, also can be used on blueprint factory methods to be used during code generation, and on custom methods to mark static methods to be added to prototype | -| `Prototype.Singular` | `false` | Used for lists, sets, and maps to add methods `add*`/`put*` in addition to the full collection setters | -| `Prototype.SameGeneric` | `false` | Use for maps, where we want a setter method to use the same generic type for key and for value (such as `Class key, T valuel`) | -| `Prototype.Redundant` | `false` | A redundant option will not be part of generated `toString`, `hashCode`, and `equals` methods (allows finer grained control) | -| `Prototype.Confidential` | `false` | A confidential option will not have value visible when `toString` is called, only if it is `null` or it has a value (`****`) | -| `Prototype.CustomMethods` | `false` | reference a class that will contain declarations (all static) of custom methods to be added to the generated code, can add prototype, builder, and factory methods | -| `Prototype.BuilderMethod` | `false` | Annotation to be placed on factory methods that are to be added to builder, first parameter is the `BuilderBase` of the prototype | -| `Prototype.PrototypeMethod` | `false` | Annotation to be placed on factory methods that are to be added to prototype, first parameter is the prototype instance | -| `RuntimeType.PrototypedBy` | `true` | Annotation on runtime type that is created from a `Prototype`, to map it to the prototype it can be created from, used to trigger annotation processor for validation | - -Interfaces: - -| Interface | Generated | Description | -|-------------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `RuntimeType.Api` | `false` | runtime type must implement this interface to mark which prototype is used to create it | -| `Prototype.Factory` | `false` | if blueprint implements factory, it means the prototype is used to create a single runtime type and will have methods `build` and `get` both on builder an on prototype interface that create a new instance of the runtime object | -| `Prototype.BuilderDecorator` | `false` | custom decorator to modify builder before validation is done in method `build` | -| `Prototype.Api` | `true` | all prototypes implement this interface | -| `Prototype.Builder` | `true` | all prototype builders implement this interface, defines method `buildPrototype` | -| `Prototype.ConfiguredBuilder` | `true` | all prototype builders that support configuration implement this interface, defines method `config(Config)` | - -## Configured providers - -We can define a configured option as follows: -`@ConfiguredOption(key = "security-providers", provider = true, providerType = SecurityProviderProvider.class, providerDiscoverServices = false)` - -Rules: - -1. `providerType` MUST extend `io.helidon.common.config.ConfiguredProvider` -2. The method MUST return a `List` of the type the provider creates, so in this case we consider the `SecurityProviderProvider` - to be capable of creating a `SecurityProvider` instance from configuration, so the return type would - be `List`, where `SecurityProvider extends NamedService` and - `SecurityProviderProvider extends ConfiguredProvider` - -This will expect the following configuration: - -```yaml -security-providers: - discover-services: true # optional, to override "providerDiscoverServices" option - providers: - - name: "my-provider" - type: "http-basic" - enabled: true -``` - -The generated code will read all nodes under `providers` and map them to an instance. - -## Naming rules - -Part of the naming rules is constant, part depends on whether we use two or three types, as mentioned above. - -### Blueprint name -Blueprint MUST be package local, and MUST be named with a `Blueprint` suffix. The part of the name before the suffix will be the prototype name. - -### Blueprint -> Prototype -For cases, where the `Prototype` is the target desired type (such as `TypeName`, `Keys`), the prototype name is a name of the type we represent (no suffixes, prefixes etc.). - -Example: `TypeName` would have the following structure (as can be seen in the [builder/tests/common-types](../tests/common-types)): - -- `TypeNameBlueprint` - the definition of the type -- `TypeName` - the generated type to be used as an API - -### Blueprint -> Prototype -> Runtime type -For cases, where the `Prototype` serves as a configuration object of a runtime type (such as `WebServerConfig`, `RetryConfig`), -the prototype name should have a `Config` suffix, and the runtime type is a name of the type we represent. - -Example: `Retry` would have the following structure (can be seen in Fault Tolerance): - -- `RetryConfigBlueprint` - the definition of the config -- `RetryConfig` - the prototype -- `Retry` - the runtime type \ No newline at end of file +This document is merged into parent readme, see [Builder](../README.md). diff --git a/builder/api/src/main/java/io/helidon/builder/api/Option.java b/builder/api/src/main/java/io/helidon/builder/api/Option.java index f1ac6d14a0b..96f6143be93 100644 --- a/builder/api/src/main/java/io/helidon/builder/api/Option.java +++ b/builder/api/src/main/java/io/helidon/builder/api/Option.java @@ -432,7 +432,7 @@ private Option() { * This is useful for example when setting a compound option, where we need to set additional options on this builder. *

* Decorator on collection based options will be ignored. - * Decorator on optional values must accept an option (as it would be called both from the setter and unset methods). + * Decorator on optional values must accept an optional (as it would be called both from the setter and unset methods). */ @Target(ElementType.METHOD) // note: class retention needed for cases when derived builders are inherited across modules From ba8bdd1cb37c4011a793a4c52b3f83b5a4491a53 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Fri, 15 Nov 2024 14:06:05 +0100 Subject: [PATCH 08/36] Only generate scope constants for custom scopes. (#9504) --- .../inject/codegen/InjectionExtension.java | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java index 28fb4954854..2cef3307b2a 100644 --- a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java +++ b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java @@ -2270,17 +2270,31 @@ private void qualifiersMethod(ClassModel.Builder classModel, DescribedService se private void scopeMethod(ClassModel.Builder classModel, DescribedService service) { // TypeName scope() - classModel.addField(scopesField -> scopesField - .isStatic(true) - .isFinal(true) - .name("SCOPE") - .type(TypeNames.TYPE_NAME) - .addContentCreate(service.scope())); + TypeName scope = service.scope(); - classModel.addMethod(scopeMethod -> scopeMethod.name("scope") - .addAnnotation(Annotations.OVERRIDE) - .returnType(TypeNames.TYPE_NAME) - .addContentLine("return SCOPE;")); + if (scope.packageName().equals(INJECTION_SINGLETON.packageName()) + && scope.enclosingNames().size() == 1 && scope.enclosingNames().getFirst().equals("Injection")) { + // only method + classModel.addMethod(scopeMethod -> scopeMethod.name("scope") + .addAnnotation(Annotations.OVERRIDE) + .returnType(TypeNames.TYPE_NAME) + .addContent("return ") + .addContent(scope) + .addContentLine(".TYPE;")); + } else { + // field and method + classModel.addField(scopesField -> scopesField + .isStatic(true) + .isFinal(true) + .name("SCOPE") + .type(TypeNames.TYPE_NAME) + .addContentCreate(scope)); + + classModel.addMethod(scopeMethod -> scopeMethod.name("scope") + .addAnnotation(Annotations.OVERRIDE) + .returnType(TypeNames.TYPE_NAME) + .addContentLine("return SCOPE;")); + } } private void weightMethod(ClassModel.Builder classModel, DescribedService service) { From 9e7a7e8c211eba71ac2e3896566bad25d5ae8194 Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Mon, 18 Nov 2024 12:43:51 -0500 Subject: [PATCH 09/36] Changes in this commit: (#9505) - Special handling for identity compressor to ensure compress flag is not turned on. Identity compression is a special case, and for compatibility it should not be treated as a normal compressor. - If a gRPC service is not found, include ":status" header in response to comply with gRPC spec - A few null checks were missing in GrpcRouteHandler - New tests for all the changes above --- webserver/grpc/pom.xml | 17 ++++ .../webserver/grpc/GrpcProtocolHandler.java | 91 +++++++++++-------- .../grpc/GrpcProtocolHandlerNotFound.java | 5 +- .../webserver/grpc/GrpcRouteHandler.java | 8 +- .../grpc/GrpcProtocolHandlerNotFoundTest.java | 70 ++++++++++++++ .../grpc/GrpcProtocolHandlerTest.java | 69 ++++++++++++++ .../webserver/grpc/GrpcRouteHandlerTest.java | 33 +++++++ webserver/grpc/src/test/proto/strings.proto | 27 ++++++ 8 files changed, 278 insertions(+), 42 deletions(-) create mode 100644 webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFoundTest.java create mode 100644 webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerTest.java create mode 100644 webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcRouteHandlerTest.java create mode 100644 webserver/grpc/src/test/proto/strings.proto diff --git a/webserver/grpc/pom.xml b/webserver/grpc/pom.xml index 032d6d442a3..e95858da32f 100644 --- a/webserver/grpc/pom.xml +++ b/webserver/grpc/pom.xml @@ -150,6 +150,23 @@ + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + test-compile + + + + + com.google.protobuf:protoc:${version.lib.google-protobuf}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${version.lib.grpc}:exe:${os.detected.classifier} + + + diff --git a/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandler.java b/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandler.java index 73b7968cf9b..0e4d62f4a29 100644 --- a/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandler.java +++ b/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandler.java @@ -44,6 +44,7 @@ import io.helidon.http.http2.StreamFlowControl; import io.helidon.webserver.http2.spi.Http2SubProtocolSelector; +import io.grpc.Codec; import io.grpc.Compressor; import io.grpc.CompressorRegistry; import io.grpc.Decompressor; @@ -90,6 +91,7 @@ class GrpcProtocolHandler implements Http2SubProtocolSelector.SubProto private BufferData entityBytes; private Compressor compressor; private Decompressor decompressor; + private boolean isIdentityCompressor; GrpcProtocolHandler(HttpPrologue prologue, Http2Headers headers, @@ -115,41 +117,10 @@ class GrpcProtocolHandler implements Http2SubProtocolSelector.SubProto public void init() { try { ServerCall serverCall = createServerCall(); - Headers httpHeaders = headers.httpHeaders(); - // check for encoding and respond using same algorithm - if (httpHeaders.contains(GRPC_ENCODING)) { - Header grpcEncoding = httpHeaders.get(GRPC_ENCODING); - String encoding = grpcEncoding.asString().get(); - decompressor = DECOMPRESSOR_REGISTRY.lookupDecompressor(encoding); - compressor = COMPRESSOR_REGISTRY.lookupCompressor(encoding); - - // report encoding not supported - if (decompressor == null || compressor == null) { - Metadata metadata = new Metadata(); - Set encodings = DECOMPRESSOR_REGISTRY.getAdvertisedMessageEncodings(); - metadata.put(Metadata.Key.of(GRPC_ACCEPT_ENCODING.defaultCase(), Metadata.ASCII_STRING_MARSHALLER), - String.join(",", encodings)); - serverCall.close(Status.UNIMPLEMENTED, metadata); - currentStreamState = Http2StreamState.CLOSED; // stops processing - return; - } - } else if (httpHeaders.contains(GRPC_ACCEPT_ENCODING)) { - Header acceptEncoding = httpHeaders.get(GRPC_ACCEPT_ENCODING); - - // check for matching encoding - for (String encoding : acceptEncoding.allValues()) { - compressor = COMPRESSOR_REGISTRY.lookupCompressor(encoding); - if (compressor != null) { - decompressor = DECOMPRESSOR_REGISTRY.lookupDecompressor(encoding); - if (decompressor != null) { - break; // found match - } - compressor = null; - } - } - } + // setup compression + initCompression(serverCall, httpHeaders); // initiate server call ServerCallHandler callHandler = route.callHandler(); @@ -161,10 +132,6 @@ public void init() { } } - private void addNumMessages(int n) { - numMessages.getAndAdd(n); - } - @Override public Http2StreamState streamState() { return currentStreamState; @@ -224,6 +191,52 @@ public void data(Http2FrameHeader header, BufferData data) { } } + void initCompression(ServerCall serverCall, Headers httpHeaders) { + // check for encoding and respond using same algorithm + if (httpHeaders.contains(GRPC_ENCODING)) { + Header grpcEncoding = httpHeaders.get(GRPC_ENCODING); + String encoding = grpcEncoding.asString().get(); + decompressor = DECOMPRESSOR_REGISTRY.lookupDecompressor(encoding); + compressor = COMPRESSOR_REGISTRY.lookupCompressor(encoding); + + // report encoding not supported + if (decompressor == null || compressor == null) { + Metadata metadata = new Metadata(); + Set encodings = DECOMPRESSOR_REGISTRY.getAdvertisedMessageEncodings(); + metadata.put(Metadata.Key.of(GRPC_ACCEPT_ENCODING.defaultCase(), Metadata.ASCII_STRING_MARSHALLER), + String.join(",", encodings)); + serverCall.close(Status.UNIMPLEMENTED, metadata); + currentStreamState = Http2StreamState.CLOSED; // stops processing + return; + } + } else if (httpHeaders.contains(GRPC_ACCEPT_ENCODING)) { + Header acceptEncoding = httpHeaders.get(GRPC_ACCEPT_ENCODING); + + // check for matching encoding + for (String encoding : acceptEncoding.allValues()) { + compressor = COMPRESSOR_REGISTRY.lookupCompressor(encoding); + if (compressor != null) { + decompressor = DECOMPRESSOR_REGISTRY.lookupDecompressor(encoding); + if (decompressor != null) { + break; // found match + } + compressor = null; + } + } + } + + // special handling for identity compressor + isIdentityCompressor = (compressor instanceof Codec.Identity); + } + + boolean isIdentityCompressor() { + return isIdentityCompressor; + } + + private void addNumMessages(int n) { + numMessages.getAndAdd(n); + } + private void flushQueue() { if (listener != null) { while (!listenerQueue.isEmpty() && numMessages.getAndDecrement() > 0) { @@ -268,10 +281,10 @@ public void sendMessage(RES message) { try (InputStream inputStream = route.method().streamResponse(message)) { // prepare buffer for writing BufferData bufferData; - if (compressor == null) { + if (compressor == null || isIdentityCompressor) { byte[] bytes = inputStream.readAllBytes(); bufferData = BufferData.create(5 + bytes.length); - bufferData.write(0); + bufferData.write(0); // off for identity compressor bufferData.writeUnsignedInt32(bytes.length); bufferData.write(bytes); } else { diff --git a/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFound.java b/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFound.java index 316131404a2..0c0a64cec7b 100644 --- a/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFound.java +++ b/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFound.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package io.helidon.webserver.grpc; import io.helidon.common.buffers.BufferData; +import io.helidon.http.Status; import io.helidon.http.WritableHeaders; import io.helidon.http.http2.FlowControl; import io.helidon.http.http2.Http2Flag; @@ -45,6 +46,7 @@ class GrpcProtocolHandlerNotFound implements Http2SubProtocolSelector.SubProtoco @Override public void init() { WritableHeaders writable = WritableHeaders.create(); + writable.set(Http2Headers.STATUS_NAME, Status.NOT_FOUND_404.code()); writable.set(GrpcStatus.NOT_FOUND); Http2Headers http2Headers = Http2Headers.create(writable); streamWriter.writeHeaders(http2Headers, @@ -70,5 +72,4 @@ public void windowUpdate(Http2WindowUpdate update) { @Override public void data(Http2FrameHeader header, BufferData data) { } - } diff --git a/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcRouteHandler.java b/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcRouteHandler.java index 92c74008991..471a5f6240e 100644 --- a/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcRouteHandler.java +++ b/webserver/grpc/src/main/java/io/helidon/webserver/grpc/GrpcRouteHandler.java @@ -120,8 +120,14 @@ private static GrpcRouteHandler grpc(Descriptors.FileDe String methodName, ServerCallHandler callHandler) { Descriptors.ServiceDescriptor svc = proto.findServiceByName(serviceName); + if (svc == null) { + throw new IllegalArgumentException("Unable to find gRPC service '" + serviceName + "'"); + } Descriptors.MethodDescriptor mtd = svc.findMethodByName(methodName); - + if (mtd == null) { + throw new IllegalArgumentException("Unable to find gRPC method '" + methodName + + "' in service '" + serviceName + "'"); + } String path = svc.getFullName() + "/" + methodName; /* diff --git a/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFoundTest.java b/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFoundTest.java new file mode 100644 index 00000000000..bc1cb5fe4cc --- /dev/null +++ b/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerNotFoundTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.grpc; + +import io.helidon.http.Status; +import io.helidon.http.http2.FlowControl; +import io.helidon.http.http2.Http2Flag; +import io.helidon.http.http2.Http2FrameData; +import io.helidon.http.http2.Http2Headers; +import io.helidon.http.http2.Http2StreamState; +import io.helidon.http.http2.Http2StreamWriter; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.is; + +class GrpcProtocolHandlerNotFoundTest { + + private boolean validateHeaders; + + @Test + void testNotFoundHeaders() { + Http2StreamWriter writer = new Http2StreamWriter() { + @Override + public void write(Http2FrameData frame) { + throw new UnsupportedOperationException("Unsupported"); + } + + @Override + public void writeData(Http2FrameData frame, FlowControl.Outbound flowControl) { + throw new UnsupportedOperationException("Unsupported"); + + } + + @Override + public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, FlowControl.Outbound flowControl) { + validateHeaders = (headers.status() == Status.NOT_FOUND_404); + try { + headers.validateResponse(); + } catch (Exception e) { + validateHeaders = false; + } + return 0; + } + + @Override + public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, Http2FrameData dataFrame, FlowControl.Outbound flowControl) { + throw new UnsupportedOperationException("Unsupported"); + } + }; + GrpcProtocolHandlerNotFound handler = new GrpcProtocolHandlerNotFound(writer, 1, Http2StreamState.OPEN); + assertThat(validateHeaders, is(false)); + handler.init(); + assertThat(validateHeaders, is(true)); + } +} diff --git a/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerTest.java b/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerTest.java new file mode 100644 index 00000000000..5638cc38126 --- /dev/null +++ b/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcProtocolHandlerTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.grpc; + +import io.helidon.http.HeaderName; +import io.helidon.http.HeaderNames; +import io.helidon.http.WritableHeaders; +import io.helidon.http.http2.Http2Headers; +import io.helidon.http.http2.Http2Settings; +import io.helidon.http.http2.Http2StreamState; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.is; + +class GrpcProtocolHandlerTest { + + private static final HeaderName GRPC_ACCEPT_ENCODING = HeaderNames.create("grpc-accept-encoding"); + + @Test + @SuppressWarnings("unchecked") + void testIdentityCompressorFlag() { + WritableHeaders headers = WritableHeaders.create(); + headers.add(GRPC_ACCEPT_ENCODING, "identity"); + GrpcProtocolHandler handler = new GrpcProtocolHandler(null, + Http2Headers.create(headers), + null, + 1, + Http2Settings.builder().build(), + Http2Settings.builder().build(), + null, + Http2StreamState.OPEN, + null); + handler.initCompression(null, headers); + assertThat(handler.isIdentityCompressor(), is(true)); + } + + @Test + @SuppressWarnings("unchecked") + void testGzipCompressor() { + WritableHeaders headers = WritableHeaders.create(); + headers.add(GRPC_ACCEPT_ENCODING, "gzip"); + GrpcProtocolHandler handler = new GrpcProtocolHandler(null, + Http2Headers.create(headers), + null, + 1, + Http2Settings.builder().build(), + Http2Settings.builder().build(), + null, + Http2StreamState.OPEN, + null); + handler.initCompression(null, headers); + assertThat(handler.isIdentityCompressor(), is(false)); + } +} diff --git a/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcRouteHandlerTest.java b/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcRouteHandlerTest.java new file mode 100644 index 00000000000..948d7558963 --- /dev/null +++ b/webserver/grpc/src/test/java/io/helidon/webserver/grpc/GrpcRouteHandlerTest.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.grpc; + +import com.google.protobuf.Descriptors; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class GrpcRouteHandlerTest { + + @Test + void testBadServiceNames() throws Descriptors.DescriptorValidationException { + assertThrows(IllegalArgumentException.class, + () -> GrpcRouteHandler.unary(Strings.getDescriptor(), "foo", "Upper", null)); + assertThrows(IllegalArgumentException.class, + () -> GrpcRouteHandler.unary(Strings.getDescriptor(), "StringService", "foo", null)); + } +} diff --git a/webserver/grpc/src/test/proto/strings.proto b/webserver/grpc/src/test/proto/strings.proto new file mode 100644 index 00000000000..5242a102593 --- /dev/null +++ b/webserver/grpc/src/test/proto/strings.proto @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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. + */ + + +syntax = "proto3"; +option java_package = "io.helidon.webserver.grpc"; + +service StringService { + rpc Upper (StringMessage) returns (StringMessage) {} +} + +message StringMessage { + string text = 1; +} From a1de349499c54fc53cc9c6cf7ea12e8049badcfa Mon Sep 17 00:00:00 2001 From: Joe DiPol Date: Mon, 18 Nov 2024 12:57:26 -0800 Subject: [PATCH 10/36] 4.x netty 4.1.115 (#9499) * Upgrade dependency check plugin to 11.1.0 * Upgrade Netty to 4.1.115 --- dependencies/pom.xml | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 47996f01a48..52f79ed49e1 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -126,7 +126,7 @@ 8.2.0 7.0.0.Final 5.12.0 - 4.1.108.Final + 4.1.115.Final 3.50.0 21 - ${version.lib.ojdbc.family}.15.0.0 + ${version.lib.ojdbc.family}.6.0.24.10 ${version.lib.ojdbc} 3.4.0 diff --git a/integrations/cdi/datasource-ucp/pom.xml b/integrations/cdi/datasource-ucp/pom.xml index 6f49a42a604..c06979ef2d8 100644 --- a/integrations/cdi/datasource-ucp/pom.xml +++ b/integrations/cdi/datasource-ucp/pom.xml @@ -47,7 +47,7 @@ com.oracle.database.jdbc - ucp + ucp11 compile diff --git a/integrations/cdi/datasource-ucp/src/main/java/io/helidon/integrations/datasource/ucp/cdi/UCPBackedDataSourceExtension.java b/integrations/cdi/datasource-ucp/src/main/java/io/helidon/integrations/datasource/ucp/cdi/UCPBackedDataSourceExtension.java index c3b4e3fa19e..edd5ca0e588 100644 --- a/integrations/cdi/datasource-ucp/src/main/java/io/helidon/integrations/datasource/ucp/cdi/UCPBackedDataSourceExtension.java +++ b/integrations/cdi/datasource-ucp/src/main/java/io/helidon/integrations/datasource/ucp/cdi/UCPBackedDataSourceExtension.java @@ -184,12 +184,14 @@ private static PoolDataSource createDataSource(Instance instance, for (PropertyDescriptor pd : pds) { if (propertyName.equals(pd.getName())) { // We have matched a Java Beans property on the PoolDataSource implementation class. Set it - // if we can. Note that these properties are NOT those of the PoolDataSource's *underlying* - // "real" connection factory (usually a DataSource that provides the actual connections - // ultimately pooled by the Universal Connection Pool). Those are handled in a manner - // unfortunately restricted by the limited configuration mechanism belonging to the - // PoolDataSource implementation itself via the connectionFactoryProperties object. See - // below. + // if we can. PoolDataSourceImpl Java Beans properties happen to be either String-, int-, + // long-, or boolean-typed properties only. + // + // Note that these properties are NOT those of the PoolDataSource's *underlying* "real" + // connection factory (usually a DataSource that provides the actual connections ultimately + // pooled by the Universal Connection Pool). Those are handled in a manner unfortunately + // restricted by the limited configuration mechanism belonging to the PoolDataSource + // implementation itself via the connectionFactoryProperties object. See below. Method writeMethod = pd.getWriteMethod(); if (writeMethod != null) { Class type = pd.getPropertyType(); @@ -210,68 +212,29 @@ private static PoolDataSource createDataSource(Instance instance, } } if (!handled) { - // We have found a property that is not a Java Beans property of the PoolDataSource, but is - // supposed to be a property of the connection factory that it wraps. + // We have found a property that is not a writable Java Beans property of the PoolDataSource, + // but is supposed to be a writable Java Beans property of the connection factory that it wraps. // - // (Sadly, "serviceName" and "pdbRoles" are special properties that have significance to certain - // connection factories (such as Oracle database-oriented DataSources), and to the - // oracle.ucp.jdbc.UCPConnectionBuilder class, which underlies getConnection(user, password) - // calls, but which sadly cannot be set on a PoolDataSource except by means of some irrelevant - // XML configuration. We work around this design and special case it below, not here.) - // - // Sadly, the Universal Connection Pool lacks a mechanism to tunnel arbitrary Java + // Sadly, the Universal Connection Pool lacks a mechanism to send *arbitrarily-typed* Java // Beans-conformant property values destined for the underlying connection factory (which is - // usually a DataSource or ConnectionPoolDataSource implementation, but may be other things) - // through to that underlying connection factory with arbitrary type information set - // properly. Because the PoolDataSource is in charge of instantiating the connection factory - // (the underlying DataSource), you can't pass a fully configured DataSource into it, nor can - // you access an unconfigured instance of it that you can work with. The only configuration the - // Universal Connection Pool supports is via a Properties object, whose values are retrieved by - // the PoolDataSource implementation, as Strings. This limits the kinds of underlying - // connection factories (DataSource implementations, usually) that can be fully configured with - // the Universal Connection Pool to Strings and those Strings which can be converted by the - // PoolDataSourceImpl#toBasicType(String, String) method. + // usually a DataSource or ConnectionPoolDataSource implementation, but may be other things) to + // that underlying connection factory. Because the PoolDataSource is in charge of instantiating + // the connection factory, you can't pass a fully configured DataSource into it, nor can you + // access an unconfigured instance of it that you can work with. The only configuration the + // Universal Connection Pool supports sending to the connection factory is via a Properties + // object, whose values are retrieved by the PoolDataSource implementation, as Strings. This + // limits the kinds of underlying connection factories (DataSource implementations, usually) + // that can be fully configured with the Universal Connection Pool to Strings and those Strings + // which can be converted by the PoolDataSourceImpl#toBasicType(String, String) method. connectionFactoryProperties.setProperty(propertyName, properties.getProperty(propertyName)); } } } - Object serviceName = connectionFactoryProperties.remove("serviceName"); - Object pdbRoles = connectionFactoryProperties.remove("pdbRoles"); - // Used for OCI ATP Integration - // Removing this so that it is not set on connectionFactoryProperties, - // Else we get exception with getConnection using this DS, if its set. - connectionFactoryProperties.remove("tnsNetServiceName"); if (!connectionFactoryProperties.stringPropertyNames().isEmpty()) { // We found some String-typed properties that are destined for the underlying connection factory to - // hopefully fully configure it. Apply them here. + // hopefully fully configure it. Apply them here. returnValue.setConnectionFactoryProperties(connectionFactoryProperties); } - // Set the PoolDataSource's serviceName property so that it appears to the PoolDataSource to have been set - // via the undocumented XML configuration that the PoolDataSource can apparently be configured with in - // certain (irrelevant for Helidon) application server cases. - if (serviceName instanceof String) { - try { - Method m = returnValue.getClass().getDeclaredMethod("setServiceName", String.class); - if (m.trySetAccessible()) { - m.invoke(returnValue, serviceName); - } - } catch (NoSuchMethodException ignoreOnPurpose) { - - } - } - // Set the PoolDataSource's pdbRoles property so that it appears to the PoolDataSource to have been set via - // the undocumented XML configuration that the PoolDataSource can apparently be configured with in certain - // (irrelevant for Helidon) application server cases. - if (pdbRoles instanceof Properties) { - try { - Method m = returnValue.getClass().getDeclaredMethod("setPdbRoles", Properties.class); - if (m.trySetAccessible()) { - m.invoke(returnValue, pdbRoles); - } - } catch (NoSuchMethodException ignoreOnPurpose) { - - } - } } if (returnValue.getConnectionPoolName() == null) { String proposedConnectionPoolName = dataSourceName.value(); diff --git a/integrations/cdi/datasource-ucp/src/test/java/io/helidon/integrations/datasource/ucp/cdi/TestDataSourceAcquisition.java b/integrations/cdi/datasource-ucp/src/test/java/io/helidon/integrations/datasource/ucp/cdi/TestDataSourceAcquisition.java index 1ddd7056287..3863377816a 100644 --- a/integrations/cdi/datasource-ucp/src/test/java/io/helidon/integrations/datasource/ucp/cdi/TestDataSourceAcquisition.java +++ b/integrations/cdi/datasource-ucp/src/test/java/io/helidon/integrations/datasource/ucp/cdi/TestDataSourceAcquisition.java @@ -104,7 +104,6 @@ private void onStartup(@Observes @Initialized(ApplicationScoped.class) final Obj } private void configure(@Observes @Named("test") final PoolDataSource pds) throws SQLException { - assertThat(pds.getServiceName(), is("fred")); assertThat(pds.getDescription(), nullValue()); assertThat(pds.getClass().isSynthetic(), is(false)); pds.setDescription("A test datasource"); diff --git a/integrations/cdi/datasource-ucp/src/test/resources/application.yaml b/integrations/cdi/datasource-ucp/src/test/resources/application.yaml index a668a66a5d6..658158d7ac9 100644 --- a/integrations/cdi/datasource-ucp/src/test/resources/application.yaml +++ b/integrations/cdi/datasource-ucp/src/test/resources/application.yaml @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2022 Oracle and/or its affiliates. +# Copyright (c) 2019, 2024 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -22,7 +22,6 @@ oracle: connectionFactoryClassName: org.h2.jdbcx.JdbcDataSource password: "${EMPTY}" user: sa - serviceName: fred PoolXADataSource: testxa: URL: jdbc:h2:mem:test diff --git a/integrations/db/ojdbc/pom.xml b/integrations/db/ojdbc/pom.xml index 832b55ab9ff..3adf6549cdf 100644 --- a/integrations/db/ojdbc/pom.xml +++ b/integrations/db/ojdbc/pom.xml @@ -46,7 +46,7 @@ com.oracle.database.jdbc - ucp + ucp11 diff --git a/jersey/server/src/main/resources/META-INF/native-image/io.helidon.jersey/helidon-jersey-server/reflect-config.json b/jersey/server/src/main/resources/META-INF/native-image/io.helidon.jersey/helidon-jersey-server/reflect-config.json new file mode 100644 index 00000000000..356506f69b8 --- /dev/null +++ b/jersey/server/src/main/resources/META-INF/native-image/io.helidon.jersey/helidon-jersey-server/reflect-config.json @@ -0,0 +1,58 @@ +[ + { + "name": "java.lang.Boolean", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.Byte", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.Double", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.Float", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.Integer", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.Long", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.Short", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.String" ] } + ] + }, + { + "name": "java.lang.String", + "methods": [ + { "name": "", "parameterTypes": [ "java.lang.String" ] }, + { "name": "valueOf", "parameterTypes": [ "java.lang.Object" ] } + ] + } +] diff --git a/messaging/connectors/aq/pom.xml b/messaging/connectors/aq/pom.xml index 38ebfa0e6be..0daadcf450d 100644 --- a/messaging/connectors/aq/pom.xml +++ b/messaging/connectors/aq/pom.xml @@ -65,10 +65,6 @@ io.helidon.integrations.db ojdbc - - com.oracle.database.jdbc - ucp - jakarta.transaction jakarta.transaction-api diff --git a/tests/integration/packaging/mp-2/pom.xml b/tests/integration/packaging/mp-2/pom.xml index be51c3b9827..5ddc438bd8a 100644 --- a/tests/integration/packaging/mp-2/pom.xml +++ b/tests/integration/packaging/mp-2/pom.xml @@ -30,6 +30,9 @@ Helidon Tests Integration Packaging MP2 + false + false + false io.helidon.tests.integration.packaging.mp2.Mp2Main true @@ -145,6 +148,11 @@ build-native-image true + + ${angus.activation.native-image.trace} + ${helidon.native.reflection.trace} + ${helidon.native.reflection.trace-parsing} + diff --git a/tests/integration/packaging/mp-2/src/main/resources/META-INF/native-image/io.helidon.tests.integration.packaging/helidon-tests-integration-packaging-mp2/native-image.properties b/tests/integration/packaging/mp-2/src/main/resources/META-INF/native-image/io.helidon.tests.integration.packaging/helidon-tests-integration-packaging-mp2/native-image.properties index 97410fa5a9b..a023fadb1a3 100644 --- a/tests/integration/packaging/mp-2/src/main/resources/META-INF/native-image/io.helidon.tests.integration.packaging/helidon-tests-integration-packaging-mp2/native-image.properties +++ b/tests/integration/packaging/mp-2/src/main/resources/META-INF/native-image/io.helidon.tests.integration.packaging/helidon-tests-integration-packaging-mp2/native-image.properties @@ -14,4 +14,5 @@ # limitations under the License. # -Args=--initialize-at-build-time=io.helidon.tests.integration.packaging.mp2 +Args=--initialize-at-build-time=io.helidon.tests.integration.packaging.mp2 \ + --enable-url-protocols=http diff --git a/tests/integration/packaging/mp-3/pom.xml b/tests/integration/packaging/mp-3/pom.xml index b12400f384d..7fbf20e8c45 100644 --- a/tests/integration/packaging/mp-3/pom.xml +++ b/tests/integration/packaging/mp-3/pom.xml @@ -34,6 +34,9 @@ + false + false + false io.helidon.tests.integration.packaging.mp3.Mp3Main true @@ -83,6 +86,11 @@ native-maven-plugin true + + ${angus.activation.native-image.trace} + ${helidon.native.reflection.trace} + ${helidon.native.reflection.trace-parsing} + From c087857e348171b026274499374ac34e4698e863 Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Thu, 21 Nov 2024 11:23:09 -0500 Subject: [PATCH 16/36] New paragraph describing how to explicitly turn off TLS on an MP gRPC client. (#9519) Signed-off-by: Santiago Pericas-Geertsen --- docs/src/main/asciidoc/mp/grpc/client.adoc | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/src/main/asciidoc/mp/grpc/client.adoc b/docs/src/main/asciidoc/mp/grpc/client.adoc index bf2b196de1d..258c89a67b7 100644 --- a/docs/src/main/asciidoc/mp/grpc/client.adoc +++ b/docs/src/main/asciidoc/mp/grpc/client.adoc @@ -116,6 +116,25 @@ TLS in the gRPC MP client section is configured in the same way as in other Heli components such as the webserver. For more information see xref:{rootdir}/se/webserver.adoc#_configuring_tls[Configuring TLS]. +Given that TLS is enabled by default in gRPC, it must be explicitly turned off by +setting the `enabled` flag to `false` when connecting to an unsecure endpoint. +For example, to turn off TLS for the `string-channel` above use: + +[source,yaml] +---- +grpc: + client: + channels: + - name: "string-channel" + port: 8080 + tls: + enabled: "false" +---- + +NOTE: It is not sufficient to omit the TLS section in the configuration above. The +TLS section must be present and explicitly disabled. It is generally discouraged +to expose unsecure gRPC endpoints. + == Usage === Defining a Client Interface From 76081aa4a24accd577483a35e647e8f95228b4c9 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Thu, 21 Nov 2024 20:22:56 +0100 Subject: [PATCH 17/36] Fix to correctly create references for config documentation provider implementations. (#9523) --- .../io/helidon/config/metadata/docs/CmModule.java | 5 +++++ .../io/helidon/config/metadata/docs/ConfigDocs.java | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/CmModule.java b/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/CmModule.java index 602a44bad09..94d9520222f 100644 --- a/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/CmModule.java +++ b/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/CmModule.java @@ -66,4 +66,9 @@ public List getTypes() { public void setTypes(List types) { this.types = types; } + + @Override + public String toString() { + return module; + } } diff --git a/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/ConfigDocs.java b/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/ConfigDocs.java index 2f8d3c6a842..8278667b8ac 100644 --- a/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/ConfigDocs.java +++ b/config/metadata/docs/src/main/java/io/helidon/config/metadata/docs/ConfigDocs.java @@ -32,10 +32,12 @@ import java.util.Comparator; import java.util.Enumeration; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -205,10 +207,13 @@ public void process() { // map of annotated types to documentation Map configuredTypes = new HashMap<>(); + Set allTypes = new HashSet<>(); for (CmModule module : allModules) { for (CmType type : module.getTypes()) { configuredTypes.put(type.getAnnotatedType(), type); + allTypes.add(type.getAnnotatedType()); + allTypes.add(type.getType()); } } @@ -225,7 +230,7 @@ public void process() { List generatedFiles = new LinkedList<>(); for (CmModule module : allModules) { - moduleDocs(configuredTypes, typeTemplate, path, module, generatedFiles); + moduleDocs(allTypes, configuredTypes, typeTemplate, path, module, generatedFiles); } // sort alphabetically by page title @@ -320,14 +325,15 @@ private static String title(String typeName) { return title; } - private static void moduleDocs(Map configuredTypes, + private static void moduleDocs(Set allTypes, + Map configuredTypes, Template template, Path modulePath, CmModule module, List generatedFiles) { Function exists = type -> { // 1: check if part of this processing - if (configuredTypes.containsKey(type)) { + if (allTypes.contains(type)) { return true; } // 2: check if exists in target directory From f6def8ce3cbc5d1649b17a43f5dc9ec85c8e7e4d Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Fri, 22 Nov 2024 09:02:52 -0500 Subject: [PATCH 18/36] Removes repeated warning when method name cannot be determined from annotation. (#9529) Signed-off-by: Santiago Pericas-Geertsen --- .../helidon/microprofile/grpc/core/AbstractServiceBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microprofile/grpc/core/src/main/java/io/helidon/microprofile/grpc/core/AbstractServiceBuilder.java b/microprofile/grpc/core/src/main/java/io/helidon/microprofile/grpc/core/AbstractServiceBuilder.java index 48c2819aeaa..bc3dd825e81 100644 --- a/microprofile/grpc/core/src/main/java/io/helidon/microprofile/grpc/core/AbstractServiceBuilder.java +++ b/microprofile/grpc/core/src/main/java/io/helidon/microprofile/grpc/core/AbstractServiceBuilder.java @@ -224,7 +224,7 @@ private static String nameFromMember(Annotation annotation, String member) { Object value = m.invoke(annotation); return value instanceof String s ? s : null; } catch (NoSuchMethodException e) { - LOGGER.log(Level.WARNING, () -> String.format("Annotation %s has no name() method", annotation)); + // falls through } catch (IllegalAccessException | InvocationTargetException e) { LOGGER.log(Level.WARNING, () -> String.format("Error calling name() method on annotation %s", annotation), e); } From 7f9d8ac51791c0f93387ce05266d904e3f31d2c8 Mon Sep 17 00:00:00 2001 From: Joe DiPol Date: Fri, 22 Nov 2024 08:49:59 -0800 Subject: [PATCH 19/36] Upgrade kafka-clients to 3.8.1 (#9527) --- dependencies/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 3878b44a852..9ab5bda6a4a 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -95,7 +95,7 @@ 3.1.9 6.7.0.202309050840-r 5.9.3 - 3.6.2 + 3.8.1 2.21.1 1.4.14 2.6.2 From a5868d613327038745b9ef29f72e39a7a69cbce3 Mon Sep 17 00:00:00 2001 From: Abdelhak Zaaim Date: Mon, 25 Nov 2024 20:10:56 +0100 Subject: [PATCH 20/36] Refactor assertions to use `assertThat` with appropriate matchers (#9503) --- .../interceptor/InterceptorRuntimeTest.java | 15 ++++++++------- .../inject/tools/ActivatorCreatorDefaultTest.java | 5 ++--- .../microprofile/scheduling/InvalidStateTest.java | 7 ++++--- .../functional/requestscopecdi/SecretTest.java | 5 ++--- .../io/helidon/webclient/tests/HttpProxyTest.java | 5 ++--- .../Http2ErrorHandlingWithOutputStreamTest.java | 11 +++++------ .../helidon/webserver/http/ErrorHandlersTest.java | 3 +-- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/inject/tests/resources-inject/src/test/java/io/helidon/inject/tests/inject/interceptor/InterceptorRuntimeTest.java b/inject/tests/resources-inject/src/test/java/io/helidon/inject/tests/inject/interceptor/InterceptorRuntimeTest.java index 994397aadb0..740113e467c 100644 --- a/inject/tests/resources-inject/src/test/java/io/helidon/inject/tests/inject/interceptor/InterceptorRuntimeTest.java +++ b/inject/tests/resources-inject/src/test/java/io/helidon/inject/tests/inject/interceptor/InterceptorRuntimeTest.java @@ -59,7 +59,6 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class InterceptorRuntimeTest { @@ -91,9 +90,10 @@ void createNoArgBasedInterceptorSource() throws Exception { assertThat(file.exists(), is(true)); String java = Files.readString(file.toPath()); String expected = loadStringFromResource("expected/ximpl-interceptor._java_"); - assertEquals( - expected.replaceFirst("#DATE#", Integer.toString(Calendar.getInstance().get(Calendar.YEAR))), - java); + assertThat( + java, + is(expected.replaceFirst("#DATE#", Integer.toString(Calendar.getInstance().get(Calendar.YEAR)))) + ); } @Test @@ -104,9 +104,10 @@ void createInterfaceBasedInterceptorSource() throws Exception { assertThat(file.exists(), is(true)); String java = Files.readString(file.toPath()); String expected = loadStringFromResource("expected/yimpl-interceptor._java_"); - assertEquals( - expected.replaceFirst("#DATE#", Integer.toString(Calendar.getInstance().get(Calendar.YEAR))), - java); + assertThat( + java, + is(expected.replaceFirst("#DATE#", Integer.toString(Calendar.getInstance().get(Calendar.YEAR)))) + ); } @Test diff --git a/inject/tools/src/test/java/io/helidon/inject/tools/ActivatorCreatorDefaultTest.java b/inject/tools/src/test/java/io/helidon/inject/tools/ActivatorCreatorDefaultTest.java index 493766d4f19..c4e87021084 100644 --- a/inject/tools/src/test/java/io/helidon/inject/tools/ActivatorCreatorDefaultTest.java +++ b/inject/tools/src/test/java/io/helidon/inject/tools/ActivatorCreatorDefaultTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,6 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; /** @@ -67,7 +66,7 @@ void codegenHelloActivator() { .build(); ToolsException te = assertThrows(ToolsException.class, () -> activatorCreator.createModuleActivators(req)); - assertEquals("Failed in create", te.getMessage()); + assertThat(te.getMessage(), is("Failed in create")); ActivatorCreatorRequest req2 = ActivatorCreatorRequest.builder() .serviceTypeNames(Collections.singletonList(TypeName.create(HelloInjectionWorldImpl.class))) diff --git a/microprofile/scheduling/src/test/java/io/helidon/microprofile/scheduling/InvalidStateTest.java b/microprofile/scheduling/src/test/java/io/helidon/microprofile/scheduling/InvalidStateTest.java index 15d87dddb86..07b0cf1cd22 100644 --- a/microprofile/scheduling/src/test/java/io/helidon/microprofile/scheduling/InvalidStateTest.java +++ b/microprofile/scheduling/src/test/java/io/helidon/microprofile/scheduling/InvalidStateTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Oracle and/or its affiliates. + * Copyright (c) 2021, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,8 @@ import org.junit.jupiter.api.Test; import org.opentest4j.AssertionFailedError; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; import static org.junit.jupiter.api.Assertions.fail; public class InvalidStateTest { @@ -124,7 +125,7 @@ void assertDeploymentException(Class expected, Map Date: Mon, 25 Nov 2024 20:59:47 +0100 Subject: [PATCH 21/36] 4.x: Update to static content (#9502) * Static content update - WebServer feature to support static content from configuration - new Builder API based prototypes to configure it - deprecated old approach * Use defaults for sockets and welcome file. Added singular methods for classpath and path handlers. Renamed welcome file to welcome to align with current MP configuration option. * CDI extension update to use new API with same defaults, update to deprecated usages to avoid compiler warnings. * Documentation update for MP and SE. * Fix classpath root cleanup. Fix configuration used in MP extension. * Introduction of io.helidon.common.Size to handle size strings (similar to Java's Duration, but for size in bytes). Update to builder to support nice defaults for size. Update to PR to fix comments, and use Size for memory cache capacity. --- .../helidon/builder/codegen/TypeHandler.java | 9 +- .../io/helidon/codegen/CodegenValidator.java | 32 ++ .../helidon/codegen/TypeInfoFactoryBase.java | 10 +- .../src/main/java/io/helidon/common/Size.java | 352 ++++++++++++++++++ .../main/java/io/helidon/common/SizeImpl.java | 111 ++++++ .../test/java/io/helidon/common/SizeTest.java | 160 ++++++++ .../io/helidon/common/types/TypeNames.java | 5 + docs-internal/http-features.md | 1 + docs/src/main/asciidoc/mp/server.adoc | 33 +- docs/src/main/asciidoc/se/webserver.adoc | 48 ++- .../io/helidon/docs/se/WebServerSnippets.java | 16 +- .../server/ServerCdiExtension.java | 48 ++- .../META-INF/microprofile-config.properties | 6 +- .../static-content/etc/spotbugs/exclude.xml | 16 +- webserver/static-content/pom.xml | 47 +++ .../BaseHandlerConfigBlueprint.java | 137 +++++++ .../staticcontent/CachedHandlerInMemory.java | 4 +- .../staticcontent/CachedHandlerJar.java | 108 +++++- .../ClassPathContentHandler.java | 241 ++++++------ .../ClasspathHandlerConfigBlueprint.java | 54 +++ .../FileBasedContentHandler.java | 10 +- .../FileSystemContentHandler.java | 21 +- .../FileSystemHandlerConfigBlueprint.java | 38 ++ .../webserver/staticcontent/IoSupplier.java | 24 ++ .../webserver/staticcontent/MemoryCache.java | 180 +++++++++ .../MemoryCacheConfigBlueprint.java | 53 +++ .../SingleFileContentHandler.java | 10 +- .../StaticContentConfigBlueprint.java | 154 ++++++++ .../StaticContentConfigSupport.java | 93 +++++ .../staticcontent/StaticContentFeature.java | 245 ++++++++++++ .../StaticContentFeatureProvider.java | 49 +++ .../staticcontent/StaticContentHandler.java | 67 ++-- .../staticcontent/StaticContentService.java | 95 +++-- .../staticcontent/TemporaryStorage.java | 74 ++++ .../TemporaryStorageConfigBlueprint.java | 86 +++++ .../staticcontent/TemporaryStorageImpl.java | 118 ++++++ .../src/main/java/module-info.java | 6 +- .../staticcontent/CachedHandlerTest.java | 3 +- .../StaticContentConfigTest.java | 171 +++++++++ .../StaticContentHandlerTest.java | 16 +- .../staticcontent/StaticContentTest.java | 82 +++- .../test/resources/config-unit-test-1.yaml | 42 +++ .../test/resources/logging-test.properties | 19 + 43 files changed, 2827 insertions(+), 267 deletions(-) create mode 100644 common/common/src/main/java/io/helidon/common/Size.java create mode 100644 common/common/src/main/java/io/helidon/common/SizeImpl.java create mode 100644 common/common/src/test/java/io/helidon/common/SizeTest.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java create mode 100644 webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java create mode 100644 webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java create mode 100644 webserver/static-content/src/test/resources/config-unit-test-1.yaml create mode 100644 webserver/static-content/src/test/resources/logging-test.properties diff --git a/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java b/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java index 775878131ab..c67e0d0ef6f 100644 --- a/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java +++ b/builder/codegen/src/main/java/io/helidon/builder/codegen/TypeHandler.java @@ -34,6 +34,7 @@ import io.helidon.codegen.classmodel.InnerClass; import io.helidon.codegen.classmodel.Javadoc; import io.helidon.codegen.classmodel.Method; +import io.helidon.common.Size; import io.helidon.common.types.AccessModifier; import io.helidon.common.types.TypeName; import io.helidon.common.types.TypeNames; @@ -220,8 +221,14 @@ Consumer> toDefaultValue(String defaultValue) { .addContent(defaultValue) .addContent("\""); } + if (TypeNames.SIZE.equals(typeName)) { + CodegenValidator.validateSize(enclosingType, annotatedMethod, OPTION_DEFAULT, "value", defaultValue); + return content -> content.addContent(Size.class) + .addContent(".parse(\"") + .addContent(defaultValue) + .addContent("\")"); + } if (TypeNames.DURATION.equals(typeName)) { - CodegenValidator.validateDuration(enclosingType, annotatedMethod, OPTION_DEFAULT, "value", defaultValue); return content -> content.addContent(Duration.class) .addContent(".parse(\"") diff --git a/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java b/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java index 97b61cc9da1..e923e95a61d 100644 --- a/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java +++ b/codegen/codegen/src/main/java/io/helidon/codegen/CodegenValidator.java @@ -19,6 +19,7 @@ import java.net.URI; import java.time.Duration; +import io.helidon.common.Size; import io.helidon.common.types.TypeName; import io.helidon.common.types.TypedElementInfo; @@ -86,4 +87,35 @@ public static String validateDuration(TypeName enclosingType, element.originatingElementValue()); } } + + /** + * Validate a {@link io.helidon.common.Size} annotation on a method, field, or constructor. + * + * @param enclosingType type that owns the element + * @param element annotated element + * @param annotationType type of annotation + * @param property property of annotation + * @param value actual value read from the annotation property + * @return the value + * @throws io.helidon.codegen.CodegenException with correct source element describing the problem + */ + public static String validateSize(TypeName enclosingType, + TypedElementInfo element, + TypeName annotationType, + String property, + String value) { + try { + Size.parse(value); + return value; + } catch (Exception e) { + throw new CodegenException("Size expression of annotation " + annotationType.fqName() + "." + + property + "(): " + + "\"" + value + "\" cannot be parsed. Size expects an" + + " expression such as '120 KB' (120 * 1024 * 1024), " + + "'120 kB' (120 * 1000 * 1000), or '120 KiB' (same as KB)" + + " Please check javadoc of " + Size.class.getName() + " class.", + e, + element.originatingElementValue()); + } + } } diff --git a/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java b/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java index e0a233f1d7d..79c0a7867eb 100644 --- a/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java +++ b/codegen/codegen/src/main/java/io/helidon/codegen/TypeInfoFactoryBase.java @@ -44,6 +44,7 @@ public abstract class TypeInfoFactoryBase { TypeName.create(Target.class), TypeName.create(Retention.class), TypeName.create(Repeatable.class)); + private static final Set ACCESS_MODIFIERS = Set.of("PUBLIC", "PRIVATE", "PROTECTED"); /** * There are no side effects of this constructor. @@ -144,10 +145,15 @@ protected static Set modifiers(CodegenContext Set result = new HashSet<>(); for (String stringModifier : stringModifiers) { + String upperCased = stringModifier.toUpperCase(Locale.ROOT); + if (ACCESS_MODIFIERS.contains(upperCased)) { + // ignore access modifiers, as they are handled elsewhere + continue; + } try { - result.add(io.helidon.common.types.Modifier.valueOf(stringModifier.toUpperCase(Locale.ROOT))); + result.add(io.helidon.common.types.Modifier.valueOf(upperCased)); } catch (Exception ignored) { - // we do not care about modifiers we do not understand - either access modifier, or something new + // we do not care about modifiers we do not understand ctx.logger().log(System.Logger.Level.TRACE, "Modifier " + stringModifier + " not understood by type info factory."); } diff --git a/common/common/src/main/java/io/helidon/common/Size.java b/common/common/src/main/java/io/helidon/common/Size.java new file mode 100644 index 00000000000..b888d8b4e09 --- /dev/null +++ b/common/common/src/main/java/io/helidon/common/Size.java @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.common; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * A definition of size in bytes. + */ +public interface Size { + /** + * Empty size - zero bytes. + */ + Size ZERO = Size.create(0); + + /** + * Create a new size with explicit number of bytes. + * + * @param size number of bytes + * @return a new size instance + */ + static Size create(long size) { + return new SizeImpl(BigInteger.valueOf(size)); + } + + /** + * Create a new size from amount and unit. + * + * @param amount amount in the provided unit + * @param unit unit + * @return size representing the amount + */ + static Size create(long amount, Unit unit) { + Objects.requireNonNull(unit, "Unit must not be null"); + + return new SizeImpl(BigInteger.valueOf(amount).multiply(unit.bytesInteger())); + } + + /** + * Create a new size from amount and unit. + * + * @param amount amount that can be decimal + * @param unit unit + * @return size representing the amount + * @throws IllegalArgumentException in case the amount cannot be converted to whole bytes (i.e. it has + * a fraction of byte) + */ + static Size create(BigDecimal amount, Unit unit) { + Objects.requireNonNull(amount, "Amount must not be null"); + Objects.requireNonNull(unit, "Unit must not be null"); + + BigDecimal result = amount.multiply(new BigDecimal(unit.bytesInteger())); + return new SizeImpl(result.toBigIntegerExact()); + } + + /** + * Crete a new size from the size string. + * The string may contain a unit. If a unit is not present, the size string is considered to be number of bytes. + *

+ * We understand units from kilo (meaning 1000 or 1024, see table below), to exa bytes. + * Each higher unit is either 1000 times or 1024 times bigger than the one below, depending on the approach used. + *

+ * Measuring approaches and their string representations: + *

    + *
  • KB, KiB - kibi, kilobinary, stands for 1024 bytes
  • + *
  • kB, kb - kilobyte, stands for 1000 bytes
  • + *
  • MB, MiB - mebi, megabinary, stands for 1024*1024 bytes
  • + *
  • mB, mb - megabyte, stands for 1000*1000 bytes
  • + *
  • From here the same concept is applied with Giga, Tera, Peta, and Exa bytes
  • + *
+ * + * @param sizeString the string definition, such as {@code 76 MB}, or {@code 976 mB}, can also be a decimal number + * - we use {@link java.math.BigDecimal} to parse the numeric section of the size; if there is a unit + * defined, it must be separated by a single space from the numeric section + * @return parsed size that can provide exact number of bytes + */ + static Size parse(String sizeString) { + Objects.requireNonNull(sizeString, "Size string is null"); + + String parsed = sizeString.trim(); + if (parsed.isEmpty()) { + throw new IllegalArgumentException("Size string is empty."); + } + int lastSpace = parsed.lastIndexOf(' '); + if (lastSpace == -1) { + // no unit + return create(new BigDecimal(parsed), Unit.BYTE); + } + String size = parsed.substring(0, lastSpace); + Unit unit = Unit.parse(parsed.substring(lastSpace + 1)); + BigDecimal amount = new BigDecimal(size); + return create(amount, unit); + } + + /** + * Amount of units in this size. + * + * @param unit to get the size of + * @return size in the provided unit as a big decimal + * @throws ArithmeticException in case this size cannot be converted to the specified unit without losing + * information + * @see #toBytes() + */ + BigDecimal to(Unit unit); + + /** + * Number of bytes this size represents. + * + * @return number of bytes + * @throws ArithmeticException in case the amount is higher than {@link Long#MAX_VALUE}, or would contain + * fractions of byte + */ + long toBytes(); + + /** + * Get the highest possible unit of the size with integer amount. + * + * @param unitKind kind of unit to print (kB, kb, KB, or KiB) + * @return amount integer with a unit, such as {@code 270 kB}, if the amount is {@code 2000 kB}, this method would return + * {@code 2 mB} instead for {@link io.helidon.common.Size.UnitKind#DECIMAL_UPPER_CASE} + */ + String toString(UnitKind unitKind); + + /** + * Get the amount in the provided unit as a decimal number if needed. If the amount cannot be correctly + * expressed in the provided unit, an exception is thrown. + * + * @param unit unit to use, such as {@link io.helidon.common.Size.Unit#MIB} + * @param unitKind kind of unit for the output, must match the provided unit, + * such as {@link io.helidon.common.Size.UnitKind#BINARY_BI} to print {@code MiB} + * @return amount decimal with a unit, such as {@code 270.426 MiB} + * @throws java.lang.IllegalArgumentException in case the unitKind does not match the unit + */ + String toString(Unit unit, UnitKind unitKind); + + /** + * Kind of units, used for printing out the correct unit. + */ + enum UnitKind { + /** + * The first letter (if two lettered) is lower case, the second is upper case, such ase + * {@code B, kB, mB}. These represent powers of 1000. + */ + DECIMAL_UPPER_CASE(false), + /** + * All letters are lower case, such as + * {@code b, kb, mb}. These represent powers of 1000. + */ + DECIMAL_LOWER_CASE(false), + /** + * The multiplier always contains {@code i}, the second is upper case B, such ase + * {@code B, KiB, MiB}. These represent powers of 1024. + */ + BINARY_BI(true), + /** + * All letters are upper case, such as + * {@code B, KB, MB}. These represent powers of 1024. + */ + BINARY_UPPER_CASE(true); + private final boolean isBinary; + + UnitKind(boolean isBinary) { + this.isBinary = isBinary; + } + + boolean isBinary() { + return isBinary; + } + } + + /** + * Units that can be used. + */ + enum Unit { + /** + * Bytes. + */ + BYTE(1024, 0, "b", "B"), + /** + * Kilobytes (represented as {@code kB}), where {@code kilo} is used in its original meaning as a thousand, + * i.e. 1 kB is 1000 bytes. + */ + KB(1000, 1, "kB", "kb"), + /** + * Kibi-bytes (represented as either {@code KB} or {@code KiB}), where we use binary approach, i.e. + * 1 KB or KiB is 1024 bytes. + */ + KIB(1024, 1, "KB", "KiB"), + /** + * Megabytes (represented as {@code mB}), where {@code mega} is used in its original meaning as a million, + * i.e. 1 mB is 1000^2 bytes (1000 to the power of 2), or 1000 kB. + */ + MB(1000, 2, "mB", "mb"), + /** + * Mebi-bytes (represented as either {@code MB} or {@code MiB}), where we use binary approach, i.e. + * 1 MB or MiB is 1024^2 bytes (1024 to the power 2), or 1024 KiB. + */ + MIB(1024, 2, "MB", "MiB"), + /** + * Gigabytes (represented as {@code gB}): + * i.e. 1 gB is 1000^3 bytes (1000 to the power of 3), or 1000 mB. + */ + GB(1000, 3, "gB", "gb"), + /** + * Gibi-bytes (represented as either {@code GB} or {@code GiB}), where we use binary approach, i.e. + * 1 GB or GiB is 1024^3 bytes (1024 to the power 3), or 1024 MiB. + */ + GIB(1024, 3, "GB", "GiB"), + /** + * Terabytes (represented as {@code tB}): + * i.e. 1 gB is 1000^4 bytes (1000 to the power of 4), or 1000 gB. + */ + TB(1000, 4, "tB", "tb"), + /** + * Tebi-bytes (represented as either {@code TB} or {@code TiB}), where we use binary approach, i.e. + * 1 TB or TiB is 1024^4 bytes (1024 to the power 4), or 1024 GiB. + */ + TIB(1024, 4, "TB", "TiB"), + /** + * Petabytes (represented as {@code pB}): + * i.e. 1 pB is 1000^5 bytes (1000 to the power of 5), or 1000 tB. + */ + PB(1000, 5, "pB", "pb"), + /** + * Pebi-bytes (represented as either {@code PB} or {@code PiB}), where we use binary approach, i.e. + * 1 PB or PiB is 1024^5 bytes (1024 to the power 5), or 1024 TiB. + */ + PIB(1024, 5, "PB", "PiB"), + /** + * Exabytes (represented as {@code eB}): + * i.e. 1 eB is 1000^6 bytes (1000 to the power of 6), or 1000 pB. + */ + EB(1000, 6, "eB", "eb"), + /** + * Exbi-bytes (represented as either {@code EB} or {@code EiB}), where we use binary approach, i.e. + * 1 EB or EiB is 1024^6 bytes (1024 to the power 6), or 1024 PiB. + */ + EIB(1024, 6, "EB", "EiB"); + + private static final Map UNIT_MAP; + + static { + Map units = new HashMap<>(); + for (Unit unit : Unit.values()) { + for (String validUnitString : unit.units) { + units.put(validUnitString, unit); + } + } + UNIT_MAP = Map.copyOf(units); + } + + private final long bytes; + private final int power; + private final BigInteger bytesInteger; + private final Set units; + private final boolean binary; + private final String firstUnit; + private final String secondUnit; + + /** + * Unit. + * + * @param base base of the calculation (1000 or 1024) + * @param power to the power of + * @param firstUnit first unit (either upper case decimal [mB], or all upper case [MB]) + * @param secondUnit second unit (either lower case decimal [mb], or binary unit name [MiB]) + */ + Unit(int base, int power, String firstUnit, String secondUnit) { + this.firstUnit = firstUnit; + this.secondUnit = secondUnit; + this.units = Set.of(firstUnit, secondUnit); + this.bytes = (long) Math.pow(base, power); + this.bytesInteger = BigInteger.valueOf(bytes); + this.power = power; + this.binary = base == 1024; + } + + /** + * Parse the size string to appropriate unit. + * + * @param unitString defines the unit, such as {@code KB}, {@code MiB}, {@code pB} etc.; empty string parses to + * {@link #BYTE} + * @return a parsed unit + * @throws IllegalArgumentException if the unit cannot be parsed + */ + public static Unit parse(String unitString) { + if (unitString.isEmpty()) { + return BYTE; + } + Unit unit = UNIT_MAP.get(unitString); + if (unit == null) { + throw new IllegalArgumentException("Unknown unit: " + unitString); + } + return unit; + } + + /** + * Number of bytes this unit represents. + * + * @return number of bytes of this unit + */ + public long bytes() { + return bytes; + } + + /** + * Number of bytes in this unit (exact integer). + * + * @return number of bytes this unit contains + */ + public BigInteger bytesInteger() { + return bytesInteger; + } + + String unitString(UnitKind unitKind) { + if (power == 0) { + if (unitKind == UnitKind.DECIMAL_LOWER_CASE) { + return "b"; + } + return "B"; + } + + return switch (unitKind) { + case DECIMAL_UPPER_CASE, BINARY_UPPER_CASE -> firstUnit; + case DECIMAL_LOWER_CASE, BINARY_BI -> secondUnit; + }; + } + + boolean isBinary() { + return binary; + } + } +} diff --git a/common/common/src/main/java/io/helidon/common/SizeImpl.java b/common/common/src/main/java/io/helidon/common/SizeImpl.java new file mode 100644 index 00000000000..e89b2bcbc0e --- /dev/null +++ b/common/common/src/main/java/io/helidon/common/SizeImpl.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.common; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.util.Objects; + +class SizeImpl implements Size { + private final BigInteger bytes; + + SizeImpl(BigInteger bytes) { + this.bytes = bytes; + } + + @Override + public BigDecimal to(Unit unit) { + Objects.requireNonNull(unit, "Unit must not be null"); + + BigDecimal bigDecimal = new BigDecimal(unit.bytesInteger()); + BigDecimal result = new BigDecimal(bytes).divide(bigDecimal, + bigDecimal.precision() + 1, + RoundingMode.UNNECESSARY); + return result.stripTrailingZeros(); + } + + @Override + public long toBytes() { + try { + return bytes.longValueExact(); + } catch (ArithmeticException e) { + // we cannot use a cause with constructor, creating a more descriptive message + throw new ArithmeticException("Size " + this + " cannot be converted to number of bytes, out of long range."); + } + } + + @Override + public String toString(UnitKind unitKind) { + Objects.requireNonNull(unitKind, "Unit kind must not be null"); + + if (bytes.equals(BigInteger.ZERO)) { + return "0 " + Unit.BYTE.unitString(unitKind); + } + + // try each amount from the highest that returns zero decimal places + Unit[] values = Unit.values(); + for (int i = values.length - 1; i >= 0; i--) { + Unit value = values[i]; + if (value.isBinary() != unitKind.isBinary()) { + continue; + } + BigDecimal bigDecimal = to(value); + try { + // try to convert without any decimal spaces + BigInteger bi = bigDecimal.toBigIntegerExact(); + return bi + " " + value.unitString(unitKind); + } catch (Exception ignored) { + // ignored, we cannot convert to this unit, because it cannot be correctly divided + } + } + + return bytes + " " + Unit.BYTE.unitString(unitKind); + } + + @Override + public String toString(Unit unit, UnitKind unitKind) { + Objects.requireNonNull(unit, "Unit must not be null"); + Objects.requireNonNull(unitKind, "Unit kind must not be null"); + + if (unit.isBinary() != unitKind.isBinary()) { + throw new IllegalArgumentException("Unit " + unit + " does not match kind " + unitKind); + } + String unitString = unit.unitString(unitKind); + BigDecimal amount = to(unit); + + return amount.toPlainString() + " " + unitString; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Size size)) { + return false; + } + return Objects.equals(size.to(Unit.BYTE), this.to(Unit.BYTE)); + } + + @Override + public int hashCode() { + return Objects.hash(to(Unit.BYTE)); + } + + @Override + public String toString() { + return toString(UnitKind.DECIMAL_UPPER_CASE); + } +} diff --git a/common/common/src/test/java/io/helidon/common/SizeTest.java b/common/common/src/test/java/io/helidon/common/SizeTest.java new file mode 100644 index 00000000000..73be10f677b --- /dev/null +++ b/common/common/src/test/java/io/helidon/common/SizeTest.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.common; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SizeTest { + @Test + void testBytesEmpty() { + Size first = Size.create(0); + Size second = Size.ZERO; + + assertThat(first, is(second)); + assertThat(first.hashCode(), is(second.hashCode())); + + assertThat(first.toBytes(), is(0L)); + assertThat(second.toBytes(), is(0L)); + + for (Size.Unit unit : Size.Unit.values()) { + assertThat(first.to(unit), is(BigDecimal.ZERO)); + assertThat(second.to(unit), is(BigDecimal.ZERO)); + } + + assertThat(first.toString(), is("0 B")); + assertThat(first.toString(Size.UnitKind.DECIMAL_LOWER_CASE), is("0 b")); + assertThat(first.toString(Size.Unit.EIB, Size.UnitKind.BINARY_BI), is("0 EiB")); + } + + @Test + void testTooBig() { + Size size = Size.create(Long.MAX_VALUE, Size.Unit.KB); + assertThrows(ArithmeticException.class, size::toBytes); + } + + @Test + void testToStringWrongUnitKind() { + Size size = Size.create(1024, Size.Unit.KIB); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EB, Size.UnitKind.BINARY_BI)); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EB, Size.UnitKind.BINARY_UPPER_CASE)); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EIB, Size.UnitKind.DECIMAL_UPPER_CASE)); + assertThrows(IllegalArgumentException.class, () -> size.toString(Size.Unit.EIB, Size.UnitKind.DECIMAL_LOWER_CASE)); + } + + @Test + void testConversionsBinary() { + Size size = Size.create(1, Size.Unit.EIB); + + assertThat(size.toBytes(), is(1152921504606846976L)); + + assertThat(size.to(Size.Unit.BYTE), is(new BigDecimal(Size.Unit.EIB.bytesInteger()))); + assertThat(size.to(Size.Unit.KIB), is(BigDecimal.valueOf(1125899906842624L))); + assertThat(size.to(Size.Unit.MIB), is(BigDecimal.valueOf(1099511627776L))); + assertThat(size.to(Size.Unit.GIB), is(BigDecimal.valueOf(1073741824L))); + assertThat(size.to(Size.Unit.TIB), is(BigDecimal.valueOf(1048576L))); + assertThat(size.to(Size.Unit.PIB), is(BigDecimal.valueOf(1024L))); + assertThat(size.to(Size.Unit.EIB), is(BigDecimal.valueOf(1L))); + + assertThat(size.toString(Size.UnitKind.BINARY_UPPER_CASE), is("1 EB")); + assertThat(size.toString(Size.UnitKind.BINARY_BI), is("1 EiB")); + assertThat(size.toString(Size.Unit.GIB, Size.UnitKind.BINARY_BI), is("1073741824 GiB")); + assertThat(size.toString(Size.Unit.GIB, Size.UnitKind.BINARY_UPPER_CASE), is("1073741824 GB")); + } + + @Test + void testConversionsDecimal() { + Size size = Size.create(1048576, Size.Unit.BYTE); + + assertThat(size.toBytes(), is(1048576L)); + + assertThat(size.to(Size.Unit.BYTE), is(BigDecimal.valueOf(1048576L))); + assertThat(size.to(Size.Unit.KB), is(new BigDecimal("1048.576"))); + assertThat(size.to(Size.Unit.MB), is(new BigDecimal("1.048576"))); + assertThat(size.to(Size.Unit.GB), closeTo(new BigDecimal("0.001048576"), BigDecimal.ZERO)); + assertThat(size.to(Size.Unit.TB), closeTo(new BigDecimal("0.000001048576"), BigDecimal.ZERO)); + assertThat(size.to(Size.Unit.PB), closeTo(new BigDecimal("0.000000001048576"), BigDecimal.ZERO)); + assertThat(size.to(Size.Unit.EB), closeTo(new BigDecimal("0.000000000001048576"), BigDecimal.ZERO)); + + assertThat(size.toString(), is("1048576 B")); + assertThat(size.toString(Size.UnitKind.DECIMAL_LOWER_CASE), is("1048576 b")); + assertThat(size.toString(Size.Unit.EB, Size.UnitKind.DECIMAL_UPPER_CASE), is("0.000000000001048576 eB")); + } + + @Test + void testParsingDecimal() { + testParsing("10", 10); + testParsing("2 kb", 2_000); + testParsing("2 kB", 2_000); + testParsing("3 mB", 3_000_000); + testParsing("3 mb", 3_000_000); + testParsing("4 gB", 4_000_000_000L); + testParsing("4 gb", 4_000_000_000L); + testParsing("7 tB", 7_000_000_000_000L); + testParsing("7 tb", 7_000_000_000_000L); + testParsing("5 pB", 5_000_000_000_000_000L); + testParsing("5 pb", 5_000_000_000_000_000L); + testParsing("6 eB", 6_000_000_000_000_000_000L); + testParsing("6 eb", 6_000_000_000_000_000_000L); + + testParsing("2.42 kb", 2_420); + testParsing("2.42 kB", 2_420); + testParsing("3.42 mB", 3_420_000); + testParsing("3.42 mb", 3_420_000); + testParsing("4.42 gB", 4_420_000_000L); + testParsing("4.42 gb", 4_420_000_000L); + testParsing("7.42 tB", 7_420_000_000_000L); + testParsing("7.42 tb", 7_420_000_000_000L); + testParsing("5.42 pB", 5_420_000_000_000_000L); + testParsing("5.42 pb", 5_420_000_000_000_000L); + testParsing("6.42 eB", 6_420_000_000_000_000_000L); + testParsing("6.42 eb", 6_420_000_000_000_000_000L); + } + + @Test + void testParsingBinary() { + testParsing("10", 10); + testParsing("2 KB", 2_048); + testParsing("2 KiB", 2_048); + testParsing("3 MB", 3_145_728); + testParsing("3 MiB", 3_145_728); + testParsing("4 GB", 4_294_967_296L); + testParsing("4 GiB", 4_294_967_296L); + testParsing("7 TB", 7_696_581_394_432L); + testParsing("7 TiB", 7_696_581_394_432L); + testParsing("5 PB", 5_629_499_534_213_120L); + testParsing("5 PiB", 5_629_499_534_213_120L); + testParsing("6 EB", 6_917_529_027_641_081_856L); + testParsing("6 EiB", 6_917_529_027_641_081_856L); + + testParsing("3.5 KB", 3_584); + testParsing("3.5 KiB", 3_584); + // not testing others, as this combines decimal numbers with binary numbers + } + + private void testParsing(String value, long numberOfBytes) { + Size size = Size.parse(value); + assertThat(size.toBytes(), is(numberOfBytes)); + } +} + diff --git a/common/types/src/main/java/io/helidon/common/types/TypeNames.java b/common/types/src/main/java/io/helidon/common/types/TypeNames.java index 3f392587ee7..b3d513c516e 100644 --- a/common/types/src/main/java/io/helidon/common/types/TypeNames.java +++ b/common/types/src/main/java/io/helidon/common/types/TypeNames.java @@ -30,6 +30,7 @@ import io.helidon.common.Generated; import io.helidon.common.GenericType; +import io.helidon.common.Size; /** * Commonly used type names. @@ -206,6 +207,10 @@ public final class TypeNames { * Helidon {@link io.helidon.common.GenericType}. */ public static final TypeName GENERIC_TYPE = TypeName.create(GenericType.class); + /** + * Type name for {@link io.helidon.common.Size}. + */ + public static final TypeName SIZE = TypeName.create(Size.class); private TypeNames() { } diff --git a/docs-internal/http-features.md b/docs-internal/http-features.md index 61c22338833..5eba617b5e6 100644 --- a/docs-internal/http-features.md +++ b/docs-internal/http-features.md @@ -11,6 +11,7 @@ Features | CORS | 850 | | Security | 800 | | Routing (all handlers) | 100 | +| Static Content | 95 | | OpenAPI | 90 | | Observe | 80 | diff --git a/docs/src/main/asciidoc/mp/server.adoc b/docs/src/main/asciidoc/mp/server.adoc index c6a7234ffe7..0cd3a31d304 100644 --- a/docs/src/main/asciidoc/mp/server.adoc +++ b/docs/src/main/asciidoc/mp/server.adoc @@ -333,24 +333,41 @@ io.helidon.examples.AdminService: .META-INF/microprofile-config.properties - File system static content ---- # Location of content on file system -server.static.path.location=/var/www/html -# default is index.html -server.static.path.welcome=resource.html -# static content path - default is "/" -# server.static.path.context=/static-file +server.features.static-content.path.0.location=/var/www/html +# default is index.html (only in Helidon MicroProfile) +server.features.static-content.path.0.welcome=resource.html +# static content context on webserver - default is "/" +# server.features.static-content.path.0.context=/static-file ---- [source,properties] .META-INF/microprofile-config.properties - Classpath static content ---- # src/main/resources/WEB in your source tree -server.static.classpath.location=/WEB +server.features.static-content.classpath.0.location=/WEB # default is index.html -server.static.classpath.welcome=resource.html +server.features.static-content.classpath.0.welcome=resource.html # static content path - default is "/" -# server.static.classpath.context=/static-cp +# server.features.static-content.classpath.0.context=/static-cp +---- + +It is usually easier to configure list-based options using `application.yaml` instead, such as: +[source,yaml] +.application.yaml - Static content +---- +server: + features: + static-content: + welcome: "welcome.html" + classpath: + - context: "/static" + location: "/WEB" + path: + - context: "/static-file" + location: "./static-content" ---- +See xref:{rootdir}/config/io_helidon_webserver_staticcontent_StaticContentFeature.adoc[Static Content Feature Configuration Reference] for details. The only difference is that we set welcome file to `index.html` by default. === Example configuration of routing diff --git a/docs/src/main/asciidoc/se/webserver.adoc b/docs/src/main/asciidoc/se/webserver.adoc index c1e8c433395..02e6f091ff6 100644 --- a/docs/src/main/asciidoc/se/webserver.adoc +++ b/docs/src/main/asciidoc/se/webserver.adoc @@ -626,12 +626,12 @@ To enable HTTP/2 support add the following dependency to your project's `pom.xml == Static Content Support -+Use the `io.helidon.webserver.staticcontent.StaticContentService` class to serve files and classpath resources. -`StaticContentService` can be created for any readable directory or classpath -context root and registered on a path in `HttpRouting`. +Static content is served through a `StaticContentFeature`. As with other server features, it can be configured through config, +or registered with server config builder. -You can combine dynamic handlers with `StaticContentService` objects: if no file matches the request path, then the request is forwarded to -the next handler. +Static content supports serving of files from classpath, or from any readable directory on the file system. +Each content handler must include a location, and can provide a context that will be registered with the WebServer +(defaults to `/`). === Maven Coordinates @@ -650,19 +650,39 @@ To enable Static Content Support add the following dependency to your project's To register static content based on a file system (`/pictures`), and classpath (`/`): [source,java] +.server feature using `WebServerConfig.Builder` ---- include::{sourcedir}/se/WebServerSnippets.java[tag=snippet_22, indent=0] ---- -<1> Create a new `StaticContentService` object to serve data from the file system, -and associate it with the `"/pictures"` context path. -<2> Create a `StaticContentService` object to serve resources from the contextual -`ClassLoader`. The specific classloader can be also -defined. A builder lets you provide more configuration values. -<3> `index.html` is the file that is returned if a directory is requested. +<1> Create a new `StaticContentFeature` to register with the web server (will be served on all sockets by default) +<2> Add path location served from `/some/WEB/pics` absolute path +<3> Associate the path location with server context `/pictures` +<4> Add classpath location to serve resources from the contextual +`ClassLoader` from location `/static-content` +<5> `index.html` is the file that is returned if a directory is requested +<6> serve the classpath content on root context `/` + +Static content can also be registered using the configuration of server feature. + +If you use `Config` with your webserver setup, you can register the same static content using configuration: + +[source,yaml] +.application.yaml +---- +server: + features: + static-content: + path: + - context: "/pictures" + location: "/some/WEB/pics" + classpath: + - context: "/" + welcome: "index.html" + location: "/static-content" +---- + +See xref:{rootdir}/config/io_helidon_webserver_staticcontent_StaticContentFeature.adoc[Static Content Feature Configuration Reference] for details of configuration options. -A `StaticContentService` object can be created using `create(...)` factory methods or a -`builder`. The `builder` lets you provide more configuration values, including _welcome file-name_ -and mappings of filename extensions to media types. == Media types support WebServer and WebClient share the HTTP media support of Helidon, and any supported media type can be used in both. diff --git a/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java b/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java index 5ba705ba717..c5a53aec33c 100644 --- a/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java +++ b/docs/src/main/java/io/helidon/docs/se/WebServerSnippets.java @@ -34,6 +34,7 @@ import io.helidon.http.media.jsonp.JsonpSupport; import io.helidon.webserver.ProxyProtocolData; import io.helidon.webserver.WebServer; +import io.helidon.webserver.WebServerConfig; import io.helidon.webserver.accesslog.AccessLogFeature; import io.helidon.webserver.http.HttpRoute; import io.helidon.webserver.http.HttpRouting; @@ -41,7 +42,7 @@ import io.helidon.webserver.http.HttpService; import io.helidon.webserver.http1.Http1Route; import io.helidon.webserver.http2.Http2Route; -import io.helidon.webserver.staticcontent.StaticContentService; +import io.helidon.webserver.staticcontent.StaticContentFeature; // tag::snippet_14[] import jakarta.json.Json; @@ -240,12 +241,15 @@ void snippet_21(HttpRules rules) { // end::snippet_21[] } - void snippet_22(HttpRouting.Builder routing) { + void snippet_22(WebServerConfig.Builder builder) { // tag::snippet_22[] - routing.register("/pictures", StaticContentService.create(Paths.get("/some/WEB/pics"))) // <1> - .register("/", StaticContentService.builder("/static-content") // <2> - .welcomeFileName("index.html") // <3> - .build()); + builder.addFeature(StaticContentFeature.builder() // <1> + .addPath(p -> p.location(Paths.get("/some/WEB/pics")) // <2> + .context("/pictures")) // <3> + .addClasspath(cl -> cl.location("/static-content") // <4> + .welcome("index.html") // <5> + .context("/")) // <6> + .build()); // end::snippet_22[] } diff --git a/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java b/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java index 1866eb47d09..fd3b9ef1738 100644 --- a/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java +++ b/microprofile/server/src/main/java/io/helidon/microprofile/server/ServerCdiExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2023 Oracle and/or its affiliates. + * Copyright (c) 2018, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,7 +56,7 @@ import io.helidon.webserver.observe.ObserveFeatureConfig; import io.helidon.webserver.observe.spi.Observer; import io.helidon.webserver.spi.ServerFeature; -import io.helidon.webserver.staticcontent.StaticContentService; +import io.helidon.webserver.staticcontent.StaticContentConfig; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; @@ -383,8 +383,10 @@ private void registerKpiMetricsDeferrableRequestContextSetterHandler(JaxRsCdiExt if (!routingsWithKPIMetrics.contains(routing)) { routingsWithKPIMetrics.add(routing); routing.any(KeyPerformanceIndicatorSupport.DeferrableRequestContext.CONTEXT_SETTING_HANDLER); - LOGGER.log(Level.TRACE, () -> String.format("Adding deferrable request KPI metrics context for routing with name '%s'" - + "", namedRouting.orElse(""))); + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, String.format("Adding deferrable request KPI metrics context for routing with name '%s'", + namedRouting.orElse(""))); + } } } @@ -557,40 +559,64 @@ private void registerDefaultRedirect() { } private void registerStaticContent() { - Config config = (Config) ConfigProvider.getConfig(); - config = config.get("server.static"); + Config rootConfig = (Config) ConfigProvider.getConfig(); + Config config = rootConfig.get("server.static"); + + if (config.exists()) { + LOGGER.log(Level.WARNING, "Configuration of static content through \"server.static\" is now deprecated." + + " Please use \"server.features.static-content\", with sub-keys \"path\" and/or \"classpath\"" + + " containing a list of handlers. At least \"context\" and \"location\" should be provided for each handler." + + " Location for classpath is the resource location with static content, for path it is the" + + " location on file system with the root of static content. For advanced configuration such as" + + " in-memory caching, temporary storage setup etc. kindly see our config reference for " + + "\"StaticContentFeature\" in documentation."); + } config.get("classpath") .ifExists(this::registerClasspathStaticContent); config.get("path") .ifExists(this::registerPathStaticContent); + + Config featureConfig = rootConfig.get("server.features.static-content"); + if (featureConfig.exists()) { + var builder = StaticContentConfig.builder() + .config(featureConfig); + if (builder.welcome().isEmpty()) { + builder.welcome("index.html"); + } + addFeature(builder.build()); + } } + @SuppressWarnings("removal") private void registerPathStaticContent(Config config) { Config context = config.get("context"); - StaticContentService.FileSystemBuilder pBuilder = StaticContentService.builder(config.get("location") + io.helidon.webserver.staticcontent.StaticContentService.FileSystemBuilder pBuilder = + io.helidon.webserver.staticcontent.StaticContentService.builder(config.get("location") .as(Path.class) .get()); pBuilder.welcomeFileName(config.get("welcome") .asString() .orElse("index.html")); - StaticContentService staticContent = pBuilder.build(); + var staticContent = pBuilder.build(); if (context.exists()) { routingBuilder.register(context.asString().get(), staticContent); } else { - Supplier ms = () -> staticContent; + Supplier ms = () -> staticContent; routingBuilder.register(ms); } STARTUP_LOGGER.log(Level.TRACE, "Static path"); } + @SuppressWarnings("removal") private void registerClasspathStaticContent(Config config) { Config context = config.get("context"); - StaticContentService.ClassPathBuilder cpBuilder = StaticContentService.builder(config.get("location").asString().get()); + io.helidon.webserver.staticcontent.StaticContentService.ClassPathBuilder cpBuilder = + io.helidon.webserver.staticcontent.StaticContentService.builder(config.get("location").asString().get()); cpBuilder.welcomeFileName(config.get("welcome") .asString() .orElse("index.html")); @@ -604,7 +630,7 @@ private void registerClasspathStaticContent(Config config) { .flatMap(List::stream) .forEach(cpBuilder::addCacheInMemory); - StaticContentService staticContent = cpBuilder.build(); + var staticContent = cpBuilder.build(); if (context.exists()) { routingBuilder.register(context.asString().get(), staticContent); diff --git a/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties b/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties index d5a17f82f33..b91e53bffac 100644 --- a/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties +++ b/tests/integration/packaging/mp-1/src/main/resources/META-INF/microprofile-config.properties @@ -27,6 +27,6 @@ tracing.global=false features.print-details=true -server.static.classpath.context=/static -server.static.classpath.location=/web -server.static.classpath.welcome=welcome.txt \ No newline at end of file +server.features.static-content.classpath.0.context=/static +server.features.static-content.classpath.0.location=/web +server.features.static-content.classpath.0.welcome=welcome.txt \ No newline at end of file diff --git a/webserver/static-content/etc/spotbugs/exclude.xml b/webserver/static-content/etc/spotbugs/exclude.xml index b88fd4ce2e8..387c959faba 100644 --- a/webserver/static-content/etc/spotbugs/exclude.xml +++ b/webserver/static-content/etc/spotbugs/exclude.xml @@ -1,7 +1,7 @@ + + + + + + + @@ -39,4 +45,10 @@ + + + + + + diff --git a/webserver/static-content/pom.xml b/webserver/static-content/pom.xml index 4ffa8e89a19..ce431215bec 100644 --- a/webserver/static-content/pom.xml +++ b/webserver/static-content/pom.xml @@ -61,6 +61,11 @@ mockito-core test
+ + io.helidon.logging + helidon-logging-jul + test + @@ -75,8 +80,50 @@ helidon-common-features-processor ${helidon.version} + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java new file mode 100644 index 00000000000..2656bb5723d --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/BaseHandlerConfigBlueprint.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.media.type.MediaType; + +/** + * Configuration of static content handlers that is common for classpath and file system based handlers. + */ +@Prototype.Blueprint(createEmptyPublic = false, createFromConfigPublic = false) +@Prototype.Configured +@Prototype.CustomMethods(StaticContentConfigSupport.BaseMethods.class) +interface BaseHandlerConfigBlueprint { + /** + * Whether this handle is enabled, defaults to {@code true}. + * + * @return whether enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); + + /** + * Context that will serve this handler's static resources, defaults to {@code /}. + * + * @return context under webserver + */ + @Option.Configured + @Option.Default("/") + String context(); + + /** + * Sockets names (listeners) that will host this static content handler, defaults to all configured sockets. + * Default socket name is {@code @default}. + * + * @return sockets to register this handler on + */ + @Option.Configured + @Option.Singular + Set sockets(); + + /** + * Welcome-file name. In case a directory is requested, this file would be served if present. + * There is no welcome file by default. + * + * @return welcome-file name, such as {@code index.html} + */ + @Option.Configured + Optional welcome(); + + /** + * A set of files that are cached in memory at startup. These files are never removed from the in-memory cache, though + * their overall size is added to the memory cache used bytes. + * When using classpath, the set must contain explicit list of all files that should be cached, when using file system, + * it can contain a directory, and all files under that directory (recursive) would be cached as well. + *

+ * Note that files cached through this method may use more than the max-bytes configured for the in-memory cache (i.e. + * this option wins over the maximal size in bytes), so kindly be careful with what is pushed to the cache. + *

+ * Files cached in memory will never be re-loaded, even if changed, until server restart! + * + * @return set of file names (or directory names if not using classpath) to cache in memory on startup + */ + @Option.Configured + @Option.Singular + Set cachedFiles(); + + /** + * Handles will use memory cache configured on {@link StaticContentConfig#memoryCache()} by default. + * In case a memory cache is configured here, it will replace the memory cache used by the static content feature, and this + * handle will use a dedicated memory cache instead. + *

+ * To disable memory caching for a single handler, create the configuration, and set {@code enabled: false}. + * + * @return memory cache to use with this handler + */ + @Option.Configured + Optional memoryCache(); + + /** + * Maps a filename extension to the response content type. + * To have a system-wide configuration, you can use the service loader SPI + * {@link io.helidon.common.media.type.spi.MediaTypeDetector}. + *

+ * This method can override {@link io.helidon.common.media.type.MediaTypes} detection + * for a specific static content handler. + *

+ * Handler will use a union of configuration on the {@link io.helidon.webserver.staticcontent.StaticContentConfig} and + * here when used from configuration. + * + * @return map of file extensions to associated media type + */ + @Option.Configured + @Option.Singular + Map contentTypes(); + + /** + * Map request path to resource path. Default uses the same path as requested. + * This can be used to resolve all paths to a single file, or to filter out files. + * + * @return function to map request path to resource path + */ + @Option.DefaultMethod("identity") + Function pathMapper(); + + /** + * Configure capacity of cache used for resources. This cache will make sure the media type and location is discovered + * faster. + *

+ * To cache content (bytes) in memory, use {@link io.helidon.webserver.staticcontent.BaseHandlerConfig#memoryCache()} + * + * @return maximal number of cached records, only caches media type and Path, not the content + */ + @Option.Configured + Optional recordCacheCapacity(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java index 06ac5ab9d7a..7511b68e386 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerInMemory.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,7 +78,7 @@ private void send(ServerRequest request, ServerResponse response) { contentLength); if (ranges.size() == 1) { // single response - ByteRangeRequest range = ranges.get(0); + ByteRangeRequest range = ranges.getFirst(); if (range.offset() > contentLength()) { throw new HttpException("Invalid range offset", Status.REQUESTED_RANGE_NOT_SATISFIABLE_416, true); diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java index 62612bd01db..6209185a0f0 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/CachedHandlerJar.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,26 +18,80 @@ package io.helidon.webserver.staticcontent; import java.io.IOException; +import java.io.InputStream; +import java.lang.System.Logger.Level; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.function.BiConsumer; import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; +import io.helidon.http.Header; +import io.helidon.http.HeaderNames; +import io.helidon.http.HeaderValues; import io.helidon.http.Method; import io.helidon.http.ServerResponseHeaders; import io.helidon.webserver.http.ServerRequest; import io.helidon.webserver.http.ServerResponse; +import static io.helidon.webserver.staticcontent.StaticContentHandler.formatLastModified; import static io.helidon.webserver.staticcontent.StaticContentHandler.processEtag; import static io.helidon.webserver.staticcontent.StaticContentHandler.processModifyHeaders; -record CachedHandlerJar(Path path, - MediaType mediaType, - Instant lastModified, - BiConsumer setLastModifiedHeader) implements CachedHandler { +/** + * Handles a jar file entry. + * The entry may be extracted into a temporary file (optional). + */ +class CachedHandlerJar implements CachedHandler { private static final System.Logger LOGGER = System.getLogger(CachedHandlerJar.class.getName()); + private final MediaType mediaType; + private final Header contentLength; + private final Instant lastModified; + private final BiConsumer setLastModifiedHeader; + private final Path path; + private final URL url; + + private CachedHandlerJar(MediaType mediaType, + URL url, + long contentLength, + Instant lastModified, + BiConsumer setLastModifiedHeader, + Path path) { + this.mediaType = mediaType; + this.url = url; + this.contentLength = HeaderValues.create(HeaderNames.CONTENT_LENGTH, true, false, contentLength); + this.lastModified = lastModified; + this.setLastModifiedHeader = setLastModifiedHeader; + this.path = path; + } + + static CachedHandlerJar create(TemporaryStorage tmpStorage, + URL fileUrl, + Instant lastModified, + MediaType mediaType, + long contentLength) { + + BiConsumer headerHandler = headerHandler(lastModified); + + var createdTmpFile = tmpStorage.createFile(); + if (createdTmpFile.isPresent()) { + // extract entry + Path tmpFile = createdTmpFile.get(); + try (InputStream is = fileUrl.openStream()) { + Files.copy(is, tmpFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + // silently consume the exception, as the tmp file may have been removed, we may throw when reading the file + LOGGER.log(Level.TRACE, "Failed to create temporary extracted file for " + fileUrl, e); + } + return new CachedHandlerJar(mediaType, fileUrl, contentLength, lastModified, headerHandler, tmpFile); + } else { + // use the entry always + return new CachedHandlerJar(mediaType, fileUrl, contentLength, lastModified, headerHandler, null); + } + } @Override public boolean handle(LruCache cache, @@ -46,32 +100,52 @@ public boolean handle(LruCache cache, ServerResponse response, String requestedResource) throws IOException { - // check if file still exists (the tmp may have been removed, file may have been removed - // there is still a race change, but we do not want to keep cached records for invalid files - if (!Files.exists(path)) { - cache.remove(requestedResource); - return false; - } - - if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { - LOGGER.log(System.Logger.Level.TRACE, "Sending static content from jar: " + requestedResource); + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Sending static content from jar: " + requestedResource); } // etag etc. if (lastModified != null) { processEtag(String.valueOf(lastModified.toEpochMilli()), request.headers(), response.headers()); - processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader()); + processModifyHeaders(lastModified, request.headers(), response.headers(), setLastModifiedHeader); } response.headers().contentType(mediaType); if (method == Method.GET) { - FileBasedContentHandler.send(request, response, path); + try { + if (path != null && Files.exists(path)) { + FileBasedContentHandler.send(request, response, path); + return true; + } + } catch (IOException e) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Failed to send jar entry from extracted path: " + path + + ", will send directly from jar", + e); + } + } + try (var in = url.openStream(); var out = response.outputStream()) { + // no support for ranges when using jar stream + in.transferTo(out); + } } else { - response.headers().contentLength(FileBasedContentHandler.contentLength(path)); + response.headers().set(contentLength); response.send(); } return true; } + + private static BiConsumer headerHandler(Instant lastModified) { + if (lastModified == null) { + return (headers, instant) -> { + }; + } + Header instantHeader = HeaderValues.create(HeaderNames.LAST_MODIFIED, + true, + false, + formatLastModified(lastModified)); + return (headers, instant) -> headers.set(instantHeader); + } } diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java index 7d2dce9cbbb..3e2ec31db05 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClassPathContentHandler.java @@ -24,19 +24,14 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.time.Instant; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.BiFunction; +import java.util.function.BiConsumer; +import java.util.function.Supplier; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -46,6 +41,7 @@ import io.helidon.http.HeaderValues; import io.helidon.http.InternalServerException; import io.helidon.http.Method; +import io.helidon.http.ServerResponseHeaders; import io.helidon.webserver.http.ServerRequest; import io.helidon.webserver.http.ServerResponse; @@ -59,49 +55,23 @@ class ClassPathContentHandler extends FileBasedContentHandler { private final ClassLoader classLoader; private final String root; private final String rootWithTrailingSlash; - private final BiFunction tmpFile; private final Set cacheInMemory; + private final TemporaryStorage tmpStorage; - // URL's hash code and equal are not suitable for map or set - private final Map extracted = new HashMap<>(); - private final ReentrantLock lock = new ReentrantLock(); + ClassPathContentHandler(ClasspathHandlerConfig config) { + super(config); - ClassPathContentHandler(StaticContentService.ClassPathBuilder builder) { - super(builder); - - this.classLoader = builder.classLoader(); - this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); - this.root = builder.root(); + this.classLoader = config.classLoader().orElseGet(() -> Thread.currentThread().getContextClassLoader()); + this.cacheInMemory = new HashSet<>(config.cachedFiles()); + this.root = cleanRoot(config.location()); this.rootWithTrailingSlash = root + '/'; - Path tmpDir = builder.tmpDir(); - if (tmpDir == null) { - this.tmpFile = (prefix, suffix) -> { - try { - return Files.createTempFile(prefix, suffix); - } catch (IOException e) { - throw new InternalServerException("Failed to create temporary file", e, true); - } - }; - } else { - this.tmpFile = (prefix, suffix) -> { - try { - return Files.createTempFile(tmpDir, prefix, suffix); - } catch (IOException e) { - throw new InternalServerException("Failed to create temporary file", e, true); - } - }; - } + this.tmpStorage = config.temporaryStorage().orElseGet(TemporaryStorage::create); } - static String fileName(URL url) { - String path = url.getPath(); - int index = path.lastIndexOf('/'); - if (index > -1) { - return path.substring(index + 1); - } - - return path; + @SuppressWarnings("removal") // will be replaced with HttpService once removed + static StaticContentService create(ClasspathHandlerConfig config) { + return new ClassPathContentHandler(config); } @Override @@ -123,7 +93,6 @@ void releaseCache() { populatedInMemoryCache.set(false); } - @SuppressWarnings("checkstyle:RegexpSinglelineJava") @Override boolean doHandle(Method method, String requestedPath, ServerRequest request, ServerResponse response, boolean mapped) throws IOException, URISyntaxException { @@ -214,6 +183,16 @@ boolean doHandle(Method method, String requestedPath, ServerRequest request, Ser return cachedHandler.handle(handlerCache(), method, request, response, requestedResource); } + private static String fileName(URL url) { + String path = url.getPath(); + int index = path.lastIndexOf('/'); + if (index > -1) { + return path.substring(index + 1); + } + + return path; + } + private String requestedResource(String rawPath, String requestedPath, boolean mapped) throws URISyntaxException { String resource = requestedPath.isEmpty() || "/".equals(requestedPath) ? root : (rootWithTrailingSlash + requestedPath); @@ -232,82 +211,99 @@ private String requestedResource(String rawPath, String requestedPath, boolean m return rawPath.endsWith("/") ? result + "/" : result; } - private Optional jarHandler(String requestedResource, URL url) { - ExtractedJarEntry extrEntry; - lock.lock(); - try { - extrEntry = extracted.compute(requestedResource, (key, entry) -> existOrCreate(url, entry)); - } finally { - lock.unlock(); - } + private Optional jarHandler(String requestedResource, URL url) throws IOException { + JarURLConnection jarUrlConnection = (JarURLConnection) url.openConnection(); + JarEntry jarEntry = jarUrlConnection.getJarEntry(); - if (extrEntry.tempFile == null) { - // once again, not caching 404 + if (jarEntry.isDirectory()) { + // we cannot cache this - as we consider this to be 404 return Optional.empty(); } - Instant lastModified = extrEntry.lastModified(); - if (lastModified == null) { - return Optional.of(new CachedHandlerJar(extrEntry.tempFile, - detectType(extrEntry.entryName), - null, - null)); - } else { - // we can cache this, as this is a jar record + var contentLength = jarEntry.getSize(); + var contentType = detectType(fileName(url)); + Optional lastModified; + + try (JarFile jarFile = jarUrlConnection.getJarFile()) { + lastModified = lastModified(jarFile.getName()); + } + + var lastModifiedHandler = lastModifiedHandler(lastModified); + + /* + We have all the information we need to process a jar file + Now we have two options: + 1. The file will be cached in memory + 2. The file will be handled through CachedHandlerJar (and possibly extracted to a temporary directory) + */ + if (contentLength <= Integer.MAX_VALUE && canCacheInMemory((int) contentLength)) { + // we may be able to cache this entry + var cached = cacheInMemory(requestedResource, + (int) contentLength, + inMemorySupplier(url, + lastModified.orElse(null), + lastModifiedHandler, + contentType, + contentLength)); + if (cached.isPresent()) { + // we have successfully cached the entry in memory + return Optional.of(cached.get()); + } + } + + // cannot cache in memory (too big file, cache full) + CachedHandlerJar jarHandler = CachedHandlerJar.create(tmpStorage, + url, + lastModified.orElse(null), + contentType, + contentLength); + + return Optional.of(jarHandler); + } + + private BiConsumer lastModifiedHandler(Optional lastModified) { + if (lastModified.isPresent()) { Header lastModifiedHeader = HeaderValues.create(HeaderNames.LAST_MODIFIED, true, false, - formatLastModified(lastModified)); - return Optional.of(new CachedHandlerJar(extrEntry.tempFile, - detectType(extrEntry.entryName), - extrEntry.lastModified(), - (headers, instant) -> headers.set(lastModifiedHeader))); + formatLastModified(lastModified.get())); + return (headers, instant) -> headers.set(lastModifiedHeader); + } else { + return (headers, instant) -> { + }; } } - private ExtractedJarEntry existOrCreate(URL url, ExtractedJarEntry entry) { - if (entry == null) { - return extractJarEntry(url); - } - if (entry.tempFile == null) { - return entry; - } - if (Files.notExists(entry.tempFile)) { - return extractJarEntry(url); - } - return entry; + private Supplier inMemorySupplier(URL url, + Instant lastModified, + BiConsumer lastModifiedHandler, + MediaType contentType, + long contentLength) { + + Header contentLengthHeader = HeaderValues.create(HeaderNames.CONTENT_LENGTH, + contentLength); + return () -> { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream in = url.openStream()) { + in.transferTo(baos); + } catch (IOException e) { + throw new InternalServerException("Cannot load resource", e); + } + byte[] bytes = baos.toByteArray(); + return new CachedHandlerInMemory(contentType, + lastModified, + lastModifiedHandler, + bytes, + bytes.length, + contentLengthHeader); + }; } private Optional urlStreamHandler(URL url) { return Optional.of(new CachedHandlerUrlStream(detectType(fileName(url)), url)); } - private ExtractedJarEntry extractJarEntry(URL url) { - try { - JarURLConnection jarUrlConnection = (JarURLConnection) url.openConnection(); - JarFile jarFile = jarUrlConnection.getJarFile(); - JarEntry jarEntry = jarUrlConnection.getJarEntry(); - if (jarEntry.isDirectory()) { - return new ExtractedJarEntry(jarEntry.getName()); // a directory - } - Optional lastModified = lastModified(jarFile.getName()); - - // Extract JAR entry to file - try (InputStream is = jarFile.getInputStream(jarEntry)) { - Path tempFile = tmpFile.apply("ws", ".je"); - Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); - return new ExtractedJarEntry(tempFile, lastModified.orElse(null), jarEntry.getName()); - } finally { - if (!jarUrlConnection.getUseCaches()) { - jarFile.close(); - } - } - } catch (IOException ioe) { - throw new InternalServerException("Cannot load resource", ioe); - } - } - - private void addToInMemoryCache(String resource) throws IOException, URISyntaxException { + private void addToInMemoryCache(String resource) throws IOException { /* we need to know: - content size @@ -351,12 +347,19 @@ private void addToInMemoryCache(String resource) throws IOException, URISyntaxEx cacheInMemory(requestedResource, contentType, entityBytes, lastModified); } - private Optional lastModified(URL url) throws URISyntaxException, IOException { - return switch (url.getProtocol()) { - case "file" -> lastModified(Paths.get(url.toURI())); - case "jar" -> lastModifiedFromJar(url); - default -> Optional.empty(); - }; + private Optional lastModified(URL url) { + try { + return switch (url.getProtocol()) { + case "file" -> lastModified(Paths.get(url.toURI())); + case "jar" -> lastModifiedFromJar(url); + default -> Optional.empty(); + }; + } catch (IOException | URISyntaxException e) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Failed to get last modification of a file for URL: " + url, e); + } + return Optional.empty(); + } } private Optional lastModifiedFromJar(URL url) throws IOException { @@ -369,12 +372,18 @@ private Optional lastModified(String path) throws IOException { return lastModified(Paths.get(path)); } - private record ExtractedJarEntry(Path tempFile, Instant lastModified, String entryName) { - /** - * Creates directory representation. - */ - ExtractedJarEntry(String entryName) { - this(null, null, entryName); + private static String cleanRoot(String location) { + String cleanRoot = location; + if (cleanRoot.startsWith("/")) { + cleanRoot = cleanRoot.substring(1); + } + while (cleanRoot.endsWith("/")) { + cleanRoot = cleanRoot.substring(0, cleanRoot.length() - 1); + } + + if (cleanRoot.isEmpty()) { + throw new IllegalArgumentException("Cannot serve full classpath, please configure a classpath prefix"); } + return cleanRoot; } } diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java new file mode 100644 index 00000000000..0c76306a30f --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/ClasspathHandlerConfigBlueprint.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Classpath based static content handler configuration. + */ +@Prototype.Configured +@Prototype.Blueprint +@Prototype.CustomMethods(StaticContentConfigSupport.ClasspathMethods.class) +interface ClasspathHandlerConfigBlueprint extends BaseHandlerConfigBlueprint { + /** + * The location on classpath that contains the root of the static content. + * This should never be the root (i.e. {@code /}), as that would allow serving of all class files. + * + * @return location on classpath to serve the static content, such as {@code "/web"}. + */ + @Option.Configured + String location(); + + /** + * Customization of temporary storage configuration. + * + * @return temporary storage config + */ + @Option.Configured + Optional temporaryStorage(); + + /** + * Class loader to use to lookup the static content resources from classpath. + * + * @return class loader to use + */ + Optional classLoader(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java index 68dc364ffdf..e7eeb87766e 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileBasedContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2023 Oracle and/or its affiliates. + * Copyright (c) 2021, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,10 +44,10 @@ abstract class FileBasedContentHandler extends StaticContentHandler { private final Map customMediaTypes; - FileBasedContentHandler(StaticContentService.FileBasedBuilder builder) { - super(builder); + FileBasedContentHandler(BaseHandlerConfig config) { + super(config); - this.customMediaTypes = builder.specificContentTypes(); + this.customMediaTypes = config.contentTypes(); } static String fileName(Path path) { @@ -82,7 +82,7 @@ static void send(ServerRequest request, ServerResponse response, Path path) thro contentLength); if (ranges.size() == 1) { // single response - ByteRangeRequest range = ranges.get(0); + ByteRangeRequest range = ranges.getFirst(); range.setContentRange(response); // only send a part of the file diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java index d0ab9570794..233c46670e0 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemContentHandler.java @@ -20,7 +20,6 @@ import java.lang.System.Logger.Level; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; @@ -40,11 +39,21 @@ class FileSystemContentHandler extends FileBasedContentHandler { private final Path root; private final Set cacheInMemory; - FileSystemContentHandler(StaticContentService.FileSystemBuilder builder) { - super(builder); + FileSystemContentHandler(FileSystemHandlerConfig config) { + super(config); - this.root = builder.root().toAbsolutePath().normalize(); - this.cacheInMemory = new HashSet<>(builder.cacheInMemory()); + this.root = config.location().toAbsolutePath().normalize(); + this.cacheInMemory = config.cachedFiles(); + } + + @SuppressWarnings("removal") + static StaticContentService create(FileSystemHandlerConfig config) { + Path location = config.location(); + if (Files.isDirectory(location)) { + return new FileSystemContentHandler(config); + } else { + return new SingleFileContentHandler(config); + } } @Override @@ -175,7 +184,7 @@ private void addToInMemoryCache(String resource) throws IOException { if (Files.isDirectory(path)) { try (var paths = Files.newDirectoryStream(path)) { - paths.forEach(child -> { + paths.forEach(child -> { if (!Files.isDirectory(child)) { // we need to use forward slash even on Windows String childResource = root.relativize(child).toString().replace('\\', '/'); diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java new file mode 100644 index 00000000000..bc24cb3a8a4 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/FileSystemHandlerConfigBlueprint.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.nio.file.Path; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * File system based static content handler configuration. + */ +@Prototype.Configured +@Prototype.Blueprint +@Prototype.CustomMethods(StaticContentConfigSupport.FileSystemMethods.class) +interface FileSystemHandlerConfigBlueprint extends BaseHandlerConfigBlueprint { + /** + * The directory (or a single file) that contains the root of the static content. + * + * @return location to serve the static content, such as {@code "/home/user/static-content"}. + */ + @Option.Configured + Path location(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java new file mode 100644 index 00000000000..3b8b2d46a7b --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/IoSupplier.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.io.IOException; + +@FunctionalInterface +interface IoSupplier { + T get() throws IOException; +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java new file mode 100644 index 00000000000..ae065de3a91 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCache.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.helidon.builder.api.RuntimeType; + +/** + * Memory cache to allow in-memory storage of static content, rather than reading it from file system each time the + * resource is requested. + */ +@RuntimeType.PrototypedBy(MemoryCacheConfig.class) +public class MemoryCache implements RuntimeType.Api { + private final MemoryCacheConfig config; + private final long maxSize; + // cache is Map Map CachedHandlerInMemory>> + private final Map> cache = new IdentityHashMap<>(); + private final ReadWriteLock cacheLock = new ReentrantReadWriteLock(); + + private final ReentrantLock sizeLock = new ReentrantLock(); + private long currentSize; + + private MemoryCache(MemoryCacheConfig config) { + this.config = config; + if (config.enabled()) { + long configuredMax = config.capacity().toBytes(); + this.maxSize = configuredMax == 0 ? Long.MAX_VALUE : configuredMax; + } else { + this.maxSize = 0; + } + } + + /** + * A new builder to configure and create a memory cache. + * + * @return a new fluent API builder + */ + public static MemoryCacheConfig.Builder builder() { + return MemoryCacheConfig.builder(); + } + + /** + * Create a new memory cache from its configuration. + * + * @param config memory cache configuration + * @return a new configured memory cache + */ + public static MemoryCache create(MemoryCacheConfig config) { + return new MemoryCache(config); + } + + /** + * Create a new memory cache customizing its configuration. + * + * @param consumer configuration consumer + * @return a new configured memory cache + */ + public static MemoryCache create(Consumer consumer) { + return builder().update(consumer).build(); + } + + /** + * Create an in-memory cache with zero capacity. Only + * {@link #cache(StaticContentHandler, String, CachedHandlerInMemory)} will be stored in it, as these are considered + * required cache records. All calls to {@link #cache(StaticContentHandler, String, int, java.util.function.Supplier)} will + * return empty. + * + * @return a new memory cache with capacity set to zero + */ + public static MemoryCache create() { + return builder().enabled(false).build(); + } + + @Override + public MemoryCacheConfig prototype() { + return config; + } + + void clear(StaticContentHandler staticContentHandler) { + try { + cacheLock.writeLock().lock(); + cache.remove(staticContentHandler); + } finally { + cacheLock.writeLock().unlock(); + } + } + + /** + * Is there a possibility to cache the bytes. + * There may be a race, so {@link #cache(StaticContentHandler, String, int, java.util.function.Supplier)} may still return + * empty. + * + * @return if there is space in the cache for the number of bytes requested + */ + boolean available(int bytes) { + return maxSize != 0 && (currentSize + bytes) <= maxSize; + } + + Optional cache(StaticContentHandler handler, + String resource, + int size, + Supplier handlerSupplier) { + try { + sizeLock.lock(); + if (maxSize == 0 || currentSize + size > maxSize) { + // either we are not enabled, or the size would be bigger than maximal size + return Optional.empty(); + } + // increase current size + currentSize += size; + } finally { + sizeLock.unlock(); + } + try { + cacheLock.writeLock().lock(); + CachedHandlerInMemory cachedHandlerInMemory = handlerSupplier.get(); + cache.computeIfAbsent(handler, k -> new HashMap<>()) + .put(resource, cachedHandlerInMemory); + return Optional.of(cachedHandlerInMemory); + } finally { + cacheLock.writeLock().unlock(); + } + } + + // hard add to cache, even if disabled (for explicitly configured resources to cache in memory) + void cache(StaticContentHandler handler, String resource, CachedHandlerInMemory inMemoryHandler) { + try { + sizeLock.lock(); + if (maxSize != 0) { + // only increase current size if enabled, otherwise it does not matter + currentSize += inMemoryHandler.contentLength(); + } + } finally { + sizeLock.unlock(); + } + try { + cacheLock.writeLock().lock(); + cache.computeIfAbsent(handler, k -> new HashMap<>()) + .put(resource, inMemoryHandler); + } finally { + cacheLock.writeLock().unlock(); + } + } + + Optional get(StaticContentHandler handler, String resource) { + try { + cacheLock.readLock().lock(); + Map resourceCache = cache.get(handler); + if (resourceCache == null) { + return Optional.empty(); + } + return Optional.ofNullable(resourceCache.get(resource)); + } finally { + cacheLock.readLock().unlock(); + } + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java new file mode 100644 index 00000000000..f2d6eaed31f --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/MemoryCacheConfigBlueprint.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.Size; + +/** + * Configuration of memory cache for static content. + * The memory cache will cache the first {@link #capacity() bytes} that fit into the configured memory size for the + * duration of the service uptime. + */ +@Prototype.Blueprint +@Prototype.Configured +interface MemoryCacheConfigBlueprint extends Prototype.Factory { + /** + * Whether the cache is enabled, defaults to {@code true}. + * + * @return whether the cache is enabled + */ + @Option.DefaultBoolean(true) + @Option.Configured + boolean enabled(); + + /** + * Capacity of the cached bytes of file content. + * If set to {@code 0}, the cache is unlimited. To disable caching, set {@link #enabled()} to {@code false}, + * or do not configure a memory cache at all. + *

+ * The capacity must be less than {@link java.lang.Long#MAX_VALUE} bytes, though you must be careful still, + * as it must fit into the heap size. + * + * @return capacity of the cache in bytes, defaults to 50 million bytes (50 mB) + */ + @Option.Default("50 mB") + @Option.Configured + Size capacity(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java index b737bfdf670..c5e4141a416 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/SingleFileContentHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,11 +32,11 @@ class SingleFileContentHandler extends FileBasedContentHandler { private final boolean cacheInMemory; private final Path path; - SingleFileContentHandler(FileSystemBuilder builder) { - super(builder); + SingleFileContentHandler(FileSystemHandlerConfig config) { + super(config); - this.cacheInMemory = builder.cacheInMemory().contains(".") || builder.cacheInMemory().contains("/"); - this.path = builder.root().toAbsolutePath().normalize(); + this.cacheInMemory = config.cachedFiles().contains(".") || config.cachedFiles().contains("/"); + this.path = config.location().toAbsolutePath().normalize(); } @Override diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java new file mode 100644 index 00000000000..6a16496d69e --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigBlueprint.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.media.type.MediaType; +import io.helidon.webserver.spi.ServerFeatureProvider; + +/** + * Configuration of Static content feature. + *

+ * Minimal example configuring a single classpath resource (properties): + *

+ * server.features.static-content.classpath.0.context=/static
+ * server.features.static-content.classpath.0.location=/web
+ * 
+ * and using yaml: + *
+ * server:
+ *   features:
+ *     static-content:
+ *       classpath:
+ *         - context: "/static"
+ *           location: "/web"
+ * 
+ */ +@Prototype.Blueprint +@Prototype.Configured(value = StaticContentFeature.STATIC_CONTENT_ID, root = false) +@Prototype.Provides(ServerFeatureProvider.class) +@Prototype.CustomMethods(StaticContentConfigSupport.StaticContentMethods.class) +interface StaticContentConfigBlueprint extends Prototype.Factory { + /** + * Whether this feature is enabled, defaults to {@code true}. + * + * @return whether this feature is enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); + + /** + * Weight of the static content feature. Defaults to + * {@value StaticContentFeature#WEIGHT}. + * + * @return weight of the feature + */ + @Option.DefaultDouble(StaticContentFeature.WEIGHT) + @Option.Configured + double weight(); + + + /** + * Name of this instance. + * + * @return instance name + */ + @Option.Default(StaticContentFeature.STATIC_CONTENT_ID) + String name(); + + /** + * Memory cache shared by the whole feature. + * If not configured, files are not cached in memory (except for explicitly marked files/resources in each section). + * + * @return memory cache, if configured + */ + @Option.Configured + Optional memoryCache(); + + /** + * Temporary storage to use across all classpath handlers. + * If not defined, a default one will be created. + * + * @return temporary storage + */ + @Option.Configured + Optional temporaryStorage(); + + /** + * List of classpath based static content handlers. + * + * @return classpath handlers + */ + @Option.Configured + @Option.Singular + List classpath(); + + /** + * List of file system based static content handlers. + * + * @return path handlers + */ + @Option.Configured + @Option.Singular + List path(); + + /** + * Maps a filename extension to the response content type. + * To have a system-wide configuration, you can use the service loader SPI + * {@link io.helidon.common.media.type.spi.MediaTypeDetector}. + *

+ * This method can override {@link io.helidon.common.media.type.MediaTypes} detection + * for a specific static content handler. + *

+ * Handler will use a union of configuration defined here, and on the handler + * here when used from configuration. + * + * @return map of file extensions to associated media type + */ + @Option.Configured + @Option.Singular + Map contentTypes(); + + /** + * Welcome-file name. Default for all handlers. + * By default, we do not serve default files. + * + * @return welcome-file name, such as {@code index.html} + */ + @Option.Configured + Optional welcome(); + + /** + * Sockets names (listeners) that will host static content handlers, defaults to all configured sockets. + * Default socket name is {@code @default}. + *

+ * This configures defaults for all handlers. + * + * @return sockets to register this handler on + */ + @Option.Configured + @Option.Singular + Set sockets(); + +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java new file mode 100644 index 00000000000..c01793f3901 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentConfigSupport.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.nio.file.Path; + +import io.helidon.builder.api.Prototype; +import io.helidon.common.config.Config; +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; + +final class StaticContentConfigSupport { + private StaticContentConfigSupport() { + } + + static class BaseMethods { + private BaseMethods() { + } + + @Prototype.FactoryMethod + static MediaType createContentTypes(Config config) { + return StaticContentConfigSupport.createContentTypes(config); + } + } + + static class FileSystemMethods { + private FileSystemMethods() { + } + + /** + * Create a new file system based static content configuration from the defined location. + * All other configuration is default. + * + * @param location path on file system that is the root of static content (all files under it will be available!) + * @return a new configuration for classpath static content handler + */ + @Prototype.FactoryMethod + static FileSystemHandlerConfig create(Path location) { + return FileSystemHandlerConfig.builder() + .location(location) + .build(); + } + } + + static class ClasspathMethods { + private ClasspathMethods() { + } + + /** + * Create a new classpath based static content configuration from the defined location. + * All other configuration is default. + * + * @param location location on classpath + * @return a new configuration for classpath static content handler + */ + @Prototype.FactoryMethod + static ClasspathHandlerConfig create(String location) { + return ClasspathHandlerConfig.builder() + .location(location) + .build(); + } + } + + static class StaticContentMethods { + private StaticContentMethods() { + } + + @Prototype.FactoryMethod + static MediaType createContentTypes(Config config) { + return StaticContentConfigSupport.createContentTypes(config); + } + } + + private static MediaType createContentTypes(Config config) { + return config.asString() + .map(MediaTypes::create) + .get(); + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java new file mode 100644 index 00000000000..cf83d2d102a --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeature.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.lang.System.Logger; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.Weighted; +import io.helidon.common.media.type.MediaType; +import io.helidon.config.Config; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.HttpService; +import io.helidon.webserver.spi.ServerFeature; + +/** + * WebServer feature to register static content. + */ +@RuntimeType.PrototypedBy(StaticContentConfig.class) +public class StaticContentFeature implements Weighted, ServerFeature, RuntimeType.Api { + static final String STATIC_CONTENT_ID = "static-content"; + static final double WEIGHT = 95; + + private static final Logger LOGGER = System.getLogger(StaticContentFeature.class.getName()); + + private final StaticContentConfig config; + private final MemoryCache memoryCache; + private final TemporaryStorage temporaryStorage; + private final Map contentTypeMapping; + private final boolean enabled; + private final Set sockets; + private final Optional welcome; + + private StaticContentFeature(StaticContentConfig config) { + this.config = config; + this.enabled = config.enabled() && !(config.classpath().isEmpty() && config.path().isEmpty()); + if (enabled) { + this.contentTypeMapping = config.contentTypes(); + this.memoryCache = config.memoryCache() + .orElseGet(MemoryCache::create); + this.sockets = config.sockets(); + this.welcome = config.welcome(); + + if (config.classpath().isEmpty()) { + this.temporaryStorage = null; + } else { + this.temporaryStorage = config.temporaryStorage() + .orElseGet(TemporaryStorage::create); + } + } else { + this.sockets = Set.of(); + this.welcome = Optional.empty(); + this.memoryCache = null; + this.temporaryStorage = null; + this.contentTypeMapping = null; + } + } + + /** + * Create Access log support configured from {@link io.helidon.config.Config}. + * + * @param config to configure a new access log support instance + * @return a new access log support to be registered with WebServer routing + */ + public static StaticContentFeature create(Config config) { + return builder() + .config(config) + .build(); + } + + /** + * A new fluent API builder to create Access log support instance. + * + * @return a new builder + */ + public static StaticContentConfig.Builder builder() { + return StaticContentConfig.builder(); + } + + /** + * Create a new instance from its configuration. + * + * @param config configuration + * @return a new feature + */ + public static StaticContentFeature create(StaticContentConfig config) { + return new StaticContentFeature(config); + } + + /** + * Create a new instance customizing its configuration. + * + * @param builderConsumer consumer of configuration + * @return a new feature + */ + public static StaticContentFeature create(Consumer builderConsumer) { + return builder() + .update(builderConsumer) + .build(); + } + + /** + * Create an Http service for file system based content handler. + * + * @param config configuration of the content handler + * @return a new HTTP service ready to be registered + */ + public static HttpService createService(FileSystemHandlerConfig config) { + return FileSystemContentHandler.create(config); + } + + /** + * Create an Http service for classpath based content handler. + * + * @param config configuration of the content handler + * @return a new HTTP service ready to be registered + */ + public static HttpService createService(ClasspathHandlerConfig config) { + return ClassPathContentHandler.create(config); + } + + @Override + public StaticContentConfig prototype() { + return config; + } + + @Override + public String name() { + return config.name(); + } + + @Override + public String type() { + return STATIC_CONTENT_ID; + } + + @Override + public void setup(ServerFeatureContext featureContext) { + if (!enabled) { + return; + } + + Set defaultSockets; + if (this.sockets.isEmpty()) { + defaultSockets = new HashSet<>(featureContext.sockets()); + defaultSockets.add(WebServer.DEFAULT_SOCKET_NAME); + } else { + defaultSockets = new HashSet<>(this.sockets); + } + + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + + for (ClasspathHandlerConfig handlerConfig : config.classpath()) { + if (!handlerConfig.enabled()) { + continue; + } + + + Set handlerSockets = handlerConfig.sockets().isEmpty() + ? defaultSockets + : handlerConfig.sockets(); + MemoryCache handlerCache = handlerConfig.memoryCache() + .orElse(this.memoryCache); + TemporaryStorage handlerTmpStorage = handlerConfig.temporaryStorage() + .orElse(this.temporaryStorage); + ClassLoader handlerClassLoader = handlerConfig.classLoader() + .orElse(contextClassLoader); + Optional welcome = handlerConfig.welcome().or(() -> this.welcome); + Map contentTypeMap = new HashMap<>(this.contentTypeMapping); + contentTypeMap.putAll(handlerConfig.contentTypes()); + + for (String handlerSocket : handlerSockets) { + if (!featureContext.socketExists(handlerSocket)) { + LOGGER.log(Logger.Level.WARNING, "Static content handler is configured for socket \"" + handlerSocket + + "\" that is not configured on the server"); + continue; + } + + handlerConfig = ClasspathHandlerConfig.builder() + .from(handlerConfig) + .memoryCache(handlerCache) + .temporaryStorage(handlerTmpStorage) + .update(it -> welcome.ifPresent(it::welcome)) + .classLoader(handlerClassLoader) + .contentTypes(contentTypeMap) + .build(); + + HttpService service = createService(handlerConfig); + featureContext.socket(handlerSocket) + .httpRouting() + .register(handlerConfig.context(), service); + } + } + for (FileSystemHandlerConfig handlerConfig : config.path()) { + if (!handlerConfig.enabled()) { + continue; + } + Set handlerSockets = handlerConfig.sockets().isEmpty() + ? defaultSockets + : handlerConfig.sockets(); + MemoryCache handlerCache = handlerConfig.memoryCache() + .orElse(this.memoryCache); + Map contentTypeMap = new HashMap<>(this.contentTypeMapping); + contentTypeMap.putAll(handlerConfig.contentTypes()); + + for (String handlerSocket : handlerSockets) { + if (!featureContext.socketExists(handlerSocket)) { + LOGGER.log(Logger.Level.WARNING, "Static content handler is configured for socket \"" + handlerSocket + + "\" that is not configured on the server"); + continue; + } + + handlerConfig = FileSystemHandlerConfig.builder() + .from(handlerConfig) + .memoryCache(handlerCache) + .contentTypes(contentTypeMap) + .build(); + + HttpService service = createService(handlerConfig); + featureContext.socket(handlerSocket) + .httpRouting() + .register(handlerConfig.context(), service); + } + } + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java new file mode 100644 index 00000000000..fe9379a2f4f --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentFeatureProvider.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import io.helidon.common.Weight; +import io.helidon.common.config.Config; +import io.helidon.webserver.spi.ServerFeatureProvider; + +/** + * {@link java.util.ServiceLoader} provider implementation for static-content feature for {@link io.helidon.webserver.WebServer}. + */ +@Weight(StaticContentFeature.WEIGHT) +public class StaticContentFeatureProvider implements ServerFeatureProvider { + /** + * Required for {@link java.util.ServiceLoader}. + * + * @deprecated only for {@link java.util.ServiceLoader} + */ + @Deprecated + public StaticContentFeatureProvider() { + } + + @Override + public String configKey() { + return StaticContentFeature.STATIC_CONTENT_ID; + } + + @Override + public StaticContentFeature create(Config config, String name) { + return StaticContentFeature.builder() + .config(config) + .name(name) + .build(); + } +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java index 101ed81080f..b84e3d59a63 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentHandler.java @@ -24,12 +24,11 @@ import java.time.ZonedDateTime; import java.time.chrono.ChronoZonedDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Function; +import java.util.function.Supplier; import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; @@ -52,19 +51,23 @@ /** * Base implementation of static content support. */ +@SuppressWarnings("removal") // will be replaced with HttpService once removed, or made package local abstract class StaticContentHandler implements StaticContentService { private static final System.Logger LOGGER = System.getLogger(StaticContentHandler.class.getName()); - private final Map inMemoryCache = new ConcurrentHashMap<>(); private final LruCache handlerCache; private final String welcomeFilename; private final Function resolvePathFunction; private final AtomicInteger webServerCounter = new AtomicInteger(); - - StaticContentHandler(StaticContentService.Builder builder) { - this.welcomeFilename = builder.welcomeFileName(); - this.resolvePathFunction = builder.resolvePathFunction(); - this.handlerCache = builder.handlerCache(); + private final MemoryCache memoryCache; + + StaticContentHandler(BaseHandlerConfig config) { + this.welcomeFilename = config.welcome().orElse(null); + this.resolvePathFunction = config.pathMapper(); + this.handlerCache = LruCache.builder() + .update(it -> config.recordCacheCapacity().ifPresent(it::capacity)) + .build(); + this.memoryCache = config.memoryCache().orElseGet(MemoryCache::create); } /** @@ -172,6 +175,11 @@ static void throwNotFoundIf(boolean condition) { } } + static String formatLastModified(Instant lastModified) { + ZonedDateTime dt = ZonedDateTime.ofInstant(lastModified, ZoneId.systemDefault()); + return dt.format(DateTime.RFC_1123_DATE_TIME); + } + @Override public void beforeStart() { webServerCounter.incrementAndGet(); @@ -199,7 +207,7 @@ public void routing(HttpRules rules) { */ void releaseCache() { handlerCache.clear(); - inMemoryCache.clear(); + memoryCache.clear(this); } /** @@ -247,7 +255,7 @@ void handle(ServerRequest request, ServerResponse response) { * @param response an HTTP response * @param mapped whether the requestedPath is mapped using a mapping function (and differs from defined path) * @return {@code true} only if static content was found and processed. - * @throws java.io.IOException if resource is not acceptable + * @throws java.io.IOException if resource is not acceptable * @throws io.helidon.http.RequestException if some known WEB error */ abstract boolean doHandle(Method method, @@ -272,7 +280,7 @@ String welcomePageName() { * @param handler in memory handler */ void cacheInMemory(String resource, CachedHandlerInMemory handler) { - inMemoryCache.put(resource, handler); + memoryCache.cache(this, resource, handler); } /** @@ -282,7 +290,15 @@ void cacheInMemory(String resource, CachedHandlerInMemory handler) { * @return handler if found */ Optional cacheInMemory(String resource) { - return Optional.ofNullable(inMemoryCache.get(resource)); + return memoryCache.get(this, resource); + } + + boolean canCacheInMemory(int size) { + return memoryCache.available(size); + } + + Optional cacheInMemory(String resource, int size, Supplier supplier) { + return memoryCache.cache(this, resource, size, supplier); } /** @@ -306,19 +322,6 @@ LruCache handlerCache() { return handlerCache; } - private static String unquoteETag(String etag) { - if (etag == null || etag.isEmpty()) { - return etag; - } - if (etag.startsWith("W/") || etag.startsWith("w/")) { - etag = etag.substring(2); - } - if (etag.startsWith("\"") && etag.endsWith("\"")) { - etag = etag.substring(1, etag.length() - 1); - } - return etag; - } - void cacheInMemory(String resource, MediaType contentType, byte[] bytes, Optional lastModified) { int contentLength = bytes.length; Header contentLengthHeader = HeaderValues.create(HeaderNames.CONTENT_LENGTH, contentLength); @@ -349,8 +352,16 @@ void cacheInMemory(String resource, MediaType contentType, byte[] bytes, Optiona cacheInMemory(resource, inMemoryResource); } - static String formatLastModified(Instant lastModified) { - ZonedDateTime dt = ZonedDateTime.ofInstant(lastModified, ZoneId.systemDefault()); - return dt.format(DateTime.RFC_1123_DATE_TIME); + private static String unquoteETag(String etag) { + if (etag == null || etag.isEmpty()) { + return etag; + } + if (etag.startsWith("W/") || etag.startsWith("w/")) { + etag = etag.substring(2); + } + if (etag.startsWith("\"") && etag.endsWith("\"")) { + etag = etag.substring(1, etag.length() - 1); + } + return etag; } } diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java index 872468578ab..e02fd89bb02 100644 --- a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/StaticContentService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2023 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,11 +21,11 @@ import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.TreeMap; import java.util.function.Function; -import io.helidon.common.configurable.LruCache; import io.helidon.common.media.type.MediaType; import io.helidon.webserver.http.HttpService; @@ -42,7 +42,13 @@ * } *

* Content is served ONLY on HTTP {@code GET} method. + * + * @deprecated static content has been refactored to use server feature, with a new service, using new configuration approach, + * use {@link io.helidon.webserver.staticcontent.StaticContentFeature} instead, or if specific services are desired, + * kindly use {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(ClasspathHandlerConfig)} + * and/or {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(FileSystemHandlerConfig)} */ +@Deprecated(forRemoval = true, since = "4.1.5") public interface StaticContentService extends HttpService { /** * Creates new builder with defined static content root as a class-loader resource. Builder provides ability to define @@ -53,7 +59,9 @@ public interface StaticContentService extends HttpService { * @param resourceRoot a root resource path. * @return a builder * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static ClassPathBuilder builder(String resourceRoot) { Objects.requireNonNull(resourceRoot, "Attribute resourceRoot is null!"); return builder(resourceRoot, Thread.currentThread().getContextClassLoader()); @@ -67,7 +75,9 @@ static ClassPathBuilder builder(String resourceRoot) { * @param classLoader a class-loader for the static content * @return a builder * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static ClassPathBuilder builder(String resourceRoot, ClassLoader classLoader) { Objects.requireNonNull(resourceRoot, "Attribute resourceRoot is null!"); return new ClassPathBuilder() @@ -82,7 +92,9 @@ static ClassPathBuilder builder(String resourceRoot, ClassLoader classLoader) { * @param root a root path. * @return a builder * @throws NullPointerException if {@code root} attribute is {@code null} + * @deprecated use {@link io.helidon.webserver.staticcontent.FileSystemHandlerConfig#builder()} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static FileSystemBuilder builder(Path root) { Objects.requireNonNull(root, "Attribute root is null!"); return new FileSystemBuilder() @@ -97,7 +109,10 @@ static FileSystemBuilder builder(Path root) { * @param resourceRoot a root resource path. * @return created instance * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} and + * {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(ClasspathHandlerConfig)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static StaticContentService create(String resourceRoot) { return create(resourceRoot, Thread.currentThread().getContextClassLoader()); } @@ -109,7 +124,10 @@ static StaticContentService create(String resourceRoot) { * @param classLoader a class-loader for the static content * @return created instance * @throws NullPointerException if {@code resourceRoot} attribute is {@code null} + * @deprecated use {@link ClasspathHandlerConfig#builder()} and + * {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(ClasspathHandlerConfig)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static StaticContentService create(String resourceRoot, ClassLoader classLoader) { return builder(resourceRoot, classLoader).build(); } @@ -120,7 +138,10 @@ static StaticContentService create(String resourceRoot, ClassLoader classLoader) * @param root a root path. * @return created instance * @throws NullPointerException if {@code root} attribute is {@code null} + * @deprecated use {@link io.helidon.webserver.staticcontent.FileSystemHandlerConfig#builder()} and + * {@link io.helidon.webserver.staticcontent.StaticContentFeature#createService(FileSystemHandlerConfig)} instead */ + @Deprecated(forRemoval = true, since = "4.1.5") static StaticContentService create(Path root) { return builder(root).build(); } @@ -129,13 +150,16 @@ static StaticContentService create(Path root) { * Fluent builder of the StaticContent detailed parameters. * * @param type of a subclass of a concrete builder + * @deprecated replaced with {@link io.helidon.webserver.staticcontent.FileSystemHandlerConfig} and + * {@link io.helidon.webserver.staticcontent.ClasspathHandlerConfig} */ + @Deprecated(forRemoval = true, since = "4.1.5") abstract class Builder> implements io.helidon.common.Builder { + private final Set cacheInMemory = new HashSet<>(); + private String welcomeFileName; private Function resolvePathFunction = Function.identity(); - private Set cacheInMemory = new HashSet<>(); - private LruCache handlerCache; - + private Integer recordCacheCapacity; /** * Default constructor. @@ -205,13 +229,10 @@ public B addCacheInMemory(String path) { * @return updated builder */ public B recordCacheCapacity(int capacity) { - this.handlerCache = LruCache.builder() - .capacity(capacity) - .build(); + this.recordCacheCapacity = capacity; return identity(); } - /** * Build the actual instance. * @@ -231,9 +252,8 @@ Set cacheInMemory() { return cacheInMemory; } - - LruCache handlerCache() { - return handlerCache == null ? LruCache.create() : handlerCache; + Optional handlerCacheCapacity() { + return Optional.ofNullable(recordCacheCapacity); } } @@ -245,6 +265,7 @@ LruCache handlerCache() { @SuppressWarnings("unchecked") abstract class FileBasedBuilder> extends Builder> { private final Map specificContentTypes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + /** * Default constructor. */ @@ -311,7 +332,25 @@ public ClassPathBuilder tmpDir(Path tmpDir) { @Override protected StaticContentService doBuild() { - return new ClassPathContentHandler(this); + return ClassPathContentHandler.create( + ClasspathHandlerConfig.builder() + .location(clRoot) + .contentTypes(specificContentTypes()) + .update(it -> { + if (welcomeFileName() != null) { + it.welcome(welcomeFileName()); + } + }) + .cachedFiles(cacheInMemory()) + .pathMapper(resolvePathFunction()) + .update(it -> handlerCacheCapacity().ifPresent(it::recordCacheCapacity)) + .update(it -> classLoader().ifPresent(it::classLoader)) + .update(it -> { + if (tmpDir != null) { + it.temporaryStorage(tmp -> tmp.directory(tmpDir)); + } + }) + .build()); } ClassPathBuilder classLoader(ClassLoader cl) { @@ -338,16 +377,8 @@ ClassPathBuilder root(String root) { return this; } - String root() { - return clRoot; - } - - ClassLoader classLoader() { - return classLoader; - } - - Path tmpDir() { - return tmpDir; + Optional classLoader() { + return Optional.ofNullable(classLoader); } } @@ -368,11 +399,19 @@ protected StaticContentService doBuild() { if (root == null) { throw new NullPointerException("Root path must be defined"); } - if (Files.isDirectory(root)) { - return new FileSystemContentHandler(this); - } else { - return new SingleFileContentHandler(this); - } + return FileSystemContentHandler.create( + FileSystemHandlerConfig.builder() + .location(root) + .contentTypes(specificContentTypes()) + .update(it -> { + if (welcomeFileName() != null) { + it.welcome(welcomeFileName()); + } + }) + .cachedFiles(cacheInMemory()) + .pathMapper(resolvePathFunction()) + .update(it -> handlerCacheCapacity().ifPresent(it::recordCacheCapacity)) + .build()); } FileSystemBuilder root(Path root) { diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java new file mode 100644 index 00000000000..30c158fc9c3 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorage.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Consumer; + +import io.helidon.builder.api.RuntimeType; + +/** + * Handling of temporary files. + */ +@RuntimeType.PrototypedBy(TemporaryStorageConfig.class) +public interface TemporaryStorage extends RuntimeType.Api { + /** + * Create a new builder. + * + * @return a new fluent API builder + */ + static TemporaryStorageConfig.Builder builder() { + return TemporaryStorageConfig.builder(); + } + + /** + * Create a new instance from its configuration. + * + * @param config configuration of temporary storage + * @return a new configured instance + */ + static TemporaryStorage create(TemporaryStorageConfig config) { + return new TemporaryStorageImpl(config); + } + + /** + * Create a new instance customizing its configuration. + * + * @param consumer consumer of configuration of temporary storage + * @return a new configured instance + */ + static TemporaryStorage create(Consumer consumer) { + return builder().update(consumer).build(); + } + + /** + * Create a new instance with defaults. + * + * @return a new temporary storage (enabled) + */ + static TemporaryStorage create() { + return builder().build(); + } + + /** + * Create a temporary file. + * + * @return a new temporary file, if enabled and successful + */ + Optional createFile(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java new file mode 100644 index 00000000000..b3c1a3fdf41 --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageConfigBlueprint.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.nio.file.Path; +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Configuration of temporary storage for classpath based handlers. + */ +@Prototype.Configured +@Prototype.Blueprint +interface TemporaryStorageConfigBlueprint extends Prototype.Factory { + /** + * Default prefix. + */ + String DEFAULT_FILE_PREFIX = "helidon-ws"; + /** + * Default suffix. + */ + String DEFAULT_FILE_SUFFIX = ".je"; + + /** + * Whether the temporary storage is enabled, defaults to {@code true}. + * If disabled, nothing is stored in temporary directory (may have performance impact, as for example a file may be + * extracted from a zip file on each request). + * + * @return whether the temporary storage is enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); + + /** + * Location of the temporary storage, defaults to temporary storage configured for the JVM. + * + * @return directory of temporary storage + */ + @Option.Configured + Optional directory(); + + /** + * Prefix of the files in temporary storage. + * + * @return file prefix + */ + @Option.Configured + @Option.Default(DEFAULT_FILE_PREFIX) + String filePrefix(); + + /** + * Suffix of the files in temporary storage. + * + * @return file suffix + */ + @Option.Configured + @Option.Default(DEFAULT_FILE_SUFFIX) + String fileSuffix(); + + /** + * Whether temporary files should be deleted on JVM exit. + * This is enabled by default, yet it may be useful for debugging purposes to keep the files in place. + * + * @return whether to delete temporary files on JVM exit + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean deleteOnExit(); +} diff --git a/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java new file mode 100644 index 00000000000..0878f2fbb7e --- /dev/null +++ b/webserver/static-content/src/main/java/io/helidon/webserver/staticcontent/TemporaryStorageImpl.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + +import io.helidon.Main; +import io.helidon.spi.HelidonShutdownHandler; + +class TemporaryStorageImpl implements TemporaryStorage { + private static final System.Logger LOGGER = System.getLogger(TemporaryStorage.class.getName()); + + private final TemporaryStorageConfig config; + private final Supplier> tmpFile; + + TemporaryStorageImpl(TemporaryStorageConfig config) { + this.config = config; + this.tmpFile = tempFileSupplier(config); + } + + @Override + public TemporaryStorageConfig prototype() { + return config; + } + + @Override + public Optional createFile() { + return tmpFile.get(); + } + + private static Supplier> tempFileSupplier(TemporaryStorageConfig config) { + if (!config.enabled()) { + return Optional::empty; + } + + DeleteFilesHandler deleteFilesHandler = new DeleteFilesHandler(); + if (config.deleteOnExit()) { + Main.addShutdownHandler(deleteFilesHandler); + } + var configuredDir = config.directory(); + + IoSupplier pathSupplier; + if (configuredDir.isPresent()) { + pathSupplier = () -> Files.createTempFile(configuredDir.get(), config.filePrefix(), config.fileSuffix()); + } else { + pathSupplier = () -> Files.createTempFile(config.filePrefix(), config.fileSuffix()); + } + + return () -> { + deleteFilesHandler.tempFilesLock.lock(); + try { + if (deleteFilesHandler.closed) { + // we are shutting down, cannot provide a temp file, as we would not delete it + return Optional.empty(); + } + + Path path = pathSupplier.get(); + deleteFilesHandler.tempFiles.add(path); + return Optional.of(path); + } catch (IOException e) { + LOGGER.log(System.Logger.Level.WARNING, "Failed to create temporary file. Config: " + config, e); + return Optional.empty(); + } finally { + deleteFilesHandler.tempFilesLock.unlock(); + } + }; + } + + private static class DeleteFilesHandler implements HelidonShutdownHandler { + + private final List tempFiles = new ArrayList<>(); + private final ReentrantLock tempFilesLock = new ReentrantLock(); + + private volatile boolean closed; + + @Override + public void shutdown() { + tempFilesLock.lock(); + try { + closed = true; + for (Path tempFile : tempFiles) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + LOGGER.log(System.Logger.Level.WARNING, + "Failed to delete temporary file: " + tempFile.toAbsolutePath(), + e); + } + } + + tempFiles.clear(); + } finally { + tempFilesLock.unlock(); + } + } + } +} diff --git a/webserver/static-content/src/main/java/module-info.java b/webserver/static-content/src/main/java/module-info.java index 0555221617c..1121770b86f 100644 --- a/webserver/static-content/src/main/java/module-info.java +++ b/webserver/static-content/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,7 +31,11 @@ requires transitive io.helidon.common.configurable; requires transitive io.helidon.webserver; + requires transitive io.helidon.builder.api; + requires io.helidon; exports io.helidon.webserver.staticcontent; + provides io.helidon.webserver.spi.ServerFeatureProvider + with io.helidon.webserver.staticcontent.StaticContentFeatureProvider; } \ No newline at end of file diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java index a8b48feb129..718aa19ed9f 100644 --- a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/CachedHandlerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,6 +49,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@SuppressWarnings("removal") class CachedHandlerTest { private static final MediaType MEDIA_TYPE_ICON = MediaTypes.create("image/x-icon"); private static final Header ICON_TYPE = HeaderValues.create(HeaderNames.CONTENT_TYPE, MEDIA_TYPE_ICON.text()); diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java new file mode 100644 index 00000000000..5543141dd2d --- /dev/null +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentConfigTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.staticcontent; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +import io.helidon.common.testing.http.junit5.HttpHeaderMatcher; +import io.helidon.common.testing.junit5.OptionalMatcher; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.http.HeaderNames; +import io.helidon.http.Status; +import io.helidon.webclient.api.ClientResponseTyped; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webclient.http1.Http1ClientResponse; +import io.helidon.webserver.WebServer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.endsWith; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; + +class StaticContentConfigTest { + private static Http1Client testClient; + private static WebServer server; + private static Path tmpPath; + + @BeforeAll + static void setUp() throws IOException { + // current directory + Path path = Paths.get("."); + path = path.resolve("target/helidon/tmp"); + // we need to have this file ready + Files.createDirectories(path); + tmpPath = path; + + Config config = Config.just(ConfigSources.classpath("/config-unit-test-1.yaml")); + server = WebServer.builder() + .config(config.get("server")) + .port(0) + .build() + .start(); + + testClient = Http1Client.builder() + .baseUri("http://localhost:" + server.port()) + .shareConnectionCache(false) + .build(); + } + + @AfterAll + static void tearDown() { + if (server != null) { + server.stop(); + } + } + + @Test + void testClasspathFavicon() { + try (Http1ClientResponse response = testClient.get("/classpath/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/x-icon")); + } + } + + @Test + void testClasspathNested() { + try (Http1ClientResponse response = testClient.get("/classpath/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + + @Test + void testClasspathFromJar() throws IOException { + String serviceName = "io.helidon.webserver.testing.junit5.spi.ServerJunitExtension"; + ClientResponseTyped response = testClient.get("/jar/" + serviceName) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "application/octet-stream")); + assertThat(response.entity(), startsWith("# This file was generated by Helidon services Maven plugin.")); + + // when run in maven, we have jar, but from IDE we get a file, so we have to check it correctly + URL resource = Thread.currentThread().getContextClassLoader().getResource("META-INF/services/" + serviceName); + assertThat(resource, notNullValue()); + if (resource.getProtocol().equals("jar")) { + // we can validate the temporary file exists and is correct + try (var stream = Files.list(tmpPath)) { + Optional tmpFile = stream.findAny(); + + assertThat("There should be a single temporary file created in " + tmpPath, + tmpFile, + OptionalMatcher.optionalPresent()); + String fileName = tmpFile.get().getFileName().toString(); + assertThat(fileName, startsWith("helidon-custom")); + assertThat(fileName, endsWith(".cache")); + } + } + } + + @Test + void testClasspathSingleFile() { + try (Http1ClientResponse response = testClient.get("/singleclasspath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } + + @Test + void testFileSystemFavicon() { + try (Http1ClientResponse response = testClient.get("/path/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/my-icon")); + } + } + + @Test + void testFileSystemNested() { + try (Http1ClientResponse response = testClient.get("/path/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + + @Test + void testFileSystemSingleFile() { + try (Http1ClientResponse response = testClient.get("/singlepath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } +} diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java index 59b62d77116..56ec5f000d9 100644 --- a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentHandlerTest.java @@ -279,13 +279,15 @@ static class TestContentHandler extends FileSystemContentHandler { final boolean returnValue; Path path; - TestContentHandler(StaticContentService.FileSystemBuilder builder, boolean returnValue) { - super(builder); + TestContentHandler(FileSystemHandlerConfig config, boolean returnValue) { + super(config); this.returnValue = returnValue; } static TestContentHandler create(boolean returnValue) { - return new TestContentHandler(StaticContentService.builder(Paths.get(".")), returnValue); + return new TestContentHandler(FileSystemHandlerConfig.builder() + .location(Paths.get(".")) + .build(), returnValue); } @Override @@ -307,13 +309,15 @@ static class TestClassPathContentHandler extends ClassPathContentHandler { final AtomicInteger counter = new AtomicInteger(0); final boolean returnValue; - TestClassPathContentHandler(StaticContentService.ClassPathBuilder builder, boolean returnValue) { - super(builder); + TestClassPathContentHandler(ClasspathHandlerConfig config, boolean returnValue) { + super(config); this.returnValue = returnValue; } static TestClassPathContentHandler create() { - return new TestClassPathContentHandler(StaticContentService.builder("/root"), true); + return new TestClassPathContentHandler(ClasspathHandlerConfig.builder() + .location("/root") + .build(), true); } @Override diff --git a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java index 59ea7b2084d..907514b35b3 100644 --- a/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java +++ b/webserver/static-content/src/test/java/io/helidon/webserver/staticcontent/StaticContentTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2023 Oracle and/or its affiliates. + * Copyright (c) 2021, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import static io.helidon.webserver.staticcontent.StaticContentFeature.createService; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -45,6 +46,7 @@ class StaticContentTest { this.testClient = testClient; } + @SuppressWarnings("removal") @SetUpRoute static void setupRouting(HttpRouting.Builder builder) throws Exception { Path nested = tempDir.resolve("nested"); @@ -57,10 +59,15 @@ static void setupRouting(HttpRouting.Builder builder) throws Exception { Files.writeString(favicon, "Wrong icon text"); Files.writeString(nested.resolve("resource.txt"), "Nested content"); - builder.register("/classpath", StaticContentService.builder("web")) - .register("/singleclasspath", StaticContentService.builder("web/resource.txt")) - .register("/path", StaticContentService.builder(tempDir)) - .register("/singlepath", StaticContentService.builder(resource)); + builder.register("/classpath", createService(ClasspathHandlerConfig.create("web"))) + .register("/singleclasspath", createService(ClasspathHandlerConfig.create("web/resource.txt"))) + .register("/path", createService(FileSystemHandlerConfig.create(tempDir))) + .register("/singlepath", createService(FileSystemHandlerConfig.create(resource))); + + builder.register("/backward-comp/classpath", StaticContentService.builder("web")) + .register("/backward-comp/singleclasspath", StaticContentService.builder("web/resource.txt")) + .register("/backward-comp/path", StaticContentService.builder(tempDir)) + .register("/backward-comp/singlepath", StaticContentService.builder(resource)); } @Test @@ -73,6 +80,16 @@ void testClasspathFavicon() { } } + @Test + void testClasspathFaviconBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/classpath/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/x-icon")); + } + } + @Test void testClasspathNested() { try (Http1ClientResponse response = testClient.get("/classpath/nested/resource.txt") @@ -84,6 +101,17 @@ void testClasspathNested() { } } + @Test + void testClasspathNestedBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/classpath/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + @Test void testClasspathSingleFile() { try (Http1ClientResponse response = testClient.get("/singleclasspath") @@ -95,6 +123,17 @@ void testClasspathSingleFile() { } } + @Test + void testClasspathSingleFileBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/singleclasspath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } + @Test void testFileSystemFavicon() { try (Http1ClientResponse response = testClient.get("/path/favicon.ico") @@ -105,6 +144,16 @@ void testFileSystemFavicon() { } } + @Test + void testFileSystemFaviconBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/path/favicon.ico") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "image/x-icon")); + } + } + @Test void testFileSystemNested() { try (Http1ClientResponse response = testClient.get("/path/nested/resource.txt") @@ -116,6 +165,18 @@ void testFileSystemNested() { } } + + @Test + void testFileSystemNestedBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/path/nested/resource.txt") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Nested content")); + } + } + @Test void testFileSystemSingleFile() { try (Http1ClientResponse response = testClient.get("/singlepath") @@ -126,4 +187,15 @@ void testFileSystemSingleFile() { assertThat(response.as(String.class), is("Content")); } } + + @Test + void testFileSystemSingleFileBackwardComp() { + try (Http1ClientResponse response = testClient.get("/backward-comp/singlepath") + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.headers(), HttpHeaderMatcher.hasHeader(HeaderNames.CONTENT_TYPE, "text/plain")); + assertThat(response.as(String.class), is("Content")); + } + } } diff --git a/webserver/static-content/src/test/resources/config-unit-test-1.yaml b/webserver/static-content/src/test/resources/config-unit-test-1.yaml new file mode 100644 index 00000000000..e9be224c63f --- /dev/null +++ b/webserver/static-content/src/test/resources/config-unit-test-1.yaml @@ -0,0 +1,42 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed 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 +# +# http://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. +# + +# Static content configuration + +server: + features: + static-content: + temporary-storage: + directory: "./target/helidon/tmp" + file-prefix: "helidon-custom" + file-suffix: ".cache" + # delete-on-exit: false + content-types: + ico: "image/my-icon" + classpath: + - context: "/classpath" + location: "web" + content-types: + ico: "image/x-icon" + - context: "/singleclasspath" + location: "web/resource.txt" + - context: "/jar" + location: "META-INF/services" + path: + - context: "/path" + location: "./src/test/resources/web" + - context: "/singlepath" + location: "./src/test/resources/web/resource.txt" diff --git a/webserver/static-content/src/test/resources/logging-test.properties b/webserver/static-content/src/test/resources/logging-test.properties new file mode 100644 index 00000000000..fd7ff767340 --- /dev/null +++ b/webserver/static-content/src/test/resources/logging-test.properties @@ -0,0 +1,19 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed 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 +# +# http://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. +# + +handlers=io.helidon.logging.jul.HelidonConsoleHandler +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n +.level=INFO From c020496f70fe7ecc42361b4542d87903005969ce Mon Sep 17 00:00:00 2001 From: Daniel Kec Date: Tue, 26 Nov 2024 17:17:33 +0100 Subject: [PATCH 22/36] Http/2 revamp (#9520) 9273 Http/2 revamp * Add h2spec test * Consuming request trailers * Larger frame splitting fix * Flow control update timeout * Streamed payload larger than content length discovery Signed-off-by: Daniel Kec --- .../http/http2/Http2ConnectionWriter.java | 53 +--- .../io/helidon/http/http2/WindowSizeImpl.java | 56 +++- http/tests/media/multipart/pom.xml | 4 + .../test/resources/logging-test.properties | 4 +- tests/integration/h2spec/Dockerfile | 39 +++ tests/integration/h2spec/pom.xml | 133 ++++++++ .../h2spec/src/test/java/H2SpecIT.java | 154 ++++++++++ tests/integration/pom.xml | 1 + .../Http2ClientProtocolConfigBlueprint.java | 4 +- .../webserver/http2/Http2ConfigBlueprint.java | 7 +- .../webserver/http2/Http2Connection.java | 14 +- .../webserver/http2/Http2ServerResponse.java | 4 + .../webserver/http2/Http2ServerStream.java | 152 ++++++---- .../junit5/http2/Http2ServerExtension.java | 44 ++- .../testing/junit5/http2/Http2TestClient.java | 49 +++ .../junit5/http2/Http2TestConnection.java | 283 ++++++++++++++++++ webserver/tests/http2/pom.xml | 10 + .../tests/http2/ContentLengthTest.java | 185 ++++++++++++ .../test/resources/logging-test.properties | 6 +- 19 files changed, 1076 insertions(+), 126 deletions(-) create mode 100644 tests/integration/h2spec/Dockerfile create mode 100644 tests/integration/h2spec/pom.xml create mode 100644 tests/integration/h2spec/src/test/java/H2SpecIT.java create mode 100644 webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java create mode 100644 webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java create mode 100644 webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java diff --git a/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java b/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java index a52f3cc3b6b..147fa6bbdde 100755 --- a/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java +++ b/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,6 @@ public class Http2ConnectionWriter implements Http2StreamWriter { private final DataWriter writer; - // todo replace with prioritized lock (stream priority + connection writes have highest prio) private final Lock streamLock = new ReentrantLock(true); private final SocketContext ctx; private final Http2FrameListener listener; @@ -143,24 +142,14 @@ public int writeHeaders(Http2Headers headers, Http2Flag.HeaderFlags flags, Http2FrameData dataFrame, FlowControl.Outbound flowControl) { - // this is executing in the thread of the stream - // we must enforce parallelism of exactly 1, to make sure the dynamic table is updated - // and then immediately written - - lock(); - try { - int bytesWritten = 0; - - bytesWritten += writeHeaders(headers, streamId, flags, flowControl); - - writeData(dataFrame, flowControl); - bytesWritten += Http2FrameHeader.LENGTH; - bytesWritten += dataFrame.header().length(); - - return bytesWritten; - } finally { - streamLock.unlock(); - } + // Executed on stream thread + int bytesWritten = 0; + bytesWritten += writeHeaders(headers, streamId, flags, flowControl); + writeData(dataFrame, flowControl); + bytesWritten += Http2FrameHeader.LENGTH; + bytesWritten += dataFrame.header().length(); + + return bytesWritten; } /** @@ -227,32 +216,10 @@ private void splitAndWrite(Http2FrameData frame, FlowControl.Outbound flowContro } else if (splitFrames.length == 2) { // write send-able part and block until window update with the rest lockedWrite(splitFrames[0]); - flowControl.decrementWindowSize(currFrame.header().length()); + flowControl.decrementWindowSize(splitFrames[0].header().length()); flowControl.blockTillUpdate(); currFrame = splitFrames[1]; } } } - - // TODO use for fastpath - // private void noLockWrite(Http2FrameData... frames) { - // List toWrite = new LinkedList<>(); - // - // for (Http2FrameData frame : frames) { - // BufferData headerData = frame.header().write(); - // - // listener.frameHeader(ctx, frame.header()); - // listener.frameHeader(ctx, headerData); - // - // toWrite.add(headerData); - // - // BufferData data = frame.data(); - // - // if (data.available() != 0) { - // toWrite.add(data); - // } - // } - // - // writer.write(toWrite.toArray(new BufferData[0])); - // } } diff --git a/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java b/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java index f5107d7b375..5bb8a648054 100644 --- a/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java +++ b/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,12 +15,9 @@ */ package io.helidon.http.http2; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import static java.lang.System.Logger.Level.DEBUG; @@ -124,7 +121,9 @@ public long incrementWindowSize(int increment) { */ static final class Outbound extends WindowSizeImpl implements WindowSize.Outbound { - private final AtomicReference> updated = new AtomicReference<>(new CompletableFuture<>()); + private static final int BACKOFF_MIN = 50; + private static final int BACKOFF_MAX = 5000; + private final Semaphore updatedSemaphore = new Semaphore(1); private final ConnectionFlowControl.Type type; private final int streamId; private final long timeoutMillis; @@ -146,21 +145,52 @@ public long incrementWindowSize(int increment) { return remaining; } + @Override + public void resetWindowSize(int size) { + super.resetWindowSize(size); + triggerUpdate(); + } + + @Override + public int decrementWindowSize(int decrement) { + int n = super.decrementWindowSize(decrement); + triggerUpdate(); + return n; + } + @Override public void triggerUpdate() { - updated.getAndSet(new CompletableFuture<>()).complete(null); + updatedSemaphore.release(); } @Override public void blockTillUpdate() { + var startTime = System.currentTimeMillis(); + int backoff = BACKOFF_MIN; while (getRemainingWindowSize() < 1) { try { - updated.get().get(timeoutMillis, TimeUnit.MILLISECONDS); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { - LOGGER_OUTBOUND.log(DEBUG, - String.format("%s OFC STR %d: Window depleted, waiting for update.", type, streamId)); - } + updatedSemaphore.drainPermits(); + var ignored = updatedSemaphore.tryAcquire(backoff, TimeUnit.MILLISECONDS); + // linear deterministic backoff + backoff = Math.min(backoff * 2, BACKOFF_MAX); + } catch (InterruptedException e) { + debugLog("%s OFC STR %d: Window depleted, waiting for update interrupted.", e); + throw new Http2Exception(Http2ErrorCode.FLOW_CONTROL, "Flow control update wait interrupted."); + } + if (System.currentTimeMillis() - startTime > timeoutMillis) { + debugLog("%s OFC STR %d: Window depleted, waiting for update time-out.", null); + throw new Http2Exception(Http2ErrorCode.FLOW_CONTROL, "Flow control update wait time-out."); + } + debugLog("%s OFC STR %d: Window depleted, waiting for update.", null); + } + } + + private void debugLog(String message, Exception e) { + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + if (e != null) { + LOGGER_OUTBOUND.log(DEBUG, String.format(message, type, streamId), e); + } else { + LOGGER_OUTBOUND.log(DEBUG, String.format(message, type, streamId)); } } } diff --git a/http/tests/media/multipart/pom.xml b/http/tests/media/multipart/pom.xml index c5b784d9085..0794c682313 100644 --- a/http/tests/media/multipart/pom.xml +++ b/http/tests/media/multipart/pom.xml @@ -36,6 +36,10 @@ io.helidon.http.media helidon-http-media-multipart + + io.helidon.logging + helidon-logging-jul + io.helidon.webserver.testing.junit5 helidon-webserver-testing-junit5 diff --git a/http/tests/media/multipart/src/test/resources/logging-test.properties b/http/tests/media/multipart/src/test/resources/logging-test.properties index 4cb5c0b4f2f..90874f99c69 100644 --- a/http/tests/media/multipart/src/test/resources/logging-test.properties +++ b/http/tests/media/multipart/src/test/resources/logging-test.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2022, 2023 Oracle and/or its affiliates. +# Copyright (c) 2022, 2024 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -handlers=java.util.logging.ConsoleHandler +handlers=io.helidon.logging.jul.HelidonConsoleHandler java.util.logging.ConsoleHandler.level=FINEST java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=%1$tH:%1$tM:%1$tS %4$s %3$s %5$s%6$s%n diff --git a/tests/integration/h2spec/Dockerfile b/tests/integration/h2spec/Dockerfile new file mode 100644 index 00000000000..757b78d8298 --- /dev/null +++ b/tests/integration/h2spec/Dockerfile @@ -0,0 +1,39 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed 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 +# +# http://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. +# + +FROM container-registry.oracle.com/os/oraclelinux:9-slim AS build +ENV GO111MODULE=on +ENV GOPROXY=https://proxy.golang.org +ENV CGO_ENABLED=0 +ENV VERSION=2.6.1-SNAPSHOT +ENV COMMIT=af83a65f0b6273ef38bf778d400d98892e7653d8 + +RUN microdnf install go-toolset git -y + +WORKDIR /workspace +RUN git clone https://github.com/summerwind/h2spec.git + +WORKDIR /workspace/h2spec +RUN git checkout ${COMMIT} +RUN go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/h2spec + +FROM container-registry.oracle.com/os/oraclelinux:9-slim +ARG PORT=8080 +ARG HOST=localhost +ENV PORT=${PORT} +ENV HOST=${HOST} +COPY --from=build /workspace/h2spec/h2spec /usr/local/bin/h2spec +CMD ["/usr/local/bin/h2spec", "-h", "${HOST}", "-p", "${PORT}"] \ No newline at end of file diff --git a/tests/integration/h2spec/pom.xml b/tests/integration/h2spec/pom.xml new file mode 100644 index 00000000000..4338985156c --- /dev/null +++ b/tests/integration/h2spec/pom.xml @@ -0,0 +1,133 @@ + + + + + 4.0.0 + + io.helidon.applications + helidon-mp + 4.2.0-SNAPSHOT + ../../../applications/mp/pom.xml + + io.helidon.tests.integration.h2spec + helidon-tests-integration-h2spec + Helidon Tests Integration Http/2 h2spec + + + io.helidon.webserver.h2spec.Main + true + + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.webserver + helidon-webserver-http2 + + + io.helidon.config + helidon-config-yaml + + + io.helidon.logging + helidon-logging-jul + + + org.slf4j + slf4j-jdk14 + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.hamcrest + hamcrest-all + test + + + + org.testcontainers + junit-jupiter + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-libs + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT + + + + ${project.build.outputDirectory}/logging.properties + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + ${project.build.outputDirectory}/logging.properties + + + ${redirectTestOutputToFile} + + + + + integration-test + verify + + + + + + + diff --git a/tests/integration/h2spec/src/test/java/H2SpecIT.java b/tests/integration/h2spec/src/test/java/H2SpecIT.java new file mode 100644 index 00000000000..8c8b0d93e78 --- /dev/null +++ b/tests/integration/h2spec/src/test/java/H2SpecIT.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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. + */ + +import java.io.InputStream; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.stream.Stream; + +import javax.xml.parsers.DocumentBuilderFactory; + +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http2.Http2Config; +import io.helidon.webserver.http2.Http2Route; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import static io.helidon.http.Method.GET; +import static io.helidon.http.Method.POST; + +@Testcontainers(disabledWithoutDocker = true) +class H2SpecIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(H2SpecIT.class); + + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("runH2Spec") + void h2spec(String caseName, String desc, String id, String err, String skipped) { + LOGGER.info("{}: \n - {} \nID: {}", caseName, desc, id); + if (err != null) { + Assertions.fail(err); + } + if (skipped != null) { + Assumptions.abort(skipped); + } + } + + private static Stream runH2Spec() { + + HttpRouting.Builder router = HttpRouting.builder(); + router.route(Http2Route.route(GET, "/", (req, res) -> { + res.send("Hi Frank!"); + })); + + router.route(Http2Route.route(POST, "/", (req, res) -> { + req.content().consume(); + res.send("pong"); + })); + + WebServer server = WebServer.builder() + .addProtocol(Http2Config.builder() + .sendErrorDetails(true) + // 5.1.2 https://github.com/summerwind/h2spec/issues/136 + .maxConcurrentStreams(10) + .build()) + .routing(router) + .build(); + + int port = server.start().port(); + + try (var cont = new GenericContainer<>( + new ImageFromDockerfile().withDockerfile(Path.of("./Dockerfile"))) + .withAccessToHost(true) + .withImagePullPolicy(PullPolicy.ageBased(Duration.ofDays(365))) + .withLogConsumer(outputFrame -> LOGGER.info(outputFrame.getUtf8StringWithoutLineEnding())) + .waitingFor(Wait.forLogMessage(".*Finished in.*", 1))) { + + org.testcontainers.Testcontainers.exposeHostPorts(port); + cont.withCommand("/usr/local/bin/h2spec " + + "-h host.testcontainers.internal " + + "--junit-report junit-report.xml " + // h2spec creates dummy test headers x-dummy0 with generated content of length configured by parameter --max-header-length + // default value is 4000 to fit just under the default protocol max table size(4096) with margin of 96 + // as we are using custom host name 'host.testcontainers.internal' authority header is longer than usual 'localhost' + // also random port can have more chars than the usual 8080 + + "--max-header-length " + (4000 + - ("host.testcontainers.internal".length() - "localhost".length()) + - (String.valueOf(port).length() - "8080".length())) + + " -p " + port) + .withStartupAttempts(1) + .start(); + + cont.copyFileFromContainer("/junit-report.xml", "./target/h2spec-report.xml"); + return cont.copyFileFromContainer("/junit-report.xml", H2SpecIT::parseReport); + } finally { + server.stop(); + } + + } + + private static Stream parseReport(InputStream is) throws Exception { + var a = new ArrayList(); + var dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is); + var suitList = dom.getDocumentElement() + .getElementsByTagName("testsuite"); + + for (int i = 0; i < suitList.getLength(); i++) { + var suitEl = (Element) suitList.item(i); + var suitName = suitEl.getAttribute("name"); + var caseList = suitEl.getElementsByTagName("testcase"); + for (int j = 0; j < caseList.getLength(); j++) { + var caseEl = (Element) caseList.item(j); + var className = caseEl.getAttribute("classname"); + var id = caseEl.getAttribute("package"); + a.add(Arguments.of(suitName, + className, + id, + getChildElValue(caseEl, "error", "failure"), + getChildElValue(caseEl, "skipped"))); + } + } + return a.stream(); + } + + private static String getChildElValue(Element caseEl, String... nodeNames) { + for (int k = 0; k < caseEl.getChildNodes().getLength(); k++) { + Node node = caseEl.getChildNodes().item(k); + for (var nodeName : nodeNames) { + if (nodeName.equals(node.getNodeName())) { + return node.getTextContent(); + } + } + } + return null; + } + +} diff --git a/tests/integration/pom.xml b/tests/integration/pom.xml index aaf2d127b53..c342cceedb3 100644 --- a/tests/integration/pom.xml +++ b/tests/integration/pom.xml @@ -66,6 +66,7 @@ vault zipkin-mp-2.2 tls-revocation-config + h2spec diff --git a/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java b/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java index 76cd212ed35..c2d25e0c7d6 100644 --- a/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java +++ b/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java @@ -89,12 +89,12 @@ default String type() { int initialWindowSize(); /** - * Timeout for blocking between windows size check iterations. + * Timeout for blocking while waiting for window update when window is depleted. * * @return timeout */ @Option.Configured - @Option.Default("PT0.1S") + @Option.Default("PT15S") Duration flowControlBlockTimeout(); /** diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java index d1fe59ed0c0..e17a8de8dfe 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java @@ -82,9 +82,8 @@ interface Http2ConfigBlueprint extends ProtocolConfig { /** * Outbound flow control blocking timeout configured as {@link java.time.Duration} * or text in ISO-8601 format. - * Blocking timeout defines an interval to wait for the outbound window size changes(incoming window updates) - * before the next blocking iteration. - * Default value is {@code PT0.1S}. + * Blocking timeout defines an interval to wait for the outbound window size changes(incoming window updates). + * Default value is {@code PT15S}. * * * @@ -97,7 +96,7 @@ interface Http2ConfigBlueprint extends ProtocolConfig { * @see ISO_8601 Durations */ @Option.Configured - @Option.Default("PT0.1S") + @Option.Default("PT15S") Duration flowControlTimeout(); /** diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java index 295672a6130..c599a33847d 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java @@ -617,7 +617,7 @@ private void doHeaders(Limit limit) { int streamId = frameHeader.streamId(); StreamContext streamContext = stream(streamId); - streamContext.stream().checkHeadersReceivable(); + boolean trailers = streamContext.stream().checkHeadersReceivable(); // first frame, expecting continuation if (frameHeader.type() == Http2FrameType.HEADERS && !frameHeader.flags(Http2FrameTypes.HEADERS).endOfHeaders()) { @@ -672,6 +672,18 @@ private void doHeaders(Limit limit) { } receiveFrameListener.headers(ctx, streamId, headers); + + + if (trailers) { + if (!endOfStream) { + throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received trailers without endOfStream flag " + streamId); + } + stream.closeFromRemote(); + state = State.READ_FRAME; + // Client's trailers are ignored, we don't provide any API to consume them yet + return; + } + headers.validateRequest(); String path = headers.path(); Method method = headers.method(); diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java index 019ec79515d..374cdb5ba28 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java @@ -30,7 +30,9 @@ import io.helidon.http.ServerResponseHeaders; import io.helidon.http.ServerResponseTrailers; import io.helidon.http.Status; +import io.helidon.http.http2.Http2Exception; import io.helidon.http.http2.Http2Headers; +import io.helidon.webserver.CloseConnectionException; import io.helidon.webserver.ConnectionContext; import io.helidon.webserver.ServerConnectionException; import io.helidon.webserver.http.ServerResponseBase; @@ -121,6 +123,8 @@ public void send(byte[] entityBytes) { } afterSend(); + } catch (Http2Exception e) { + throw new CloseConnectionException("Failed writing entity", e); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed writing entity", e); } diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java index 50333162e29..987fb8c79a0 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java @@ -22,6 +22,7 @@ import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicReference; import io.helidon.common.buffers.BufferData; import io.helidon.common.concurrency.limits.FixedLimit; @@ -93,13 +94,12 @@ class Http2ServerStream implements Runnable, Http2Stream { private final StreamFlowControl flowControl; private final Http2ConcurrentConnectionStreams streams; private final HttpRouting routing; - + private final AtomicReference writeState = new AtomicReference<>(WriteState.INIT); private boolean wasLastDataFrame = false; private volatile Http2Headers headers; private volatile Http2Priority priority; // used from this instance and from connection private volatile Http2StreamState state = Http2StreamState.IDLE; - private WriteState writeState = WriteState.INIT; private Http2SubProtocolSelector.SubProtocolHandler subProtocolHandler; private long expectedLength = -1; private HttpPrologue prologue; @@ -165,22 +165,25 @@ public void checkDataReceivable() throws Http2Exception { * Check if headers can be received on this stream. * This method is called from connection thread. * + * @return true if headers are receivable as trailers * @throws Http2Exception in case headers cannot be received. */ - public void checkHeadersReceivable() throws Http2Exception { + public boolean checkHeadersReceivable() throws Http2Exception { switch (state) { case IDLE: - // this is OK - break; + // headers + return false; + case OPEN: + // trailers + return true; case HALF_CLOSED_LOCAL: case HALF_CLOSED_REMOTE: case CLOSED: throw new Http2Exception(Http2ErrorCode.STREAM_CLOSED, "Stream " + streamId + " received headers when stream is " + state); - case OPEN: - throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received headers for open stream " + streamId); default: - throw new Http2Exception(Http2ErrorCode.INTERNAL, "Unknown stream state: " + streamId + ", state: " + state); + throw new Http2Exception(Http2ErrorCode.INTERNAL, + "Unknown stream state, streamId: " + streamId + ", state: " + state); } } @@ -192,7 +195,7 @@ public boolean rstStream(Http2RstStream rstStream) { + streamId + " in IDLE state"); } // TODO interrupt - boolean rapidReset = writeState == WriteState.INIT; + boolean rapidReset = writeState.get() == WriteState.INIT; this.state = Http2StreamState.CLOSED; return rapidReset; } @@ -228,14 +231,14 @@ public void windowUpdate(Http2WindowUpdate windowUpdate) { @Override public void headers(Http2Headers headers, boolean endOfStream) { this.headers = headers; - this.state = endOfStream ? Http2StreamState.HALF_CLOSED_REMOTE : Http2StreamState.OPEN; - if (state == Http2StreamState.HALF_CLOSED_REMOTE) { - try { - // we need to notify that there is no data coming - inboundData.put(TERMINATING_FRAME); - } catch (InterruptedException e) { - throw new Http2Exception(Http2ErrorCode.INTERNAL, "Interrupted", e); - } + if (endOfStream) { + closeFromRemote(); + } else { + this.state = Http2StreamState.OPEN; + } + Headers httpHeaders = headers.httpHeaders(); + if (httpHeaders.contains(HeaderNames.CONTENT_LENGTH)) { + this.expectedLength = httpHeaders.get(HeaderNames.CONTENT_LENGTH).get(long.class); } } @@ -243,9 +246,20 @@ public void headers(Http2Headers headers, boolean endOfStream) { public void data(Http2FrameHeader header, BufferData data, boolean endOfStream) { if (expectedLength != -1 && expectedLength < header.length()) { state = Http2StreamState.CLOSED; + writeState.updateAndGet(s -> s.checkAndMove(WriteState.END)); + streams.remove(this.streamId); Http2RstStream rst = new Http2RstStream(Http2ErrorCode.PROTOCOL); writer.write(rst.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create())); - return; + + try { + // we need to notify that there is no data coming + inboundData.put(TERMINATING_FRAME); + } catch (InterruptedException e) { + throw new Http2Exception(Http2ErrorCode.INTERNAL, "Interrupted", e); + } + + throw new Http2Exception(Http2ErrorCode.ENHANCE_YOUR_CALM, + "Request data length doesn't correspond to the content-length header."); } if (expectedLength != -1) { expectedLength -= header.length(); @@ -330,11 +344,28 @@ public void run() { } } - int writeHeaders(Http2Headers http2Headers, boolean endOfStream) { - writeState = writeState.checkAndMove(WriteState.HEADERS_SENT); + void closeFromRemote() { + this.state = Http2StreamState.HALF_CLOSED_REMOTE; + try { + // we need to notify that there is no data coming + inboundData.put(TERMINATING_FRAME); + } catch (InterruptedException e) { + throw new Http2Exception(Http2ErrorCode.INTERNAL, "Interrupted", e); + } + } + + int writeHeaders(Http2Headers http2Headers, final boolean endOfStream) { + writeState.updateAndGet(s -> { + if (endOfStream) { + return s.checkAndMove(WriteState.HEADERS_SENT) + .checkAndMove(WriteState.END); + } + return s.checkAndMove(WriteState.HEADERS_SENT); + }); + Http2Flag.HeaderFlags flags; + if (endOfStream) { - writeState = writeState.checkAndMove(WriteState.END); streams.remove(this.streamId); flags = Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM); } else { @@ -349,12 +380,9 @@ int writeHeaders(Http2Headers http2Headers, boolean endOfStream) { } int writeHeadersWithData(Http2Headers http2Headers, int contentLength, BufferData bufferData, boolean endOfStream) { - writeState = writeState.checkAndMove(WriteState.HEADERS_SENT); - writeState = writeState.checkAndMove(WriteState.DATA_SENT); - if (endOfStream) { - writeState = writeState.checkAndMove(WriteState.END); - streams.remove(this.streamId); - } + writeState.updateAndGet(s -> s + .checkAndMove(WriteState.HEADERS_SENT) + .checkAndMove(WriteState.DATA_SENT)); Http2FrameData frameData = new Http2FrameData(Http2FrameHeader.create(contentLength, @@ -369,13 +397,24 @@ int writeHeadersWithData(Http2Headers http2Headers, int contentLength, BufferDat flowControl.outbound()); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed to write headers", e); + } finally { + if (endOfStream) { + writeState.updateAndGet(s -> s.checkAndMove(WriteState.END)); + streams.remove(this.streamId); + } } } - int writeData(BufferData bufferData, boolean endOfStream) { - writeState = writeState.checkAndMove(WriteState.DATA_SENT); + int writeData(BufferData bufferData, final boolean endOfStream) { + writeState.updateAndGet(s -> { + if (endOfStream) { + return s.checkAndMove(WriteState.DATA_SENT) + .checkAndMove(WriteState.END); + } + return s.checkAndMove(WriteState.DATA_SENT); + }); + if (endOfStream) { - writeState = writeState.checkAndMove(WriteState.END); streams.remove(this.streamId); } @@ -395,30 +434,33 @@ int writeData(BufferData bufferData, boolean endOfStream) { } int writeTrailers(Http2Headers http2trailers) { - writeState = writeState.checkAndMove(WriteState.TRAILERS_SENT); + writeState.updateAndGet(s -> s.checkAndMove(WriteState.TRAILERS_SENT)); streams.remove(this.streamId); try { - return writer.writeHeaders(http2trailers, - streamId, - Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), - flowControl.outbound()); + return writer.writeHeaders(http2trailers, + streamId, + Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), + flowControl.outbound()); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed to write trailers", e); } } void write100Continue() { - if (writeState == WriteState.EXPECTED_100) { - writeState = writeState.checkAndMove(WriteState.CONTINUE_100_SENT); - + if (WriteState.EXPECTED_100 == writeState.getAndUpdate(s -> { + if (WriteState.EXPECTED_100 == s) { + return s.checkAndMove(WriteState.CONTINUE_100_SENT); + } + return s; + })) { Header status = HeaderValues.createCached(Http2Headers.STATUS_NAME, 100); Http2Headers http2Headers = Http2Headers.create(WritableHeaders.create().add(status)); try { writer.writeHeaders(http2Headers, - streamId, - Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), - flowControl.outbound()); + streamId, + Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), + flowControl.outbound()); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed to write 100-Continue", e); } @@ -454,17 +496,17 @@ private BufferData readEntityFromPipeline() { if (frame.header().flags(Http2FrameTypes.DATA).endOfStream()) { wasLastDataFrame = true; + if (state == Http2StreamState.CLOSED) { + throw RequestException.builder().message("Stream is closed.").build(); + } } return frame.data(); } private void handle() { Headers httpHeaders = headers.httpHeaders(); - if (httpHeaders.contains(HeaderNames.CONTENT_LENGTH)) { - this.expectedLength = httpHeaders.get(HeaderNames.CONTENT_LENGTH).get(long.class); - } if (headers.httpHeaders().contains(HeaderValues.EXPECT_100)) { - writeState = writeState.checkAndMove(WriteState.EXPECTED_100); + writeState.updateAndGet(s -> s.checkAndMove(WriteState.EXPECTED_100)); } subProtocolHandler = null; @@ -574,20 +616,18 @@ private void handle() { } } - private record DataFrame(Http2FrameHeader header, BufferData data) { } - private enum WriteState { END, TRAILERS_SENT(END), DATA_SENT(TRAILERS_SENT, END), HEADERS_SENT(DATA_SENT, TRAILERS_SENT, END), - CONTINUE_100_SENT(HEADERS_SENT), - EXPECTED_100(CONTINUE_100_SENT, HEADERS_SENT), - INIT(EXPECTED_100, HEADERS_SENT); + CONTINUE_100_SENT(HEADERS_SENT, END), + EXPECTED_100(CONTINUE_100_SENT, HEADERS_SENT, END), + INIT(EXPECTED_100, HEADERS_SENT, END); private final Set allowedTransitions; - WriteState(WriteState... allowedTransitions){ + WriteState(WriteState... allowedTransitions) { this.allowedTransitions = Set.of(allowedTransitions); } @@ -595,7 +635,15 @@ WriteState checkAndMove(WriteState newState) { if (this == newState || allowedTransitions.contains(newState)) { return newState; } - throw new IllegalStateException("Transition from " + this + " to " + newState + " is not allowed!"); + + IllegalStateException badTransitionException = + new IllegalStateException("Transition from " + this + " to " + newState + " is not allowed!"); + if (this == END) { + throw new IllegalStateException("Stream is already closed.", badTransitionException); + } + throw badTransitionException; } } + + private record DataFrame(Http2FrameHeader header, BufferData data) { } } diff --git a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java index 50477caa3c7..3e5ac072431 100644 --- a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java +++ b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,9 @@ package io.helidon.webserver.testing.junit5.http2; +import java.net.URI; +import java.util.Set; + import io.helidon.webclient.http2.Http2Client; import io.helidon.webserver.WebServer; import io.helidon.webserver.testing.junit5.Junit5Util; @@ -30,18 +33,15 @@ * artifacts, such as {@link io.helidon.webclient.http2.Http2Client} in Helidon integration tests. */ public class Http2ServerExtension implements ServerJunitExtension { + + private static final Set> SUPPORTED = Set.of(Http2Client.class, Http2TestClient.class); + /** * Required constructor for {@link java.util.ServiceLoader}. */ public Http2ServerExtension() { } - @Override - public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) - throws ParameterResolutionException { - return Http2Client.class.equals(parameterContext.getParameter().getType()); - } - @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, @@ -49,11 +49,39 @@ public Object resolveParameter(ParameterContext parameterContext, WebServer server) { String socketName = Junit5Util.socketName(parameterContext.getParameter()); + URI uri = URI.create("http://localhost:" + server.port(socketName)); + if (Http2Client.class.equals(parameterType)) { return Http2Client.builder() - .baseUri("http://localhost:" + server.port(socketName)) + .baseUri(uri) .build(); } + + if (Http2TestClient.class.equals(parameterType)) { + Http2TestClient client = new Http2TestClient(uri); + extensionContext + .getStore(ExtensionContext.Namespace.GLOBAL) + .put(Http2TestClient.class.getName(), client); + return client; + } + throw new ParameterResolutionException("HTTP/2 extension only supports Http2Client parameter type"); } + + @Override + public void afterEach(ExtensionContext context) { + ServerJunitExtension.super.afterEach(context); + Http2TestClient client = (Http2TestClient) context + .getStore(ExtensionContext.Namespace.GLOBAL) + .remove(Http2TestClient.class.getName()); + if (client != null) { + client.close(); + } + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return SUPPORTED.contains(parameterContext.getParameter().getType()); + } } diff --git a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java new file mode 100644 index 00000000000..a54683650b4 --- /dev/null +++ b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.testing.junit5.http2; + +import java.net.URI; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Http/2 low-level testing client. + */ +public class Http2TestClient implements AutoCloseable { + + private final URI uri; + private final ConcurrentLinkedQueue testConnections = new ConcurrentLinkedQueue<>(); + + Http2TestClient(URI uri) { + this.uri = uri; + } + + /** + * Create new low-level http/2 connection. + * @return new connection + */ + public Http2TestConnection createConnection() { + var testConnection = new Http2TestConnection(uri); + testConnections.add(testConnection); + return testConnection; + } + + @Override + public void close() { + testConnections.forEach(Http2TestConnection::close); + } +} + diff --git a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java new file mode 100644 index 00000000000..886a70b89db --- /dev/null +++ b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.testing.junit5.http2; + +import java.io.UncheckedIOException; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; + +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.tls.Tls; +import io.helidon.http.Method; +import io.helidon.http.WritableHeaders; +import io.helidon.http.http2.FlowControl; +import io.helidon.http.http2.Http2ConnectionWriter; +import io.helidon.http.http2.Http2ErrorCode; +import io.helidon.http.http2.Http2Flag; +import io.helidon.http.http2.Http2FrameData; +import io.helidon.http.http2.Http2FrameHeader; +import io.helidon.http.http2.Http2FrameType; +import io.helidon.http.http2.Http2FrameTypes; +import io.helidon.http.http2.Http2GoAway; +import io.helidon.http.http2.Http2Headers; +import io.helidon.http.http2.Http2HuffmanDecoder; +import io.helidon.http.http2.Http2RstStream; +import io.helidon.http.http2.Http2Setting; +import io.helidon.http.http2.Http2Settings; +import io.helidon.http.http2.Http2Util; +import io.helidon.http.http2.Http2WindowUpdate; +import io.helidon.webclient.api.ClientUri; +import io.helidon.webclient.api.ConnectionKey; +import io.helidon.webclient.api.DefaultDnsResolver; +import io.helidon.webclient.api.DnsAddressLookup; +import io.helidon.webclient.api.Proxy; +import io.helidon.webclient.api.TcpClientConnection; +import io.helidon.webclient.api.WebClient; + +import org.hamcrest.Matchers; + +import static java.lang.System.Logger.Level.DEBUG; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * Http/2 low-level testing client connection. + */ +public class Http2TestConnection implements AutoCloseable { + + private static final System.Logger LOGGER = System.getLogger(Http2TestConnection.class.getName()); + private static final int FRAME_HEADER_LENGTH = 9; + + private final TcpClientConnection conn; + private final Http2ConnectionWriter dataWriter; + private final DataReader reader; + private final ArrayBlockingQueue readQueue = new ArrayBlockingQueue<>(100); + private final Thread readThread; + private final ClientUri clientUri; + private final Http2Headers.DynamicTable requestDynamicTable = + Http2Headers.DynamicTable.create(Http2Setting.HEADER_TABLE_SIZE.defaultValue()); + private final Http2HuffmanDecoder requestHuffman = Http2HuffmanDecoder.create(); + + Http2TestConnection(URI uri) { + clientUri = ClientUri.create(uri); + ConnectionKey connectionKey = new ConnectionKey(clientUri.scheme(), + clientUri.host(), + clientUri.port(), + Duration.ZERO, + Tls.builder().enabled(false).build(), + DefaultDnsResolver.create(), + DnsAddressLookup.defaultLookup(), + Proxy.noProxy()); + + conn = TcpClientConnection.create(WebClient.builder() + .baseUri(clientUri) + .build(), + connectionKey, + List.of(), + connection -> false, + connection -> { + }) + .connect(); + + conn.writer().writeNow(Http2Util.prefaceData()); + reader = conn.reader(); + dataWriter = new Http2ConnectionWriter(conn.helidonSocket(), conn.writer(), List.of()); + readThread = Thread + .ofVirtual() + .start(() -> { + try { + for (;;) { + if (Thread.interrupted()) { + return; + } + BufferData frameHeaderBuffer = reader.readBuffer(FRAME_HEADER_LENGTH); + Http2FrameHeader frameHeader = Http2FrameHeader.create(frameHeaderBuffer); + LOGGER.log(DEBUG, () -> "<-- " + frameHeader); + readQueue.add(new Http2FrameData(frameHeader, reader.readBuffer(frameHeader.length()))); + } + } catch (DataReader.InsufficientDataAvailableException | UncheckedIOException e) { + // closed connection + } + }); + + sendSettings(Http2Settings.builder() + .add(Http2Setting.INITIAL_WINDOW_SIZE, 65535L) + .add(Http2Setting.MAX_FRAME_SIZE, 16384L) + .add(Http2Setting.ENABLE_PUSH, false) + .build()); + } + + /** + * Send settings frame. + * + * @param http2Settings frame to send + * @return this connection + */ + public Http2TestConnection sendSettings(Http2Settings http2Settings) { + Http2Flag.SettingsFlags flags = Http2Flag.SettingsFlags.create(0); + Http2FrameData frameData = http2Settings.toFrameData(null, 0, flags); + writer().write(frameData); + return this; + } + + /** + * Return connection writer for direct frame sending. + * + * @return connection writer + */ + public Http2ConnectionWriter writer() { + return dataWriter; + } + + /** + * Send HTTP request with given stream id with single data frame created from supplied buffer data, + * dataframe has end of stream flag. + * + * @param streamId send request as given stream id + * @param method http method + * @param path context path + * @param headers http headers + * @param payload payload data which has to fit in single frame + * @return this connection + */ + public Http2TestConnection request(int streamId, Method method, String path, WritableHeaders headers, BufferData payload) { + Http2Headers h2Headers = Http2Headers.create(headers); + h2Headers.method(method); + h2Headers.path(path); + h2Headers.scheme(clientUri().scheme()); + + writer().writeHeaders(h2Headers, + streamId, + Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), + FlowControl.Outbound.NOOP); + + Http2FrameData frameDataData = + new Http2FrameData(Http2FrameHeader.create(payload.available(), + Http2FrameTypes.DATA, + Http2Flag.DataFlags.create(Http2Flag.END_OF_STREAM), + streamId), + payload); + writer().writeData(frameDataData, FlowControl.Outbound.NOOP); + return this; + } + + /** + * Await next frame, blocks until next frame arrive. + * + * @param timeout timeout for blocking + * @return next frame in order of reading from socket + */ + public Http2FrameData awaitNextFrame(Duration timeout) { + try { + return readQueue.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Wait for the next frame and assert its frame type to be RST_STREAM. + * @param streamId stream id asserted from retrieved RST_STREAM frame. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2RstStream assertRstStream(int streamId, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.RST_STREAM, timeout); + assertThat("Stream ID doesn't match.", frame.header().streamId(), Matchers.equalTo(streamId)); + return Http2RstStream.create(frame.data()); + } + + /** + * Wait for the next frame and assert its frame type to be SETTINGS. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2Settings assertSettings(Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.SETTINGS, timeout); + return Http2Settings.create(frame.data()); + } + + /** + * Wait for the next frame and assert its frame type to be WINDOWS_UPDATE. + * @param streamId stream id asserted from retrieved WINDOWS_UPDATE frame. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2WindowUpdate assertWindowsUpdate(int streamId, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.WINDOW_UPDATE, timeout); + assertThat(frame.header().streamId(), Matchers.equalTo(streamId)); + return Http2WindowUpdate.create(frame.data()); + } + + /** + * Wait for the next frame and assert its frame type to be HEADERS. + * @param streamId stream id asserted from retrieved HEADERS frame. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2Headers assertHeaders(int streamId, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.HEADERS, timeout); + assertThat(frame.header().streamId(), Matchers.equalTo(streamId)); + return Http2Headers.create(null, requestDynamicTable, requestHuffman, frame); + } + + /** + * Wait for the next frame and assert its frame type. + * @param frameType expected type of frame + * @param timeout timeout for blocking + * @return the frame + */ + public Http2FrameData assertNextFrame(Http2FrameType frameType, Duration timeout) { + Http2FrameData frame = awaitNextFrame(timeout); + assertThat(frame.header().type(), Matchers.equalTo(frameType)); + return frame; + } + + /** + * Wait for the next frame and assert its frame type to be GO_AWAY. + * @param errorCode expected error code + * @param message expected go away message + * @param timeout timeout for blocking + * @return the frame + */ + public Http2FrameData assertGoAway(Http2ErrorCode errorCode, String message, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.GO_AWAY, timeout); + + Http2GoAway goAway = Http2GoAway.create(frame.data()); + assertThat(goAway.errorCode(), is(errorCode)); + assertThat(frame.data().readString(frame.data().available()), is(message)); + return frame; + } + + @Override + public void close() { + readThread.interrupt(); + conn.closeResource(); + } + + /** + * Client uri used for connection, derived from Helidon test server. + * @return client uri + */ + public ClientUri clientUri() { + return clientUri; + } +} diff --git a/webserver/tests/http2/pom.xml b/webserver/tests/http2/pom.xml index e86c0bec124..878419fa2ef 100644 --- a/webserver/tests/http2/pom.xml +++ b/webserver/tests/http2/pom.xml @@ -33,6 +33,11 @@ io.helidon.webserver helidon-webserver-http2 + + io.helidon.logging + helidon-logging-jul + test + io.helidon.webserver.testing.junit5 helidon-webserver-testing-junit5 @@ -63,5 +68,10 @@ hamcrest-all test + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5-http2 + test + diff --git a/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java new file mode 100644 index 00000000000..4b62564c405 --- /dev/null +++ b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.tests.http2; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.helidon.common.buffers.BufferData; +import io.helidon.http.HeaderNames; +import io.helidon.http.RequestException; +import io.helidon.http.Status; +import io.helidon.http.WritableHeaders; +import io.helidon.http.http2.Http2ErrorCode; +import io.helidon.http.http2.Http2FrameType; +import io.helidon.http.http2.Http2Headers; +import io.helidon.logging.common.LogConfig; +import io.helidon.webserver.WebServerConfig; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http2.Http2Config; +import io.helidon.webserver.http2.Http2Route; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; +import io.helidon.webserver.testing.junit5.SetUpServer; +import io.helidon.webserver.testing.junit5.http2.Http2TestClient; +import io.helidon.webserver.testing.junit5.http2.Http2TestConnection; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static io.helidon.http.Method.POST; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@ServerTest +class ContentLengthTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(100); + private static CompletableFuture consumeExceptionFuture = new CompletableFuture<>(); + private static CompletableFuture sendExceptionFuture = new CompletableFuture<>(); + + static { + LogConfig.configureRuntime(); + } + + @SetUpRoute + static void router(HttpRouting.Builder router) { + router.route(Http2Route.route(POST, "/", (req, res) -> { + try { + req.content().consume(); + } catch (Exception e) { + consumeExceptionFuture.complete(e); + } + try { + res.send("pong"); + } catch (Exception e) { + sendExceptionFuture.complete(e); + } + })); + } + + @SetUpServer + static void setup(WebServerConfig.Builder server) { + server.addProtocol(Http2Config.builder() + .sendErrorDetails(true) + .maxConcurrentStreams(5) + .build()); + } + + @BeforeEach + void beforeEach() { + consumeExceptionFuture = new CompletableFuture<>(); + sendExceptionFuture = new CompletableFuture<>(); + } + + @Test + void shorterData(Http2TestClient client) { + Http2TestConnection h2conn = client.createConnection(); + + var headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 5); + h2conn.request(1, POST, "/", headers, BufferData.create("fra")); + + h2conn.assertSettings(TIMEOUT); + h2conn.assertWindowsUpdate(0, TIMEOUT); + h2conn.assertSettings(TIMEOUT); + + Http2Headers http2Headers = h2conn.assertHeaders(1, TIMEOUT); + assertThat(http2Headers.status(), is(Status.OK_200)); + byte[] responseBytes = h2conn.assertNextFrame(Http2FrameType.DATA, TIMEOUT).data().readBytes(); + assertThat(new String(responseBytes), is("pong")); + + assertFalse(consumeExceptionFuture.isDone()); + assertFalse(sendExceptionFuture.isDone()); + } + + @Test + void longerData(Http2TestClient client) throws ExecutionException, InterruptedException, TimeoutException { + Http2TestConnection h2conn = client.createConnection(); + + assertFalse(consumeExceptionFuture.isDone()); + assertFalse(sendExceptionFuture.isDone()); + + var headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 2); + h2conn.request(1, POST, "/", headers, BufferData.create("frank")); + + h2conn.assertSettings(TIMEOUT); + h2conn.assertWindowsUpdate(0, TIMEOUT); + h2conn.assertSettings(TIMEOUT); + + h2conn.assertRstStream(1, TIMEOUT); + h2conn.assertGoAway(Http2ErrorCode.ENHANCE_YOUR_CALM, + "Request data length doesn't correspond to the content-length header.", + TIMEOUT); + + // content length discrepancy is discovered when consuming request data + var e = consumeExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(RequestException.class)); + assertThat(e.getMessage(), is("Stream is closed.")); + + // stream is closed, sending is not possible + e = sendExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(IllegalStateException.class)); + assertThat(e.getMessage(), is("Stream is already closed.")); + } + + @Test + void longerDataSecondStream(Http2TestClient client) throws ExecutionException, InterruptedException, TimeoutException { + Http2TestConnection h2conn = client.createConnection(); + + // First send payload with proper data length + var headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 5); + h2conn.request(1, POST, "/", headers, BufferData.create("frank")); + + h2conn.assertSettings(TIMEOUT); + h2conn.assertWindowsUpdate(0, TIMEOUT); + h2conn.assertSettings(TIMEOUT); + + h2conn.assertNextFrame(Http2FrameType.HEADERS, TIMEOUT); + h2conn.assertNextFrame(Http2FrameType.DATA, TIMEOUT); + + assertFalse(consumeExceptionFuture.isDone()); + assertFalse(sendExceptionFuture.isDone()); + + // Now send payload larger than advertised data length + headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 2); + h2conn.request(3, POST, "/", headers, BufferData.create("frank")); + + h2conn.assertRstStream(3, TIMEOUT); + h2conn.assertGoAway(Http2ErrorCode.ENHANCE_YOUR_CALM, + "Request data length doesn't correspond to the content-length header.", + TIMEOUT); + + // content length discrepancy is discovered when consuming request data + var e = consumeExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(RequestException.class)); + assertThat(e.getMessage(), is("Stream is closed.")); + + // stream is closed, sending is not possible + e = sendExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(IllegalStateException.class)); + assertThat(e.getMessage(), is("Stream is already closed.")); + } +} diff --git a/webserver/tests/http2/src/test/resources/logging-test.properties b/webserver/tests/http2/src/test/resources/logging-test.properties index fa0e37bbca3..1fca2c71871 100644 --- a/webserver/tests/http2/src/test/resources/logging-test.properties +++ b/webserver/tests/http2/src/test/resources/logging-test.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2022, 2023 Oracle and/or its affiliates. +# Copyright (c) 2022, 2024 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,3 +20,7 @@ java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$ # Global logging level. Can be overridden by specific loggers .level=INFO io.helidon.webserver.level=INFO + +#io.helidon.http.http2.level=FINEST +#io.helidon.http.http2.FlowControl.ifc.level=FINEST +#io.helidon.http.http2.FlowControl.ofc.level=FINEST \ No newline at end of file From b5759b3ef2a02041e6063e824f8668c96dcb2906 Mon Sep 17 00:00:00 2001 From: Thibault Vallin Date: Wed, 27 Nov 2024 23:22:32 +0100 Subject: [PATCH 23/36] Fix Jbatch typo (#9537) --- docs/src/main/asciidoc/mp/guides/jbatch.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/mp/guides/jbatch.adoc b/docs/src/main/asciidoc/mp/guides/jbatch.adoc index 113b6affb77..d359321f522 100644 --- a/docs/src/main/asciidoc/mp/guides/jbatch.adoc +++ b/docs/src/main/asciidoc/mp/guides/jbatch.adoc @@ -40,7 +40,7 @@ For this example, add the IBM JBatch implementation and the `derby` embedded DB ---- - com.imb.jbatch + com.ibm.jbatch com.ibm.jbatch.container From f192013a07ea2e8a7d3b7c54af03857d98571947 Mon Sep 17 00:00:00 2001 From: Andrei Arlou Date: Thu, 28 Nov 2024 01:32:52 +0200 Subject: [PATCH 24/36] 4.x: Remove duplicated methods from CustomScalars (#9474) --- .../graphql/server/CustomScalars.java | 40 ++----------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/microprofile/graphql/server/src/main/java/io/helidon/microprofile/graphql/server/CustomScalars.java b/microprofile/graphql/server/src/main/java/io/helidon/microprofile/graphql/server/CustomScalars.java index cecc7ec53ae..903ecc63b00 100644 --- a/microprofile/graphql/server/src/main/java/io/helidon/microprofile/graphql/server/CustomScalars.java +++ b/microprofile/graphql/server/src/main/java/io/helidon/microprofile/graphql/server/CustomScalars.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -103,13 +103,13 @@ private CustomScalars() { * An instance of a custom offset date/time scalar (with default formatting). */ static final GraphQLScalarType CUSTOM_OFFSET_DATE_TIME_SCALAR = - newOffsetDateTimeScalar(FORMATTED_OFFSET_DATETIME_SCALAR); + newDateTimeScalar(FORMATTED_OFFSET_DATETIME_SCALAR); /** * An instance of a custom offset date/time scalar (with default formatting). */ static final GraphQLScalarType CUSTOM_ZONED_DATE_TIME_SCALAR = - newZonedDateTimeScalar(FORMATTED_ZONED_DATETIME_SCALAR); + newDateTimeScalar(FORMATTED_ZONED_DATETIME_SCALAR); /** * An instance of a custom time scalar (with default formatting). @@ -137,40 +137,6 @@ static GraphQLScalarType newDateTimeScalar(String name) { .build(); } - /** - * Return a new custom offset date/time scalar. - * - * @param name the name of the scalar - * @return a new custom date/time scalar - */ - @SuppressWarnings("unchecked") - static GraphQLScalarType newOffsetDateTimeScalar(String name) { - GraphQLScalarType originalScalar = ExtendedScalars.DateTime; - - return GraphQLScalarType.newScalar() - .coercing(new DateTimeCoercing()) - .name(name) - .description("Custom: " + originalScalar.getDescription()) - .build(); - } - - /** - * Return a new custom zoned date/time scalar. - * - * @param name the name of the scalar - * @return a new custom date/time scalar - */ - @SuppressWarnings("unchecked") - static GraphQLScalarType newZonedDateTimeScalar(String name) { - GraphQLScalarType originalScalar = ExtendedScalars.DateTime; - - return GraphQLScalarType.newScalar() - .coercing(new DateTimeCoercing()) - .name(name) - .description("Custom: " + originalScalar.getDescription()) - .build(); - } - /** * Return a new custom time scalar. * From 5c2cca8654d71cbaa33cb958f0f22f234f169852 Mon Sep 17 00:00:00 2001 From: Andrei Arlou Date: Thu, 28 Nov 2024 01:33:31 +0200 Subject: [PATCH 25/36] 4.x: Replace manual casts on pattern with instanceof in yaml config modules (#9427) --- .../config/yaml/mp/YamlMpConfigSource.java | 10 +++++----- .../helidon/config/yaml/YamlConfigParser.java | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/config/yaml-mp/src/main/java/io/helidon/config/yaml/mp/YamlMpConfigSource.java b/config/yaml-mp/src/main/java/io/helidon/config/yaml/mp/YamlMpConfigSource.java index 34f95df0e5e..0e24a2b968e 100644 --- a/config/yaml-mp/src/main/java/io/helidon/config/yaml/mp/YamlMpConfigSource.java +++ b/config/yaml-mp/src/main/java/io/helidon/config/yaml/mp/YamlMpConfigSource.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -315,10 +315,10 @@ private static void process(Map resultMap, String prefix, List y private static void processNext(Map resultMap, String prefix, Object value) { - if (value instanceof List) { - process(resultMap, prefix, (List) value); - } else if (value instanceof Map) { - process(resultMap, prefix, (Map) value); + if (value instanceof List listValue) { + process(resultMap, prefix, listValue); + } else if (value instanceof Map mapValue) { + process(resultMap, prefix, mapValue); } else { String stringValue = (null == value) ? "" : value.toString(); resultMap.put(prefix, stringValue); diff --git a/config/yaml/src/main/java/io/helidon/config/yaml/YamlConfigParser.java b/config/yaml/src/main/java/io/helidon/config/yaml/YamlConfigParser.java index 47312d1b5ad..ed4aa6b1501 100644 --- a/config/yaml/src/main/java/io/helidon/config/yaml/YamlConfigParser.java +++ b/config/yaml/src/main/java/io/helidon/config/yaml/YamlConfigParser.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2023 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -123,10 +123,10 @@ private static ObjectNode fromMap(Map map) { if (map != null) { map.forEach((k, v) -> { String strKey = k.toString(); - if (v instanceof List) { - builder.addList(strKey, fromList((List) v)); - } else if (v instanceof Map) { - builder.addObject(strKey, fromMap((Map) v)); + if (v instanceof List listValue) { + builder.addList(strKey, fromList(listValue)); + } else if (v instanceof Map mapValue) { + builder.addObject(strKey, fromMap(mapValue)); } else { String strValue = v == null ? "" : v.toString(); builder.addValue(strKey, strValue); @@ -139,10 +139,10 @@ private static ObjectNode fromMap(Map map) { private static ListNode fromList(List list) { ListNode.Builder builder = ListNode.builder(); list.forEach(value -> { - if (value instanceof List) { - builder.addList(fromList((List) value)); - } else if (value instanceof Map) { - builder.addObject(fromMap((Map) value)); + if (value instanceof List listValue) { + builder.addList(fromList(listValue)); + } else if (value instanceof Map mapValue) { + builder.addObject(fromMap(mapValue)); } else { String strValue = value == null ? "" : value.toString(); builder.addValue(strValue); From 42f8ce67a34fd6dc447425bdf4c13974906ebc24 Mon Sep 17 00:00:00 2001 From: Tim Quinn Date: Mon, 2 Dec 2024 14:27:39 -0600 Subject: [PATCH 26/36] Add MP metrics annotation support to MP REST clients (#9534) * Add MP metrics annotation support to MP REST clients --- bom/pom.xml | 5 + docs/src/main/asciidoc/includes/pages.adoc | 2 +- docs/src/main/asciidoc/mp/introduction.adoc | 4 +- .../main/asciidoc/mp/jaxrs/jaxrs-client.adoc | 2 +- .../mp/{ => restclient}/restclient.adoc | 22 +- .../mp/restclient/restclientmetrics.adoc | 194 ++++++++ docs/src/main/asciidoc/sitegen.yaml | 7 +- .../restclient/RestclientMetricsSnippets.java | 129 +++++ .../{ => restclient}/RestclientSnippets.java | 0 .../bundles/helidon-microprofile/pom.xml | 4 + microprofile/pom.xml | 1 + microprofile/rest-client-metrics/pom.xml | 131 ++++++ .../RestClientMetricsAutoDiscoverable.java | 40 ++ .../RestClientMetricsCdiExtension.java | 441 ++++++++++++++++++ .../RestClientMetricsClientListener.java | 86 ++++ .../RestClientMetricsConfigBlueprint.java | 41 ++ .../RestClientMetricsFilter.java | 68 +++ .../restclientmetrics/package-info.java | 19 + .../src/main/java/module-info.java | 59 +++ .../restclientmetrics/MetricIdMatcher.java | 48 ++ .../restclientmetrics/TestClient.java | 36 ++ .../restclientmetrics/TestScanning.java | 308 ++++++++++++ .../restclientmetrics/TestService.java | 53 +++ 23 files changed, 1685 insertions(+), 15 deletions(-) rename docs/src/main/asciidoc/mp/{ => restclient}/restclient.adoc (90%) create mode 100644 docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc create mode 100644 docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java rename docs/src/main/java/io/helidon/docs/mp/{ => restclient}/RestclientSnippets.java (100%) create mode 100644 microprofile/rest-client-metrics/pom.xml create mode 100644 microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java create mode 100644 microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java create mode 100644 microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java create mode 100644 microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java create mode 100644 microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java create mode 100644 microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java create mode 100644 microprofile/rest-client-metrics/src/main/java/module-info.java create mode 100644 microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java create mode 100644 microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java create mode 100644 microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java create mode 100644 microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java diff --git a/bom/pom.xml b/bom/pom.xml index 60d895a39ed..3db507a7651 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -738,6 +738,11 @@ helidon-microprofile-rest-client ${helidon.version} + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + ${helidon.version} + diff --git a/docs/src/main/asciidoc/includes/pages.adoc b/docs/src/main/asciidoc/includes/pages.adoc index 71a081a3ec0..d90a6ed13f3 100644 --- a/docs/src/main/asciidoc/includes/pages.adoc +++ b/docs/src/main/asciidoc/includes/pages.adoc @@ -27,5 +27,5 @@ ifdef::mp-flavor[] endif::[] :webclient-page: {rootdir}/se/webclient.adoc -:restclient-page: {rootdir}/mp/restclient.adoc +:restclient-page: {rootdir}/mp/restclient/restclient.adoc :cli-page: {rootdir}/about/cli.adoc diff --git a/docs/src/main/asciidoc/mp/introduction.adoc b/docs/src/main/asciidoc/mp/introduction.adoc index 383f49cf176..4016861e58c 100644 --- a/docs/src/main/asciidoc/mp/introduction.adoc +++ b/docs/src/main/asciidoc/mp/introduction.adoc @@ -1,6 +1,6 @@ /////////////////////////////////////////////////////////////////////////////// - Copyright (c) 2019, 2023 Oracle and/or its affiliates. + Copyright (c) 2019, 2024 Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -121,7 +121,7 @@ overhead server built on Java virtual threads. | link:{microprofile-rs-operators-spec-url}[{version-lib-microprofile-rs-operators-api}] | Control flow and error processing for event streams -| xref:{rootdir}/mp/restclient.adoc[MicroProfile REST Client] +| xref:{rootdir}/mp/restclient/restclient.adoc[MicroProfile REST Client] | link:{microprofile-rest-client-spec-url}[{version-lib-microprofile-rest-client}] | Type-safe API for RESTful Web Services diff --git a/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc b/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc index 752a59460bd..ec0fa91f3cd 100644 --- a/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc +++ b/docs/src/main/asciidoc/mp/jaxrs/jaxrs-client.adoc @@ -39,7 +39,7 @@ include::{rootdir}/includes/mp.adoc[] The Jakarta REST Client defines a programmatic API to access REST resources. This API sits at a higher level than traditional HTTP client APIs and provides full integration with server-side API concepts like providers. It differs -from the xref:../restclient.adoc[Rest Client API] in that it does not support +from the xref:../restclient/restclient.adoc[Rest Client API] in that it does not support annotations or proxies, but instead uses builders and a fluent API to create and execute requests. diff --git a/docs/src/main/asciidoc/mp/restclient.adoc b/docs/src/main/asciidoc/mp/restclient/restclient.adoc similarity index 90% rename from docs/src/main/asciidoc/mp/restclient.adoc rename to docs/src/main/asciidoc/mp/restclient/restclient.adoc index 63f6e6a7244..abafcbf7e48 100644 --- a/docs/src/main/asciidoc/mp/restclient.adoc +++ b/docs/src/main/asciidoc/mp/restclient/restclient.adoc @@ -21,7 +21,7 @@ :feature-name: MicroProfile Rest Client :microprofile-bundle: true :keywords: helidon, rest, client, microprofile, micro-profile -:rootdir: {docdir}/.. +:rootdir: {docdir}/../.. include::{rootdir}/includes/mp.adoc[] @@ -43,8 +43,11 @@ Helidon will automatically create a _proxy_ class for the interface and map loca For more information, see link:{microprofile-rest-client-spec-url}[Rest Client For MicroProfile Specification]. +You can also use metrics annotations on your Rest Client methods as described in xref:restclientmetrics.adoc[this related page.] + include::{rootdir}/includes/dependencies.adoc[] +// tag::helidon-restclient-dep[] [source,xml] ---- @@ -52,7 +55,7 @@ include::{rootdir}/includes/dependencies.adoc[] helidon-microprofile-rest-client ---- - +// end::helidon-restclient-dep[] == API [cols="1,2"] |=== @@ -86,7 +89,7 @@ the provided configuration. [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_1, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_1, indent=0] ---- The `RestClientBuilder` interface extends the `Configurable` interface from Jakarta REST (JAX-RS), @@ -95,7 +98,7 @@ enabling direct registration of _providers_ such as filters, param converters, e [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_2, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_2, indent=0] ---- === Creating a New Client Using CDI @@ -107,7 +110,7 @@ to access the service. [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_3, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_3, indent=0] ---- Any Jakarta REST (JAX-RS) providers for a client can be registered using the (repeatable) @@ -116,7 +119,7 @@ Any Jakarta REST (JAX-RS) providers for a client can be registered using the (re [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_4, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_4, indent=0] ---- Once a client interface is annotated, it can be injected into any CDI bean. @@ -126,7 +129,7 @@ All properties in annotation `RegisterRestClient` can be overridden via configur [source,java] .Example ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_5, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_5, indent=0] ---- == Configuration @@ -189,14 +192,15 @@ Configuration options affecting CDI and programmatically created clients: |=== == Examples -To be able to run and test this example, use the xref:guides/quickstart.adoc[Helidon MP examples/quickstarts]. +To be able to run and test this example, use the xref:../guides/quickstart.adoc[Helidon MP examples/quickstarts]. Add a dependency on the Helidon Rest Client implementation and create the following client interface: [source,java] .client interface ---- -include::{sourcedir}/mp/RestclientSnippets.java[tag=snippet_6, indent=0] +include::{sourcedir}/mp/restclient/RestclientSnippets.java[tag=snippet_6, indent=0] ---- +[[example-after-client-interface]] Then create a runnable method as described in <>, but with baseUri `http://localhost:8080/greet` and the above interface. diff --git a/docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc b/docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc new file mode 100644 index 00000000000..7095428f1fa --- /dev/null +++ b/docs/src/main/asciidoc/mp/restclient/restclientmetrics.adoc @@ -0,0 +1,194 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2024 Oracle and/or its affiliates. + + Licensed 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 + + http://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. + +/////////////////////////////////////////////////////////////////////////////// + += Rest Client Metrics +:description: Helidon MP Rest Client Metrics +:feature-name: MicroProfile Rest Client Metrics +:microprofile-bundle: true +:keywords: helidon, rest, client, microprofile, micro-profile, metrics +:rootdir: {docdir}/../.. + +include::{rootdir}/includes/mp.adoc[] + +== Contents + +- <> +- <> +- <> +- <> +- <> +- <> + +== Overview +Helidon supports MicroProfile REST Client metrics by registering metrics automatically when developers add MicroProfile Metrics annotations to REST client interfaces and methods. + +MicroProfile neither mandates nor specifies how metrics and the REST client work together. Support in Helidon for metrics on REST clients uses the MicroProfile Metrics spec for inspiration where appropriate. + +For more information about support for REST clients in Helidon see xref:restclient.adoc[REST Client]. + +include::{rootdir}/includes/dependencies.adoc[] + +// tag::helidon-restclientmetrics-dep[] +[source,xml] +---- + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + +---- +// end::helidon-restclientmetrics-dep[] + +== Usage +Add the MicroProfile Metrics `@Counted` and `@Timed` annotations to REST client interfaces and interface methods to trigger counting or timing, respectively, of REST client method invocations. + +Helidon determines metric names according to the link:https://download.eclipse.org/microprofile/microprofile-metrics-5.1.1/microprofile-metrics-spec-5.1.1.html#annotated-naming-convention[MicroProfile Metrics naming convention] and supports the following metrics naming features: + +* absolute and relative names +* explicit and inferred names +* type-level annotations + +When you place annotations at the type level of a REST client interface Helidon registers _different_ metrics for each of the REST methods on the interface. This is the same behavior as in normal MicroProfile Metrics when you add metrics annotations at the type level. + +When you use the annotations at the type level on a superinterface Helidon acts as if those annotations appear at the type-level of any REST client subinterface which extends the superinterface. In keeping with the naming conventions enforced by the MicroProfile Metrics TCK, relative metric names use the _subinterface_ name not the declaring interface name. + +(Note that the +MicroProfile Metrics specification states that the _declaring_ class name is used, while as written the MicroProfile Metrics TCK requires that implementations use the _subclass_ name. For consistency the Helidon REST client metrics implementation follows the enforced metrics TCK behavior.) + +=== Understanding How and When Helidon Registers REST Client Metrics + +Helidon registers the metrics associated with a REST client interface when that interface becomes known to Helidon as a REST client. + +The link:https://download.eclipse.org/microprofile/microprofile-rest-client-3.0/microprofile-rest-client-spec-3.0.html#_microprofile_rest_client[MicroProfile REST Client spec] describes how your application can inject a REST client interface or prepare it programmatically. Either action makes the REST client known to Helidon, at which time Helidon registers the metrics associated with that interface's methods. As a result, depending on how your application works, REST client metrics might be registered well after your code initially starts up. + +=== Using REST Client Metrics in Standalone Clients vs. in Servers +Helidon registers and updates REST client metrics whether the REST client is standalone or is embedded in a Helidon server. + +Helidon _does not_ provide a `/metrics` endpoint for standalone clients, nor does it provide any built-in way to transmit metrics data from a client to a backend system. If needed, you can write your client code to access the application `MetricRegistry` and retrieve the REST client metrics Helidon has registered. + +In contrast, when REST clients run inside Helidon servers the REST client metrics for REST clients known to Helidon appear in the `/metrics` output. + +=== Turning on Logging +Set `io.helidon.microprofile.restclientmetrics.level=DEBUG` in your logging settings to see some of the inner workings of the REST client metrics implementation. + +During start-up the logging reports analysis of candidate REST client interfaces and the creation of metric registration entries, including the metric annotation and where Helidon found each. + +When a REST client is made known to Helidon the logging reports the actual registration of the metrics derived from that REST client interface. + +== API +Use the following annotations from `org.eclipse.microprofile.metrics.annotation` listed in the following table to trigger REST client metrics. +[cols="1,2"] +|=== +| Annotation | Description +| `@Counted` | Counts the invocations of a REST client method. +| `@Timed` | Times the invocations of a REST client method. +|=== +Type-level annotations trigger registration of separate metrics for each REST client method in the REST client interface. + +== Configuration +Optional configuration options: +[cols="3,3,2,5"] + +|=== +|key |type |default value |description + +|`enabled` | string | `true` | Whether to use REST client metrics. +|=== +The `enabled` configuration setting allows developers to build REST client metrics into an application while permitting end users to disable the feature at their discretion. + +== Examples +This example is similar to the xref:restclient.adoc#_examples[Helidon REST Client doc example] which starts with the xref:../guides/quickstart.adoc[Helidon MP QuickStart example]. + +This sample app adds a new resource which mimics the functionality of the `GreetResource` but delegates each incoming request to its counterpart on the `GreetResource` using a REST client interface for that `GreetResource`. In short, the example application delegates to itself. Of course no production application would operate this way, but this contrived situation helps illustrate how to use REST client metrics simply with a single runnable project. + +To create this REST client metrics example follow these steps. + +1. Starting with the Helidon MP QuickStart example, add dependencies for both the Helidon REST client component and the Helidon REST client metrics component, as shown below. ++ +include::restclient.adoc[tag=helidon-restclient-dep] ++ +include::restclientmetrics.adoc[tag=helidon-restclientmetrics-dep] +2. Add the following REST client interface which includes MicroProfile Metrics annotations to count and time various REST client method invocations. ++ +[source,java] +---- +include::{sourcedir}/mp/restclient/RestclientMetricsSnippets.java[tag=snippet_1, indent=0] +---- +<1> Times all outbound method invocations using separate timers for each method. +<2> Counts the number of times a request is sent to get the default greeting message. +3. Add a new resource class, similar to the `GreetService` resource class, but which delegates all incoming requests using the REST client. ++ +[source,java] +---- +include::{sourcedir}/mp/restclient/RestclientMetricsSnippets.java[tag=snippet_2, indent=0] +---- +<1> Holds the prepared REST client for use by the delegating methods. +<2> Prepares the REST client. The example shows only one of many ways of doing this step. +<3> Each delegating method invokes the corresponding REST client method and returns the result from it. ++ +By default, resource classes such as `DelegatingResource` are instantiated for each incoming request, but generally a Helidon server making outbound requests reuses the client data structures and connections. To create and reuse only a single REST client instance this example resource uses the Helidon `LazyValue` utility class so even as the system creates multiple instances of `DelegatingResource` they all reuse the same REST client. +4. Build and run the application. ++ +[source,bash] +---- +mvn clean package +java -jar target/helidon-quickstart-mp.jar +---- +5. Access the delegating endpoints. ++ +[source,bash] +---- +curl http://localhost:8080/delegate +curl http://localhost:8080/delegate +curl http://localhost:8080/delegate/Joe +---- +6. Retrieve the application metrics for the `getDefaultMessage` operation. ++ +[source,bash] +---- +curl 'http://localhost:8080/metrics?scope=application' | grep getDefault +---- +7. Look for two types of metrics: + a. Counter: ++ +[source,list] +---- +# TYPE io_helidon_examples_quickstart_mp_GreetRestClient_getDefaultMessage_total counter +io_helidon_examples_quickstart_mp_GreetRestClient_getDefaultMessage_total{mp_scope="application",} 2.0 +---- ++ +This is the counter resulting from the `@Counted` annotation on the `getDefaultMessage` method of the REST client interface. The name is relative to the annotated method's class and is automatically set to the method name because neither `name` nor `absolute` were specified with the annotation. +b. Timer: ++ +[source,list] +---- +# TYPE timedGreet_getDefaultMessage_seconds summary +timedGreet_getDefaultMessage_seconds{mp_scope="application",quantile="0.5",} 0.003407872 +timedGreet_getDefaultMessage_seconds{mp_scope="application",quantile="0.75",} 0.092143616 +timedGreet_getDefaultMessage_seconds_count{mp_scope="application",} 2.0 +---- ++ +This excerpt shows the output for only one timer, but the full output includes timers for each method. ++ +The `@Timed` annotation at the type level triggers the registration of timers for each REST method in the REST client interface. The `name` setting overrides the default of the type name, and the `absolute` setting means the selected name _is not_ relative to the fully-qualified class name. + +== Reference + +* xref:restclient.adoc[Helidon REST Client documentation] +* link:{microprofile-rest-client-spec-url}[MicroProfile RestClient specification] +* link:{microprofile-metrics-spec-url}[MicroProfile Metrics specification] + diff --git a/docs/src/main/asciidoc/sitegen.yaml b/docs/src/main/asciidoc/sitegen.yaml index 35f50b37b33..6eba3acd532 100644 --- a/docs/src/main/asciidoc/sitegen.yaml +++ b/docs/src/main/asciidoc/sitegen.yaml @@ -234,12 +234,15 @@ backend: sources: - "engine.adoc" - "rsoperators.adoc" - - type: "PAGE" + - type: "MENU" title: "REST Client" - source: "restclient.adoc" + dir: "restclient" glyph: type: "icon" value: "airplay" + sources: + - "restclient.adoc" + - "restclientmetrics.adoc" - type: "PAGE" title: "Scheduling" source: "scheduling.adoc" diff --git a/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java b/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java new file mode 100644 index 00000000000..46574e1322d --- /dev/null +++ b/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientMetricsSnippets.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.docs.mp; + +import java.net.URI; + +import io.helidon.common.LazyValue; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; +import org.eclipse.microprofile.rest.client.RestClientBuilder; + +import static io.helidon.docs.mp.RestclientMetricsSnippets.Snippet1.GreetRestClient; + +@SuppressWarnings("ALL") +class RestclientMetricsSnippets { + + // stub + static interface GreetingMessage { } + + + class Snippet1 { + + // tag::snippet_1[] + @Path("/greet") + @Timed(name = "timedGreet", absolute = true) // <1> + public interface GreetRestClient { + + @Counted // <2> + @GET + @Produces(MediaType.APPLICATION_JSON) + GreetingMessage getDefaultMessage(); + + @Path("/{name}") + @GET + @Produces(MediaType.APPLICATION_JSON) + GreetingMessage getMessage(@PathParam("name") String name); + + @Path("/greeting") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + Response updateGreeting(GreetingMessage message); + } + // end::snippet_1[] + } + + class Snippet2 { + + // tag::snippet_2[] + @Path("/delegate") + public class DelegatingResource { + + private static LazyValue greetRestClient = LazyValue.create(DelegatingResource::prepareClient); // <1> + + /** + * Return a worldly greeting message. + * + * @return {@link GreetingMessage} + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public GreetingMessage getDefaultMessage() { + return greetRestClient.get().getDefaultMessage(); // <2> + } + + /** + * Return a greeting message using the name that was provided. + * + * @param name the name to greet + * @return {@link GreetingMessage} + */ + @Path("/{name}") + @GET + @Produces(MediaType.APPLICATION_JSON) + public GreetingMessage getMessage(@PathParam("name") String name) { + return greetRestClient.get().getMessage(name); + } + + /** + * Set the greeting to use in future messages. + * + * @param message JSON containing the new greeting + * @return {@link jakarta.ws.rs.core.Response} + */ + @Path("/greeting") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response updateGreeting(GreetingMessage message) { + return greetRestClient.get().updateGreeting(message); + } + + private static GreetRestClient prepareClient() { // <3> + Config config = ConfigProvider.getConfig(); + String serverHost = config.getOptionalValue("server.host", String.class).orElse("localhost"); + String serverPort = config.getOptionalValue("server.port", String.class).orElse("8080"); + return RestClientBuilder.newBuilder() + .baseUri(URI.create("http://" + serverHost + ":" + serverPort)) + .build(GreetRestClient.class); + } + } + // end::snippet_2[] + } + +} diff --git a/docs/src/main/java/io/helidon/docs/mp/RestclientSnippets.java b/docs/src/main/java/io/helidon/docs/mp/restclient/RestclientSnippets.java similarity index 100% rename from docs/src/main/java/io/helidon/docs/mp/RestclientSnippets.java rename to docs/src/main/java/io/helidon/docs/mp/restclient/RestclientSnippets.java diff --git a/microprofile/bundles/helidon-microprofile/pom.xml b/microprofile/bundles/helidon-microprofile/pom.xml index bd1ba22548e..396e9962cfe 100644 --- a/microprofile/bundles/helidon-microprofile/pom.xml +++ b/microprofile/bundles/helidon-microprofile/pom.xml @@ -70,6 +70,10 @@ io.helidon.microprofile.rest-client helidon-microprofile-rest-client + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + org.glassfish.jersey.media jersey-media-json-binding diff --git a/microprofile/pom.xml b/microprofile/pom.xml index 5f157a3c6d8..b8ad67b5259 100644 --- a/microprofile/pom.xml +++ b/microprofile/pom.xml @@ -59,6 +59,7 @@ service-common telemetry testing + rest-client-metrics diff --git a/microprofile/rest-client-metrics/pom.xml b/microprofile/rest-client-metrics/pom.xml new file mode 100644 index 00000000000..c2bc205d2d5 --- /dev/null +++ b/microprofile/rest-client-metrics/pom.xml @@ -0,0 +1,131 @@ + + + + 4.0.0 + + io.helidon.microprofile + helidon-microprofile-project + 4.2.0-SNAPSHOT + + + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + + Helidon Microprofile REST Client Metrics + + + Support for MicroProfile Metrics in the REST client + + + + + org.eclipse.microprofile.metrics + microprofile-metrics-api + + + io.helidon.microprofile.metrics + helidon-microprofile-metrics + + + io.helidon.common + helidon-common-config + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + jakarta.ws.rs + jakarta.ws.rs-api + provided + + + io.helidon.common + helidon-common-context + + + org.glassfish.jersey.core + jersey-common + + + io.helidon.common.features + helidon-common-features-api + true + + + io.helidon.microprofile.rest-client + helidon-microprofile-rest-client + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + io.helidon.microprofile.testing + helidon-microprofile-testing-junit5 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + + diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java new file mode 100644 index 00000000000..b9ec0eb31a7 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsAutoDiscoverable.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import jakarta.ws.rs.ConstrainedTo; +import jakarta.ws.rs.RuntimeType; +import jakarta.ws.rs.core.FeatureContext; +import org.glassfish.jersey.internal.spi.AutoDiscoverable; + +/** + * Autodiscoverable to register the filter. + */ +@ConstrainedTo(RuntimeType.SERVER) +public class RestClientMetricsAutoDiscoverable implements AutoDiscoverable { + + /** + * For service loading. + */ + @Deprecated + public RestClientMetricsAutoDiscoverable() { + } + + @Override + public void configure(FeatureContext featureContext) { + featureContext.register(RestClientMetricsFilter.class); + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java new file mode 100644 index 00000000000..12025f410fd --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Initialized; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.spi.AfterBeanDiscovery; +import jakarta.enterprise.inject.spi.AnnotatedMethod; +import jakarta.enterprise.inject.spi.AnnotatedType; +import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; +import jakarta.enterprise.inject.spi.Extension; +import jakarta.enterprise.inject.spi.ProcessAnnotatedType; +import jakarta.enterprise.inject.spi.WithAnnotations; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.client.ClientRequestContext; +import org.eclipse.microprofile.metrics.Counter; +import org.eclipse.microprofile.metrics.Metadata; +import org.eclipse.microprofile.metrics.Metric; +import org.eclipse.microprofile.metrics.MetricRegistry; +import org.eclipse.microprofile.metrics.Tag; +import org.eclipse.microprofile.metrics.Timer; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; + +import static java.lang.System.Logger.Level.DEBUG; + +/** + * CDI extension for REST client metrics support. + */ +public class RestClientMetricsCdiExtension implements Extension { + + private static final System.Logger LOGGER = System.getLogger(RestClientMetricsCdiExtension.class.getName()); + + private static final String SAVED_START_TIME_PROPERTY_NAME = RestClientMetricsFilter.class.getName() + ".startTime"; + + private static final List> REST_METHOD_ANNOTATIONS = List.of(OPTIONS.class, + HEAD.class, + GET.class, + POST.class, + PUT.class, + DELETE.class); + + private final Set> candidateRestClientTypes = new HashSet<>(); + + private final Map> metricsUpdateWorkByMethod = new HashMap<>(); + + private final Map, Map>> registrations = new HashMap<>(); + + private MetricRegistry metricRegistry; + + /** + * For service loading. + */ + public RestClientMetricsCdiExtension() { + } + + void checkForMpMetrics(@Observes BeforeBeanDiscovery bbd) { + + } + + void recordRestClientTypes(@Observes @WithAnnotations({OPTIONS.class, + HEAD.class, + GET.class, + POST.class, + PUT.class, + DELETE.class, + PATCH.class}) ProcessAnnotatedType pat) { + if (pat.getAnnotatedType().getJavaClass().isInterface()) { + /* + All REST client declarations are interfaces, so at least this annotated type has a chance of being a REST client. + At this stage in processing simply record all classes with at least one method bearing a REST annotation. + */ + Class javaType = pat.getAnnotatedType().getJavaClass(); + candidateRestClientTypes.add(javaType); + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Recording " + javaType.getCanonicalName() + " for REST client processing"); + } + } + } + + void prepareMetricRegistrations(@Observes AfterBeanDiscovery abd) { + // For each identified candidate REST client type determine what metric registration(s) are needed for its methods + // and prepare the pre-invoke and post-invoke operations as needed for each to update the metrics needed for each method. + // We do not actually register the metrics yet; instead we want until the REST client infrastructure informs us type + // by type that an interface is being used as a REST client interface. + + candidateRestClientTypes.forEach(type -> { + + LOGGER.log(DEBUG, "Analyzing candidate REST client interface " + type.getCanonicalName()); + + // Earlier we collected all interfaces with REST annotations. For each of those compute the type closure and, for the + // type itself and all types in its closure: + // * capture any type-level REST annotations - these will apply to all REST methods on the interface being processed + // * for each REST method on the interface or on a type in its type closure use the method-level REST annotations + // and any type-level REST annotations to prepare metric registrations. + + Set typeLevelMetricAnnotationsOverTypeClosure = + StreamSupport.stream(abd.getAnnotatedTypes(type).spliterator(), false) + .flatMap(at -> at.getTypeClosure() + .stream()) // The CDI-provided type closure includes the type itself. + .filter(t -> t instanceof Class) + .map(t -> (Class) t) + .filter(candidateRestClientTypes::contains) + .flatMap(t -> StreamSupport.stream(abd.getAnnotatedTypes(t).spliterator(), false)) + .flatMap(at -> Stream.of(Timed.class, Counted.class) + .map(at::getAnnotation) + .filter(Objects::nonNull)) + .collect(Collectors.toSet()); + + Map> registrationsByMethodForType = new HashMap<>(); + + // We need to get the AnnotatedType for the type of interest, but without knowing the ID with which it was added + // to CDI we cannot retrieve just that one directly. Instead retrieve all annotated types for the type (most likely + // there will be just one). + StreamSupport.stream(abd.getAnnotatedTypes(type).spliterator(), false) + .flatMap(at -> at.getTypeClosure().stream()) // The CDI-provided type closure includes the type itself. + .filter(t -> t instanceof Class) + .map(t -> (Class) t) + .filter(candidateRestClientTypes::contains) + .forEach(typeInClosure -> StreamSupport.stream(abd.getAnnotatedTypes(typeInClosure).spliterator(), + false) + .forEach(annotatedTypeInClosure -> { + LOGGER.log(DEBUG, + "Examining type " + annotatedTypeInClosure.getJavaClass() + .getCanonicalName()); + + annotatedTypeInClosure.getMethods().stream() + .filter(RestClientMetricsCdiExtension::hasRestAnnotation) + .forEach(annotatedMethod -> { + // Record registrations needed for this method based on + // annotations on it or the types in this type's closure. + Set registrationsForMethod = + registrationsByMethodForType.computeIfAbsent( + annotatedMethod.getJavaMember(), + k -> new HashSet<>()); + Set registrationsFromMethod = + Stream.of(Timed.class, + Counted.class) + .map(annotatedMethod::getAnnotation) + .filter(Objects::nonNull) + .map(anno -> Registration.create( + annotatedTypeInClosure.getJavaClass(), + annotatedMethod, + anno, + false)) + .collect(Collectors.toSet()); + + registrationsForMethod.addAll(registrationsFromMethod); + + LOGGER.log(DEBUG, + "Adding metric registrations for annotations " + + "on method " + annotatedMethod.getJavaMember() + .getDeclaringClass().getCanonicalName() + + "." + annotatedMethod.getJavaMember() + .getName() + ":" + registrationsFromMethod); + + // Record registrations needed for this method based on + // type-level annotations. + + var registrationsFromTypeLevelAnnotations = + typeLevelMetricAnnotationsOverTypeClosure.stream() + .map(anno -> Registration.create( + annotatedTypeInClosure.getJavaClass(), + annotatedMethod, + anno, + true)) + .collect(Collectors.toSet()); + + registrationsForMethod.addAll(registrationsFromTypeLevelAnnotations); + LOGGER.log(DEBUG, + "Adding metric registrations for type-level " + + "annotations " + registrationsFromTypeLevelAnnotations); + }); + registrations.put(type, registrationsByMethodForType); + } + )); + }); + } + + void ready(@Observes @Initialized(ApplicationScoped.class) Object event, + MetricRegistry metricRegistry) { + this.metricRegistry = metricRegistry; + } + + void registerMetricsForRestClient(Class restClient) { + registrations.get(restClient).forEach((method, regs) -> { + List metricsRegisteredForRestClient = LOGGER.isLoggable(DEBUG) ? new ArrayList<>() : null; + regs.forEach(registration -> { + Metric metric = registration.registrationOp.apply(metricRegistry); + if (LOGGER.isLoggable(DEBUG)) { + metricsRegisteredForRestClient.add(metric); + LOGGER.log(DEBUG, String.format("For REST client method %s#%s registering metric using %s", + restClient.getCanonicalName(), + method.getName(), + registration, + metric)); + } + metricsUpdateWorkByMethod.computeIfAbsent(method, k -> new ArrayList<>()) + .add(MetricsUpdateWork.create(metric)); + if (metricsRegisteredForRestClient != null && metricsRegisteredForRestClient.isEmpty()) { + LOGGER.log(DEBUG, "No metrics registered for REST client " + restClient.getCanonicalName()); + } + }); + }); + } + + void doPreWork(Method method, ClientRequestContext context) { + List workItems = metricsUpdateWorkByMethod.get(method); + if (workItems != null) { + workItems.forEach(workItem -> workItem.preWork(context)); + } + } + + void doPostWork(Method method, ClientRequestContext context) { + + List workItems = metricsUpdateWorkByMethod.get(method); + if (workItems != null) { + workItems.forEach(workItem -> workItem.postWork(context)); + } + } + + // For testing. + Map> metricsUpdateWorkByMethod() { + return metricsUpdateWorkByMethod; + } + + private static Tag[] tags(Annotation metricAnnotation) { + return switch (metricAnnotation) { + case Counted counted -> tags(counted.tags()); + case Timed timed -> tags(timed.tags()); + default -> null; + }; + } + + /** + * Converts tag expressions in a metrics annotation to an array of {@link org.eclipse.microprofile.metrics.Tag} for use + * during metric registration. + * + * @param tagExprs tag expressions (tag=value) from the metrics annotation + * @return tag array + */ + private static Tag[] tags(String[] tagExprs) { + return Stream.of(tagExprs) + .map(tagExpr -> { + int eq = tagExpr.indexOf("="); + if (eq <= 0 || eq == tagExpr.length() - 1) { + throw new IllegalArgumentException("Tag expression " + + tagExpr + + " in annotation has missing or misplaced = sign."); + } + return new Tag(tagExpr.substring(0, eq).trim(), tagExpr.substring(eq).trim()); + }) + .toArray(Tag[]::new); + } + + private static boolean hasRestAnnotation(AnnotatedMethod am) { + return am.getAnnotations().stream() + .anyMatch(anno -> REST_METHOD_ANNOTATIONS.contains(anno.annotationType())); + } + + private static String chooseMetricName(Class type, + AnnotatedMethod method, + Annotation metricAnnotation, + boolean isTypeLevel) { + boolean isAbsolute = switch (metricAnnotation) { + case Timed timed -> timed.absolute(); + case Counted counted -> counted.absolute(); + default -> false; + }; + String specifiedName = switch (metricAnnotation) { + case Timed timed -> timed.name(); + case Counted counted -> counted.name(); + default -> ""; + }; + AnnotatedType declaringType = method.getDeclaringType(); + + // The following code mimics the structure of the Annotated Naming Convention tables in the MP Metrics spec document. + return !isTypeLevel + ? // Annotation is at the method level. + isAbsolute + ? ( + specifiedName.isEmpty() + ? method.getJavaMember().getName() + : specifiedName) + : // Non-absolute name at the method level always has the canonical class name as its prefix. + declaringType.getJavaClass().getCanonicalName() + + "." + + ( + specifiedName.isEmpty() + ? method.getJavaMember().getName() + : specifiedName) + + : // Annotation is at the type level. Choose the prefix; the metric name always ends with the method name. + ( + isAbsolute + ? ( + specifiedName.isEmpty() ? type.getSimpleName() + : specifiedName) + : ( + specifiedName.isEmpty() ? declaringType.getJavaClass().getCanonicalName() + : declaringType.getJavaClass().getPackageName() + "." + specifiedName)) + + "." + method.getJavaMember().getName(); + } + + /** + * Metrics update work to be performed by a filter to update a metric, consisting of either of both of pre-work (metrics + * work to be done before the operation is performed) and post-work (metrics work to be done after the operation completes). + * + * @param preWork metrics work to do before the operation runs + * @param postWork metrics work to do after the operation runs + */ + record MetricsUpdateWork(Consumer preWork, Consumer postWork) { + + static MetricsUpdateWork create(Metric metric) { + return switch (metric) { + case Timer timer -> MetricsUpdateWork.create(cctx -> cctx.setProperty(SAVED_START_TIME_PROPERTY_NAME, + System.nanoTime()), + cctx -> { + long startTime = + (Long) cctx.getProperty(SAVED_START_TIME_PROPERTY_NAME); + timer.update(Duration.ofNanos(System.nanoTime() - startTime)); + }); + case Counter counter -> MetricsUpdateWork.create(cctx -> counter.inc()); + default -> null; + }; + } + + void preWork(ClientRequestContext requestContext) { + if (preWork != null) { + preWork.accept(requestContext); + } + } + + void postWork(ClientRequestContext requestContext) { + if (postWork != null) { + postWork.accept(requestContext); + } + } + + private static MetricsUpdateWork create(Consumer preWork, Consumer postWork) { + return new MetricsUpdateWork(preWork, postWork); + } + + private static MetricsUpdateWork create(Consumer preWork) { + return new MetricsUpdateWork(preWork, null); + } + } + + /** + * A future group of metric registrations to be performed if and when the corresponding REST client interface is reated. + * + * @param metricName metric name + * @param metricAnnotation metric annotation which gave rise to this registration + * @param registrationOp function to register the new metric in a metric registry + */ + private record Registration(String metricName, + Tag[] tags, + Metadata metadata, + Annotation metricAnnotation, + Function registrationOp) { + + static Registration create(Class declaringType, + AnnotatedMethod method, + Annotation metricAnnotation, + boolean isTypeLevel) { + Metadata metadata = Metadata.builder() + .withName(chooseMetricName(declaringType, method, metricAnnotation, isTypeLevel)) + .withDescription("REST client " + + ( + metricAnnotation.annotationType().isAssignableFrom(Timed.class) + ? "timer" + : "counter") + + declaringType.getSimpleName() + + "." + + method.getJavaMember().getName()) + .build(); + + Tag[] tagsFromAnnotation = RestClientMetricsCdiExtension.tags(metricAnnotation); + return switch (metricAnnotation) { + case Timed timed -> new Registration(metadata.getName(), + tagsFromAnnotation, + metadata, + timed, + mr -> mr.timer(metadata, tagsFromAnnotation)); + case Counted counted -> new Registration(metadata.getName(), + tagsFromAnnotation, + metadata, + counted, + mr -> mr.counter(metadata, tagsFromAnnotation)); + default -> null; + }; + } + + @Override + public String toString() { + return new StringJoiner(", ", Registration.class.getSimpleName() + "[", "]") + .add("metadata=" + metadata) + .add("tags=" + Arrays.toString(tags)) + .add("metricAnnotation=" + metricAnnotation) + .toString(); + } + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java new file mode 100644 index 00000000000..ebb04a9582f --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsClientListener.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import java.util.HashSet; +import java.util.Set; + +import io.helidon.common.LazyValue; + +import jakarta.enterprise.inject.spi.CDI; +import jakarta.ws.rs.Priorities; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.rest.client.spi.RestClientListener; + +/** + * REST client metrics listener to add our filter for outbound REST clients. + */ +public class RestClientMetricsClientListener implements RestClientListener { + + /* + The listener can be instantiated multiple times, so we delegate the real work to a singleton. + */ + private static final LazyValue LISTENER = LazyValue.create(Listener::new); + + /** + * For service discovery. + */ + public RestClientMetricsClientListener() { + } + + @Override + public void onNewClient(Class serviceInterface, RestClientBuilder builder) { + LISTENER.get().onNewClient(serviceInterface, builder); + } + + private static class Listener { + + private final RestClientMetricsFilter restClientMetricsFilter; + + private final LazyValue restClientMetricsConfig = + LazyValue.create(() -> { + boolean enabled = ConfigProvider.getConfig() + .getOptionalValue(RestClientMetricsFilter.REST_CLIENT_METRICS_CONFIG_KEY + + ".enabled", Boolean.class) + .orElse(true); + return RestClientMetricsConfig.builder() + .enabled(enabled) + .build(); + }); + + private final LazyValue ext = + LazyValue.create(() -> CDI.current().getBeanManager().getExtension(RestClientMetricsCdiExtension.class)); + + private final Set> restClientsDiscovered = new HashSet<>(); + + private Listener() { + restClientMetricsFilter = RestClientMetricsFilter.create(); + } + + private void onNewClient(Class serviceInterface, RestClientBuilder builder) { + if (restClientMetricsConfig.get().enabled()) { + // Users might build multiple REST client builders (and instances) for a given interface, but we + // register metrics (and create metric-related work for the filter to do) only upon first + // discovering a given service interface. + if (restClientsDiscovered.add(serviceInterface)) { + ext.get().registerMetricsForRestClient(serviceInterface); + } + builder.register(restClientMetricsFilter, Priorities.USER - 100); + } + } + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java new file mode 100644 index 00000000000..3991d9b4bf6 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsConfigBlueprint.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Configuration settings for MP REST client metrics. + */ +@Prototype.Blueprint +@Prototype.Configured(RestClientMetricsConfigBlueprint.CONFIG_KEY) +interface RestClientMetricsConfigBlueprint { + + /** + * Root=level config key for REST client metrics settings. + */ + String CONFIG_KEY = "rest-client.metrics"; + + /** + * Whether REST client metrics functionality is enabled. + * + * @return if REST client metrics are configured to be enabled + */ + @Option.Configured + @Option.DefaultBoolean(true) + boolean enabled(); +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java new file mode 100644 index 00000000000..451f6455b36 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsFilter.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import java.lang.reflect.Method; + +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.spi.CDI; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; +import jakarta.ws.rs.ext.Provider; + +/** + * Filter which automatically registers and updates metrics for outgoing REST client requests. + *

+ * An instance of this filter is added explicitly to the filter chain for each REST client interface + *

+ */ +@Priority(Priorities.USER - 100) +@Provider +class RestClientMetricsFilter implements ClientRequestFilter, ClientResponseFilter { + + static final String REST_CLIENT_METRICS_CONFIG_KEY = "rest-client.metrics"; + + private static final String INVOKED_METHOD = "org.eclipse.microprofile.rest.client.invokedMethod"; + + private final RestClientMetricsCdiExtension ext; + + private RestClientMetricsFilter() { + ext = CDI.current().getBeanManager().getExtension(RestClientMetricsCdiExtension.class); + } + + static RestClientMetricsFilter create() { + return new RestClientMetricsFilter(); + } + + @Override + public void filter(ClientRequestContext requestContext) { + Method javaMethod = (Method) requestContext.getProperty(INVOKED_METHOD); + if (javaMethod != null) { + ext.doPreWork(javaMethod, requestContext); + } + } + + @Override + public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) { + Method javaMethod = (Method) requestContext.getProperty(INVOKED_METHOD); + if (javaMethod != null) { + ext.doPostWork(javaMethod, requestContext); + } + } +} diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java new file mode 100644 index 00000000000..1e6f33056d4 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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. + */ +/** + * Metrics support for MP REST Client. + */ +package io.helidon.microprofile.restclientmetrics; diff --git a/microprofile/rest-client-metrics/src/main/java/module-info.java b/microprofile/rest-client-metrics/src/main/java/module-info.java new file mode 100644 index 00000000000..0d22d561959 --- /dev/null +++ b/microprofile/rest-client-metrics/src/main/java/module-info.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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. + */ + +import io.helidon.common.features.api.Feature; +import io.helidon.common.features.api.HelidonFlavor; + +/** + * MP Rest client metrics. + * + * @see org.eclipse.microprofile.rest.client + */ +@Feature(value = "REST Client Metrics", + description = "MicroProfile REST client spec implementation", + in = HelidonFlavor.MP, + path = "REST Client Metrics" +) +@SuppressWarnings({"requires-automatic", "requires-transitive-automatic"}) +module io.helidon.microprofile.restclient.metrics { + + requires io.helidon.microprofile.metrics; + + requires transitive jakarta.ws.rs; + requires jakarta.inject; + requires transitive jersey.common; + requires microprofile.metrics.api; + requires jakarta.cdi; + requires io.helidon.metrics.api; + requires microprofile.rest.client.api; + requires io.helidon.webserver; + requires java.xml; + + requires static io.helidon.common.features.api; + + exports io.helidon.microprofile.restclientmetrics; + + opens io.helidon.microprofile.restclientmetrics to weld.core.impl; + + provides jakarta.enterprise.inject.spi.Extension + with io.helidon.microprofile.restclientmetrics.RestClientMetricsCdiExtension; + + provides org.glassfish.jersey.internal.spi.AutoDiscoverable + with io.helidon.microprofile.restclientmetrics.RestClientMetricsAutoDiscoverable; + + provides org.eclipse.microprofile.rest.client.spi.RestClientListener + with io.helidon.microprofile.restclientmetrics.RestClientMetricsClientListener; +} \ No newline at end of file diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java new file mode 100644 index 00000000000..0975ac69087 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/MetricIdMatcher.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import org.eclipse.microprofile.metrics.MetricID; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +class MetricIdMatcher { + + static WithName withName(Matcher matcher) { + return new WithName(matcher); + } + + private static class WithName extends TypeSafeMatcher { + + private final Matcher matcher; + + private WithName(Matcher matcher) { + this.matcher = matcher; + } + + @Override + protected boolean matchesSafely(MetricID item) { + return matcher.matches(item.getName()); + } + + @Override + public void describeTo(Description description) { + description.appendText("metric ID name"); + description.appendDescriptionOf(matcher); + } + } +} diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java new file mode 100644 index 00000000000..84a932c60d7 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestClient.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; + +public interface TestClient { + + @GET + @Path("/get") + String get(); + + @PUT + @Path("put") + String put(String message); + + @HEAD + @Path("/unannotatedRestMethod") + void unmeasuredRestMethod(); +} diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java new file mode 100644 index 00000000000..3869c4885a7 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestScanning.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.helidon.microprofile.testing.junit5.AddBean; +import io.helidon.microprofile.testing.junit5.HelidonTest; + +import jakarta.enterprise.inject.spi.CDI; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.client.WebTarget; +import org.eclipse.microprofile.metrics.Counter; +import org.eclipse.microprofile.metrics.MetricID; +import org.eclipse.microprofile.metrics.MetricRegistry; +import org.eclipse.microprofile.metrics.Timer; +import org.eclipse.microprofile.metrics.annotation.Counted; +import org.eclipse.microprofile.metrics.annotation.Timed; +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +@HelidonTest +@AddBean(TestScanning.ServiceClient.class) +@AddBean(TestScanning.ServiceClientParent.class) +@AddBean(TestService.class) +class TestScanning { + + @Inject + WebTarget webTarget; + + private ServiceClient serviceClient; + + @BeforeEach + void init() { + serviceClient = RestClientBuilder.newBuilder() + .baseUri(webTarget.getUri()) + .build(ServiceClient.class); + } + + @Test + void annotationsOnMethods() throws NoSuchMethodException { + RestClientMetricsCdiExtension extension = CDI.current().getBeanManager() + .getExtension(RestClientMetricsCdiExtension.class); + Map> filterWorkByMethod = + extension.metricsUpdateWorkByMethod(); + assertThat("Check for expected filter work", filterWorkByMethod.keySet(), + allOf(hasItems(equalTo(ServiceClient.class.getMethod("get")), + equalTo(ServiceClient.class.getMethod("put"))), + not(hasItems(equalTo(ServiceClient.class.getMethod("timedNonRestMethod")), + equalTo(ServiceClient.class.getMethod("countedNonRestMethod")), + equalTo(ServiceClient.class.getMethod("unannotatedRestMethod")))))); + } + + @Test + void checkMetricsRegistrationsFromMethods() { + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + // get method + + // relative, automatic name = declaring-class.element-name + Timer getTimer = metricRegistry.getTimer(new MetricID(ServiceClient.class.getCanonicalName() + ".get")); + assertThat("Relative automatically-named timer for get", getTimer, notNullValue()); + + // absolute, explicit name = specified-name + Counter getCounter = metricRegistry.getCounter(new MetricID("getAbs")); + assertThat("Absolute explicitly-named counter for get", getCounter, notNullValue()); + + // get2 method + + // absolute, automatic name = element-name + Timer get2Timer = metricRegistry.getTimer(new MetricID("get2")); + assertThat("Absolute automatically-named timer for get2", get2Timer, notNullValue()); + + // relative, explicit name = declaring-class.specified-name + Counter get2Counter = metricRegistry.getCounter(new MetricID(ServiceClient.class.getCanonicalName() + ".relget2")); + assertThat("Relative explicitly-named counter for get2", get2Counter, notNullValue()); + + // timedNonRestMethod + Timer timedNonRestMethodTimer = metricRegistry.getTimer(new MetricID(ServiceClient.class.getCanonicalName() + + ".timedNonRestMethod")); + assertThat("Relative automatically-named timer for non-REST method", timedNonRestMethodTimer, nullValue()); + + // put method + + // relative, automatic name = declaring-class.element-name + Counter putCounter = metricRegistry.getCounter(new MetricID(ServiceClient.class.getCanonicalName() + ".put")); + assertThat("Relative automatically-named counter for put", putCounter, notNullValue()); + + // absolute, explicit name = specified-name + Timer putTimer = metricRegistry.getTimer(new MetricID("putAbs")); + assertThat("Absolute explicitly-named timer for put", putTimer, notNullValue()); + + // countedNonRestMethod + Counter nonRestMethodCounter = metricRegistry.getCounter(new MetricID("shouldNotAppear")); + assertThat("Counter for non-REST method", nonRestMethodCounter, nullValue()); + + // non-REST and unmeasured + var metrics = metricRegistry.getMetrics(); + assertThat("Metrics that should not appear", + metrics.keySet(), + allOf(not(contains("shouldNotAppear")), + not(contains("countedNonRestMethod")))); + } + + @Test + void checkMetricsRegistrationsFromType() { + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + // get method + + // type-level absolute with explicit name = specified-name.method-name + Timer getTimerFromType = metricRegistry.getTimer(new MetricID("typeLevelAbs.get")); + assertThat("Type-level timer with absolute explicit name", getTimerFromType, notNullValue()); + + // type-level relative with explicit name = package.specified-name.method-name + Counter getCounterFromType = metricRegistry.getCounter(new MetricID(ServiceClient.class.getPackageName() + + ".typeLevelRel.get")); + assertThat("Type-level counter with relative explicit name", getCounterFromType, notNullValue()); + + Counter unmeasuredGetCounterFromType = metricRegistry.getCounter(new MetricID(ServiceClient.class.getPackageName() + + ".typeLevelRel" + + ".unannotatedRestMethod")); + assertThat("unmeasuredRest counter from type-level annotation", unmeasuredGetCounterFromType, notNullValue()); + } + + @Test + void checkMetricsUpdates() { + + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + List timers = new ArrayList<>(); + + // All on the get method. + + // relative automatically-named timer on the get method in the subinterface = subtype.method-name + TimerInfo getTimerInfo = TimerInfo.create(metricRegistry, ServiceClient.class.getCanonicalName() + ".get"); + assertThat("Relative automatically-named subinterface method-level timer for get method", getTimerInfo, notNullValue()); + Duration elapsedTimeBefore = getTimerInfo.timer.getElapsedTime(); + + timers.add(getTimerInfo); + + // absolute explicitly-named timer on the subinterface = specified-value.method-name + + TimerInfo getTimerFromTypeInfo = TimerInfo.create(metricRegistry, "typeLevelAbs.get"); + + assertThat("Absolute explicitly-named timer from the subinterface", getTimerFromTypeInfo.timer, notNullValue()); + + timers.add(getTimerFromTypeInfo); + + // relative automatically-named timer on parentGet method on superinterface = subtype.method-name + TimerInfo getTimerFromSuperTypeInfo = TimerInfo.create(metricRegistry, ServiceClient.class.getCanonicalName() + + ".get"); + assertThat("Relative automatically-named timer on method in superinterface", getTimerFromSuperTypeInfo.timer, + notNullValue()); + + timers.add(getTimerFromSuperTypeInfo); + + // relatively explicitly-named timer on superinterface = subtype-package.specified-name.method-name + TimerInfo inheritedGetMethodTimerInfo = TimerInfo.create(metricRegistry, ServiceClient.class.getPackageName() + + ".parentLevelRel.get"); + assertThat("Inherited relative auto-named type-level timer for get method", + inheritedGetMethodTimerInfo.timer, + notNullValue()); + + timers.add(inheritedGetMethodTimerInfo); + + String timedGetResult = serviceClient.get(); + + assertThat("Timed get result", timedGetResult, equalTo("get")); + Duration elapsedTimeAfter = getTimerInfo.timer.getElapsedTime(); + assertThat("Timer delta", elapsedTimeAfter.compareTo(elapsedTimeBefore), greaterThan(0)); + + String parentGetResult = serviceClient.parentGet(); + assertThat("Parent get result", parentGetResult, equalTo("parent get")); + + for (TimerInfo timerInfo : timers) { + assertThat("Timer for timer info " + timerInfo.metricId, timerInfo.timer, notNullValue()); + assertThat("Counter update for " + timerInfo.metricId, + timerInfo.timer.getCount(), + greaterThan(timerInfo.beforeCount)); + } + } + + @Test + void checkInheritance() { + MetricRegistry metricRegistry = CDI.current().select(MetricRegistry.class).get(); + + // parentGet method + + // method-level relative automatic name = declaring-class.method-name (declaring class is the superinterface) + Timer parentGetMethodTimer = metricRegistry.getTimer(new MetricID(ServiceClientParent.class.getCanonicalName() + + ".parentGet")); + assertThat("Relative automatically-named timer for inherited parentGet method", parentGetMethodTimer, notNullValue()); + + // get method + + // method-level absolute explicit name = specified-name + Counter getMethodCounterFromParent = metricRegistry.getCounter(new MetricID("parentGetAbs")); + assertThat("Absolute explicitly-named counter for inherited get method", getMethodCounterFromParent, notNullValue()); + + // type-level relative explicit name = package-of-declaring-class.specified-name.method-name + Timer superTypeLevelTimerForGet = metricRegistry.getTimer(new MetricID(ServiceClientParent.class.getPackageName() + + ".parentLevelRel.get")); + assertThat("Type-level relative explicitly-named counter for inherited get method", + superTypeLevelTimerForGet, + notNullValue()); + + // put method + + // type-level absolute explicit name = specified-name.method-name + Counter inheritedPutMethodCounter = metricRegistry.getCounter(new MetricID("parentLevelAbs.put")); + assertThat("Type-level absolute explicitly-named counter inherited for put method", + inheritedPutMethodCounter, + notNullValue()); + } + + @Timed(name = "parentLevelRel") + @Counted(name = "parentLevelAbs", absolute = true) + interface ServiceClientParent { + + @Timed + @GET + @Path("/parentGet") + String parentGet(); + + @Counted(name = "parentGetAbs", absolute = true) + @GET + @Path("/get") + String get(); + + } + + @Path(TestService.RESOURCE_PATH) + @Counted(name = "typeLevelRel") + @Timed(name = "typeLevelAbs", absolute = true) + interface ServiceClient extends ServiceClientParent { + + @Timed + @Counted(absolute = true, name = "getAbs") + @GET + @Path("/get") + String get(); + + @Timed(absolute = true) + @Counted(name = "relget2") + @GET + @Path("/get2") + String get2(); + + @Timed + void timedNonRestMethod(); + + @Counted + @Timed(absolute = true, name = "putAbs") + @PUT + @Path("/put") + void put(); + + @Counted(name = "shouldNotAppear", absolute = true) + void countedNonRestMethod(); + + @HEAD + @Path("/unannotatedRestMethod") + void unannotatedRestMethod(); + } + + private record TimerInfo(Timer timer, MetricID metricId, long beforeCount) { + + static TimerInfo create(MetricRegistry metricRegistry, String metricName) { + MetricID metricID = new MetricID(metricName); + Timer timer = metricRegistry.getTimer(metricID); + return new TimerInfo(timer, metricID, timer != null ? timer.getCount() : 0L); + } + } + +} diff --git a/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java new file mode 100644 index 00000000000..2ed8ad4b690 --- /dev/null +++ b/microprofile/rest-client-metrics/src/test/java/io/helidon/microprofile/restclientmetrics/TestService.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.restclientmetrics; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HEAD; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; + +@Path(TestService.RESOURCE_PATH) +public class TestService { + + static final String RESOURCE_PATH = "/restClientMetricsTest"; + + @GET + @Path("/get") + public String get() { + return "get"; + } + + @PUT + @Path("/put") + public String put(String message) { + return "I got " + message; + } + + @HEAD + @Path("/unannotatedRestMethod") + public void unmeasuredRestMethod() { + + } + + @GET + @Path("/parentGet") + public String parentGet() { + return "parent get"; + } + +} From 20b778bae5fa437bab0f3d7ec46feb36eb4f6de0 Mon Sep 17 00:00:00 2001 From: Thibault Vallin Date: Tue, 3 Dec 2024 08:53:56 +0100 Subject: [PATCH 27/36] 4.x: Fix MP OIDC guide configuration and typo --- docs/src/main/asciidoc/mp/guides/security-oidc.adoc | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/src/main/asciidoc/mp/guides/security-oidc.adoc b/docs/src/main/asciidoc/mp/guides/security-oidc.adoc index 05f66ab88bf..e9cbcd008b9 100644 --- a/docs/src/main/asciidoc/mp/guides/security-oidc.adoc +++ b/docs/src/main/asciidoc/mp/guides/security-oidc.adoc @@ -165,7 +165,7 @@ A new user is just created, but it needs a password to be able to log in. To ini . If the `Temporary` field is set to `ON`, the user has to update password on next login. Click `ON` to make it `OFF` and prevent it. . Press `Save`. -. A pop-up window is popping off. Click on `Set Password` to confirm the new password. +. A pop-up window is popping off. Click on `Save Password` to confirm the new password. To verify that the new user is created correctly: @@ -266,7 +266,6 @@ security: client-secret: "changeit" # <2> identity-uri: "http://localhost:8080/realms/myRealm" # <3> frontend-uri: "http://localhost:7987" # <4> - cookie-use: "false" ---- <1> `client-id` must be the same as the one configure in keycloak. <2> The client secret generate by Keycloak during `Create a client` section. @@ -479,7 +478,7 @@ a refresh token. ==== Resource Owner Password Credentials Grant (Direct Access Grants) The Direct Access Grants flow is used by REST clients that want to request tokens on behalf of a user. -To use Postman to make this request on behalf of `myuser`, select the GET method and enter this URL: +To use Postman to make this request on behalf of `myUser`, select the GET method and enter this URL: `http://localhost:7987/greet/`. Under `Authorization` tab, select authorization type `OAuth 2.0`. Under it, complete the sentence `Add authorization data to` with `Request Headers`, and complete the required fields. @@ -492,7 +491,7 @@ sentence `Add authorization data to` with `Request Headers`, and complete the r {"key":"Access Token URL","value":"http://localhost:8080/realms/myRealm/protocol/openid-connect/token"}, {"key":"Client ID","value":"myClientID"}, {"key":"Client Secret","value":"client secret"}, - {"key":"Username","value":"myuser"}, + {"key":"Username","value":"myUser"}, {"key":"Password","value":"password"}, {"key":"Scope","value":"openid"}, {"key":"Client Authentication","value":"Send as Basic Auth Header"} From 340271b27f5d789df744b98664c9959c09f9b037 Mon Sep 17 00:00:00 2001 From: Tim Quinn Date: Tue, 3 Dec 2024 15:36:28 -0600 Subject: [PATCH 28/36] Initialize distribution customizations even before config is applied (#9547) * Initialize distribution customizations even before config is applied * Improve comment --- microprofile/metrics/pom.xml | 13 ++++++ .../metrics/DistributionCustomizations.java | 9 +++- .../TestDistributionCustomizationsNoInit.java | 44 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDistributionCustomizationsNoInit.java diff --git a/microprofile/metrics/pom.xml b/microprofile/metrics/pom.xml index aba57224710..48f6af9ecc2 100644 --- a/microprofile/metrics/pom.xml +++ b/microprofile/metrics/pom.xml @@ -130,6 +130,7 @@ **/TestDisabledMetrics.java **/TestSelectivelyDisabledMetrics.java **/TestConfigProcessing.java + **/TestDistributionCustomizationsNoInit.java true @@ -196,6 +197,18 @@ + + + test-dist-cust-with-no-init + + test + + + + **/TestDistributionCustomizationsNoInit.java + + + diff --git a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/DistributionCustomizations.java b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/DistributionCustomizations.java index 4b171e2ad42..0f468605d94 100644 --- a/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/DistributionCustomizations.java +++ b/microprofile/metrics/src/main/java/io/helidon/microprofile/metrics/DistributionCustomizations.java @@ -62,7 +62,7 @@ class DistributionCustomizations { private static final Duration DEFAULT_TIMER_MIN = Duration.ofMillis(5); private static final Duration DEFAULT_TIMER_MAX = Duration.ofSeconds(10); - private static DistributionCustomizations instance; + private static DistributionCustomizations instance = new DistributionCustomizations(); private final List percentileCustomizations; private final List summaryBucketCustomizations; private final List timerBucketCustomizations; @@ -82,6 +82,13 @@ private DistributionCustomizations(Config mpConfig) { Boolean::parseBoolean)); } + private DistributionCustomizations() { + percentileCustomizations = List.of(); + summaryBucketCustomizations = List.of(); + timerBucketCustomizations = List.of(); + summaryBucketDefaultCustomizations = List.of(); + } + static void init(Config mpConfig) { instance = new DistributionCustomizations(mpConfig); } diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDistributionCustomizationsNoInit.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDistributionCustomizationsNoInit.java new file mode 100644 index 00000000000..eb2fbc93e15 --- /dev/null +++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/TestDistributionCustomizationsNoInit.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.metrics; + +import org.eclipse.microprofile.metrics.MetricRegistry; +import org.eclipse.microprofile.metrics.Timer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; + +class TestDistributionCustomizationsNoInit { + + private static MetricRegistry metricRegistry; + + @BeforeAll + static void initRegistry() { + metricRegistry = RegistryFactory.getInstance().getRegistry(MetricRegistry.APPLICATION_SCOPE); + } + + @Test + void checkDistributionCustomizations() { + // Without the change in the main source, the following triggers an NPE because this test does not use @HelidonTest + // and therefore the normal metrics CDI extension initialization code--which sets up the distribution + // customizations--does not run. That means the configurable distribution customizations are never set, leading + // to the NPE. + Timer timer = metricRegistry.timer("testTimer"); + assertThat("Timer", timer, notNullValue()); + } +} From 189ca35988483f736941e8d894c77304a6d22df0 Mon Sep 17 00:00:00 2001 From: Tim Quinn Date: Wed, 4 Dec 2024 08:43:01 -0600 Subject: [PATCH 29/36] Fix possible NPE if code creates a REST client 'early'; also tidy up a few other things (#9554) --- .../RestClientMetricsCdiExtension.java | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java index 12025f410fd..bdcab01f713 100644 --- a/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java +++ b/microprofile/rest-client-metrics/src/main/java/io/helidon/microprofile/restclientmetrics/RestClientMetricsCdiExtension.java @@ -20,6 +20,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -27,6 +28,7 @@ import java.util.Objects; import java.util.Set; import java.util.StringJoiner; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -39,7 +41,6 @@ import jakarta.enterprise.inject.spi.AfterBeanDiscovery; import jakarta.enterprise.inject.spi.AnnotatedMethod; import jakarta.enterprise.inject.spi.AnnotatedType; -import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; import jakarta.enterprise.inject.spi.Extension; import jakarta.enterprise.inject.spi.ProcessAnnotatedType; import jakarta.enterprise.inject.spi.WithAnnotations; @@ -84,6 +85,10 @@ public class RestClientMetricsCdiExtension implements Extension { private final Map, Map>> registrations = new HashMap<>(); + // Some code might create REST clients before CDI announces that app-scoped beans are initialized. + // We record those "early" REST clients and set up metrics for them only when we are ready. + private final Collection> deferredRestClients = new ConcurrentLinkedQueue<>(); + private MetricRegistry metricRegistry; /** @@ -92,10 +97,6 @@ public class RestClientMetricsCdiExtension implements Extension { public RestClientMetricsCdiExtension() { } - void checkForMpMetrics(@Observes BeforeBeanDiscovery bbd) { - - } - void recordRestClientTypes(@Observes @WithAnnotations({OPTIONS.class, HEAD.class, GET.class, @@ -218,20 +219,25 @@ void prepareMetricRegistrations(@Observes AfterBeanDiscovery abd) { void ready(@Observes @Initialized(ApplicationScoped.class) Object event, MetricRegistry metricRegistry) { this.metricRegistry = metricRegistry; + deferredRestClients.forEach(this::registerMetricsForRestClient); + deferredRestClients.clear(); } void registerMetricsForRestClient(Class restClient) { + if (metricRegistry == null) { + deferredRestClients.add(restClient); + return; + } registrations.get(restClient).forEach((method, regs) -> { List metricsRegisteredForRestClient = LOGGER.isLoggable(DEBUG) ? new ArrayList<>() : null; regs.forEach(registration -> { Metric metric = registration.registrationOp.apply(metricRegistry); - if (LOGGER.isLoggable(DEBUG)) { + if (metricsRegisteredForRestClient != null) { metricsRegisteredForRestClient.add(metric); LOGGER.log(DEBUG, String.format("For REST client method %s#%s registering metric using %s", restClient.getCanonicalName(), method.getName(), - registration, - metric)); + registration)); } metricsUpdateWorkByMethod.computeIfAbsent(method, k -> new ArrayList<>()) .add(MetricsUpdateWork.create(metric)); From 75353afbc4173daf680bc327a0484a1d4cff5824 Mon Sep 17 00:00:00 2001 From: Tim Quinn Date: Wed, 4 Dec 2024 12:07:14 -0600 Subject: [PATCH 30/36] Add new component to all/pom.xml (#9556) --- all/pom.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/all/pom.xml b/all/pom.xml index a4d6e458d54..3ae9b408680 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -559,6 +559,10 @@ io.helidon.microprofile.rest-client helidon-microprofile-rest-client
+ + io.helidon.microprofile.rest-client-metrics + helidon-microprofile-rest-client-metrics + io.helidon.microprofile.reactive-streams helidon-microprofile-reactive-streams From beeac25f1378c99fc1f86e19c3035a6925c00b6f Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Thu, 5 Dec 2024 10:21:09 -0500 Subject: [PATCH 31/36] Creates FT executor using our ThreadPoolSupplier to ensure context propagation. Adds new test. (#9555) --- .../faulttolerance/FaultTolerance.java | 12 ++++++---- .../io/helidon/faulttolerance/AsyncTest.java | 23 ++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/fault-tolerance/fault-tolerance/src/main/java/io/helidon/faulttolerance/FaultTolerance.java b/fault-tolerance/fault-tolerance/src/main/java/io/helidon/faulttolerance/FaultTolerance.java index 6b32e8f6cb7..9ed7b017353 100644 --- a/fault-tolerance/fault-tolerance/src/main/java/io/helidon/faulttolerance/FaultTolerance.java +++ b/fault-tolerance/fault-tolerance/src/main/java/io/helidon/faulttolerance/FaultTolerance.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2023 Oracle and/or its affiliates. + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,11 +20,11 @@ import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import io.helidon.common.LazyValue; +import io.helidon.common.configurable.ThreadPoolSupplier; import io.helidon.config.Config; import static java.lang.System.Logger.Level.ERROR; @@ -54,9 +54,11 @@ public final class FaultTolerance { private static final AtomicReference CONFIG = new AtomicReference<>(Config.empty()); static { - EXECUTOR.set(LazyValue.create(() -> Executors.newThreadPerTaskExecutor(Thread.ofVirtual() - .name("helidon-ft-", 0) - .factory()))); + EXECUTOR.set(LazyValue.create(() -> ThreadPoolSupplier.builder() + .threadNamePrefix("helidon-ft-") + .virtualThreads(true) + .build() + .get())); } private FaultTolerance() { diff --git a/fault-tolerance/fault-tolerance/src/test/java/io/helidon/faulttolerance/AsyncTest.java b/fault-tolerance/fault-tolerance/src/test/java/io/helidon/faulttolerance/AsyncTest.java index f9d40053bfb..1b77023fea7 100644 --- a/fault-tolerance/fault-tolerance/src/test/java/io/helidon/faulttolerance/AsyncTest.java +++ b/fault-tolerance/fault-tolerance/src/test/java/io/helidon/faulttolerance/AsyncTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,9 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import io.helidon.common.context.Context; +import io.helidon.common.context.Contexts; + import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.endsWith; @@ -61,6 +64,24 @@ void testThreadName() throws Exception { assertThat(threadName, endsWith(": async")); } + @Test + void testContextPropagation() throws Exception { + Context context = Context.create(); + CompletableFuture cf = new CompletableFuture<>(); + Contexts.runInContext(context, () -> { + try { + Async async = Async.create(); + async.invoke(() -> { + cf.complete(Contexts.context().orElse(null)); + return null; + }); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertThat(cf.get(WAIT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS), is(context)); + } + private Thread testAsync(Async async) { try { CompletableFuture cf = new CompletableFuture<>(); From 56985e6546706929f42dcfe16b0c0e69bdffb904 Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Thu, 5 Dec 2024 10:21:54 -0500 Subject: [PATCH 32/36] Adds support for async on @ExecuteOn methods (#9551) * Adds support for async on @ExecuteOn methods. Signed-off-by: Santiago Pericas-Geertsen * New test that verifies correct unwrapping of exceptions. Signed-off-by: Santiago Pericas-Geertsen --------- Signed-off-by: Santiago Pericas-Geertsen --- docs/src/main/asciidoc/mp/threading.adoc | 18 +- .../io/helidon/docs/mp/ExecuteOnSnippets.java | 13 ++ .../helidon/microprofile/cdi/ExecuteOn.java | 8 +- .../microprofile/cdi/ExecuteOnExtension.java | 32 ++- .../cdi/ExecuteOnInterceptor.java | 139 +++++++++--- .../microprofile/cdi/ExecuteOnAsyncTest.java | 197 ++++++++++++++++++ 6 files changed, 366 insertions(+), 41 deletions(-) create mode 100644 microprofile/cdi/src/test/java/io/helidon/microprofile/cdi/ExecuteOnAsyncTest.java diff --git a/docs/src/main/asciidoc/mp/threading.adoc b/docs/src/main/asciidoc/mp/threading.adoc index 5e613936de5..d2f926c8810 100644 --- a/docs/src/main/asciidoc/mp/threading.adoc +++ b/docs/src/main/asciidoc/mp/threading.adoc @@ -49,7 +49,9 @@ yet this process is still underway and some legacy libraries may never be fully Helidon MP supports a new `@ExecuteOn` annotation to give developers full control on how to run tasks. This annotation can be applied to any CDI bean method to control the type of thread in -which invocations of that method shall execute on. +which invocations of that method shall execute on. If such a method returns `CompletionStage` +or `CompletableFuture`, it is assumed to be asynchronous and shall execute in a new thread +but without blocking the caller's thread. include::{rootdir}/includes/dependencies.adoc[] @@ -129,10 +131,20 @@ but that is not a requirement in CDI. include::{sourcedir}/mp/ExecuteOnSnippets.java[tag=snippet_2, indent=0] ---- -3. Finally, it is also possible to explicitly execute a method in a -virtual thread, blocking the caller thread until the method execution is complete. +3. It is also possible to explicitly execute a method in a +virtual thread, blocking the caller's thread until the method execution is complete. + [source,java] ---- include::{sourcedir}/mp/ExecuteOnSnippets.java[tag=snippet_3, indent=0] ---- + +4. Finally, a method can be executed in another thread but without blocking +the caller's thread. This behavior is triggered automatically when the bean method returns +`CompletionStage` or `CompletableFuture`. ++ +[source,java] +---- +include::{sourcedir}/mp/ExecuteOnSnippets.java[tag=snippet_4, indent=0] +---- + diff --git a/docs/src/main/java/io/helidon/docs/mp/ExecuteOnSnippets.java b/docs/src/main/java/io/helidon/docs/mp/ExecuteOnSnippets.java index d3258a00d17..b82a0848fbb 100644 --- a/docs/src/main/java/io/helidon/docs/mp/ExecuteOnSnippets.java +++ b/docs/src/main/java/io/helidon/docs/mp/ExecuteOnSnippets.java @@ -17,6 +17,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import io.helidon.microprofile.cdi.ExecuteOn; import jakarta.enterprise.inject.Produces; @@ -69,4 +71,15 @@ void someTask() { } } // end::snippet_3[] + + // tag::snippet_4[] + public class MyVirtualBeanAsync { + + @ExecuteOn(ThreadType.VIRTUAL) + CompletionStage someTask() { + // run task on virtual thread without blocking caller + return CompletableFuture.completedFuture("DONE"); + } + } + // end::snippet_4[] } diff --git a/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOn.java b/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOn.java index eb08d44c41c..34d1015c4a0 100644 --- a/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOn.java +++ b/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOn.java @@ -27,7 +27,9 @@ import jakarta.interceptor.InterceptorBinding; /** - * Annotates a CDI bean method that shall be executed on a new thread. + * Annotates a CDI bean method that shall be executed on a new thread. If the method returns + * {@link java.util.concurrent.CompletableFuture} or {@link java.util.concurrent.CompletionStage}, + * it is assumed to be asynchronous. */ @Documented @Retention(RetentionPolicy.RUNTIME) @@ -65,7 +67,7 @@ enum ThreadType { ThreadType value() default ThreadType.PLATFORM; /** - * Waiting timeout. + * Waiting timeout, used when the method is synchronous. * * @return waiting timeout */ @@ -73,7 +75,7 @@ enum ThreadType { long timeout() default 10000L; /** - * Waiting time unit. + * Waiting time unit, used when the method is synchronous. * * @return waiting time unit */ diff --git a/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnExtension.java b/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnExtension.java index 2ef68aab964..456818b5ce8 100644 --- a/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnExtension.java +++ b/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnExtension.java @@ -18,8 +18,11 @@ import java.lang.reflect.Method; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; import io.helidon.common.LazyValue; @@ -32,6 +35,7 @@ import jakarta.enterprise.inject.spi.Bean; import jakarta.enterprise.inject.spi.BeanManager; import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; +import jakarta.enterprise.inject.spi.DeploymentException; import jakarta.enterprise.inject.spi.Extension; import jakarta.enterprise.inject.spi.ProcessManagedBean; import jakarta.enterprise.inject.spi.ProcessSyntheticBean; @@ -41,7 +45,13 @@ */ public class ExecuteOnExtension implements Extension { + enum MethodType { + BLOCKING, + NON_BLOCKING + }; + private final LazyValue>> methodMap = LazyValue.create(ConcurrentHashMap::new); + private final LazyValue> methodType = LazyValue.create(ConcurrentHashMap::new); void registerMethods(BeanManager bm, @Observes ProcessSyntheticBean event) { registerMethods(bm.createAnnotatedType(event.getBean().getBeanClass())); @@ -54,7 +64,9 @@ void registerMethods(@Observes ProcessManagedBean event) { private void registerMethods(AnnotatedType type) { for (AnnotatedMethod annotatedMethod : type.getMethods()) { if (annotatedMethod.isAnnotationPresent(ExecuteOn.class)) { - methodMap.get().put(annotatedMethod.getJavaMember(), annotatedMethod); + Method method = annotatedMethod.getJavaMember(); + methodMap.get().put(method, annotatedMethod); + methodType.get().put(method, findMethodType(method)); } } } @@ -63,6 +75,19 @@ void validateAnnotations(BeanManager bm, @Observes @Initialized(ApplicationScope methodMap.get().forEach((method, annotatedMethod) -> validateExecutor(bm, annotatedMethod)); } + + private static MethodType findMethodType(Method method) { + Class returnType = method.getReturnType(); + if (CompletionStage.class.isAssignableFrom(returnType) + || CompletableFuture.class.isAssignableFrom(returnType)) { + return MethodType.NON_BLOCKING; + } + if (Future.class.equals(returnType)) { + throw new DeploymentException("Future is not supported as return type of ExecuteOn method"); + } + return MethodType.BLOCKING; + } + private static void validateExecutor(BeanManager bm, AnnotatedMethod method) { ExecuteOn executeOn = method.getAnnotation(ExecuteOn.class); if (executeOn.value() == ExecuteOn.ThreadType.EXECUTOR) { @@ -85,6 +110,10 @@ ExecuteOn getAnnotation(Method method) { throw new IllegalArgumentException("Unable to map method " + method); } + MethodType getMethodType(Method method) { + return methodType.get().get(method); + } + void registerInterceptors(@Observes BeforeBeanDiscovery discovery, BeanManager bm) { discovery.addAnnotatedType(bm.createAnnotatedType(ExecuteOnInterceptor.class), ExecuteOnInterceptor.class.getName()); @@ -92,5 +121,6 @@ void registerInterceptors(@Observes BeforeBeanDiscovery discovery, BeanManager b void clearMethodMap() { methodMap.get().clear(); + methodType.get().clear(); } } diff --git a/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnInterceptor.java b/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnInterceptor.java index a18cca3901f..d8f34c51812 100644 --- a/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnInterceptor.java +++ b/microprofile/cdi/src/main/java/io/helidon/microprofile/cdi/ExecuteOnInterceptor.java @@ -16,7 +16,13 @@ package io.helidon.microprofile.cdi; +import java.lang.reflect.Method; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; import io.helidon.common.LazyValue; import io.helidon.common.configurable.ThreadPoolSupplier; @@ -47,56 +53,111 @@ class ExecuteOnInterceptor { private static final LazyValue PLATFORM_EXECUTOR_SERVICE = LazyValue.create(() -> { - Config mpConfig = ConfigProvider.getConfig(); - io.helidon.config.Config config = MpConfig.toHelidonConfig(mpConfig); - return ThreadPoolSupplier.builder() - .threadNamePrefix(EXECUTE_ON) - .config(config.get(RUN_ON_PLATFORM_THREAD)) - .virtualThreads(false) // overrides to platform threads - .build() - .get(); - }); + Config mpConfig = ConfigProvider.getConfig(); + io.helidon.config.Config config = MpConfig.toHelidonConfig(mpConfig); + return ThreadPoolSupplier.builder() + .threadNamePrefix(EXECUTE_ON) + .config(config.get(RUN_ON_PLATFORM_THREAD)) + .virtualThreads(false) // overrides to platform threads + .build() + .get(); + }); private static final LazyValue VIRTUAL_EXECUTOR_SERVICE = LazyValue.create(() -> { - Config mpConfig = ConfigProvider.getConfig(); - io.helidon.config.Config config = MpConfig.toHelidonConfig(mpConfig); - String threadNamePrefix = config.get(RUN_ON_VIRTUAL_THREAD) - .get("thread-name-prefix") - .asString() - .asOptional() - .orElse(EXECUTE_ON); - return ThreadPoolSupplier.builder() - .threadNamePrefix(threadNamePrefix) - .virtualThreads(true) - .build() - .get(); - }); + Config mpConfig = ConfigProvider.getConfig(); + io.helidon.config.Config config = MpConfig.toHelidonConfig(mpConfig); + String threadNamePrefix = config.get(RUN_ON_VIRTUAL_THREAD) + .get("thread-name-prefix") + .asString() + .asOptional() + .orElse(EXECUTE_ON); + return ThreadPoolSupplier.builder() + .threadNamePrefix(threadNamePrefix) + .virtualThreads(true) + .build() + .get(); + }); @Inject private ExecuteOnExtension extension; /** - * Intercepts a call to bean method annotated by {@code @OnNewThread}. + * Intercepts a call to bean method annotated by {@link io.helidon.microprofile.cdi.ExecuteOn}. * * @param context Invocation context. * @return Whatever the intercepted method returns. * @throws Throwable If a problem occurs. */ @AroundInvoke + @SuppressWarnings("unchecked") public Object executeOn(InvocationContext context) throws Throwable { - ExecuteOn executeOn = extension.getAnnotation(context.getMethod()); - return switch (executeOn.value()) { - case PLATFORM -> PLATFORM_EXECUTOR_SERVICE.get() - .submit(context::proceed) - .get(executeOn.timeout(), executeOn.unit()); - case VIRTUAL -> VIRTUAL_EXECUTOR_SERVICE.get() - .submit(context::proceed) - .get(executeOn.timeout(), executeOn.unit()); - case EXECUTOR -> findExecutor(executeOn.executorName()) - .submit(context::proceed) - .get(executeOn.timeout(), executeOn.unit()); + Method method = context.getMethod(); + ExecuteOn executeOn = extension.getAnnotation(method); + + // find executor service to use + ExecutorService executorService = switch (executeOn.value()) { + case PLATFORM -> PLATFORM_EXECUTOR_SERVICE.get(); + case VIRTUAL -> VIRTUAL_EXECUTOR_SERVICE.get(); + case EXECUTOR -> findExecutor(executeOn.executorName()); }; + + switch (extension.getMethodType(method)) { + case BLOCKING: + // block until call completes + return executorService.submit(context::proceed).get(executeOn.timeout(), executeOn.unit()); + case NON_BLOCKING: + // execute call asynchronously + CompletableFuture supplyFuture = CompletableFuture.supplyAsync( + () -> { + try { + return context.proceed(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, executorService); + + // return new, cancellable completable future + AtomicBoolean mayInterrupt = new AtomicBoolean(false); + CompletableFuture resultFuture = new CompletableFuture<>() { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + mayInterrupt.set(mayInterruptIfRunning); + return super.cancel(mayInterruptIfRunning); + } + }; + + // link completion of supplyFuture with resultFuture + supplyFuture.whenComplete((result, throwable) -> { + if (throwable == null) { + // result must be CompletionStage or CompletableFuture + CompletableFuture cfResult = !(result instanceof CompletableFuture) + ? ((CompletionStage) result).toCompletableFuture() + : (CompletableFuture) result; + cfResult.whenComplete((r, t) -> { + if (t == null) { + resultFuture.complete(r); + } else { + resultFuture.completeExceptionally(unwrapThrowable(t)); + } + }); + } else { + resultFuture.completeExceptionally(unwrapThrowable(throwable)); + } + }); + + // if resultFuture is cancelled, then cancel supplyFuture + resultFuture.exceptionally(t -> { + if (t instanceof CancellationException) { + supplyFuture.cancel(mayInterrupt.get()); + } + return null; + }); + + return resultFuture; + default: + throw new IllegalStateException("Unrecognized ExecuteOn method type"); + } } /** @@ -108,4 +169,14 @@ public Object executeOn(InvocationContext context) throws Throwable { private static ExecutorService findExecutor(String executorName) { return CDI.current().select(ExecutorService.class, NamedLiteral.of(executorName)).get(); } + + /** + * Extract underlying throwable. + * + * @param t the throwable + * @return the wrapped throwable + */ + private static Throwable unwrapThrowable(Throwable t) { + return t instanceof ExecutionException ? t.getCause() : t; + } } diff --git a/microprofile/cdi/src/test/java/io/helidon/microprofile/cdi/ExecuteOnAsyncTest.java b/microprofile/cdi/src/test/java/io/helidon/microprofile/cdi/ExecuteOnAsyncTest.java new file mode 100644 index 00000000000..c0c5186b1bc --- /dev/null +++ b/microprofile/cdi/src/test/java/io/helidon/microprofile/cdi/ExecuteOnAsyncTest.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.microprofile.cdi; + +import java.util.Optional; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.helidon.common.context.Context; +import io.helidon.common.context.Contexts; + +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.enterprise.inject.spi.CDI; +import jakarta.inject.Named; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static io.helidon.microprofile.cdi.ExecuteOn.ThreadType; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +class ExecuteOnAsyncTest { + + public static final int SHORT_TIMEOUT = 500; + public static final int LONG_TIMEOUT = 10000; + + static SeContainer seContainer; + static OnNewThreadBean bean; + + @BeforeAll + @SuppressWarnings("unchecked") + static void startCdi() { + seContainer = SeContainerInitializer.newInstance() + .disableDiscovery() + .addExtensions(ExecuteOnExtension.class) + .addBeanClasses(OnNewThreadBean.class) + .initialize(); + bean = CDI.current().select(OnNewThreadBean.class).get(); + } + + @AfterAll + static void stopCdi() { + seContainer.close(); + } + + static class OnNewThreadBean { + + @ExecuteOn(ThreadType.PLATFORM) + CompletionStage cpuIntensive() { + return CompletableFuture.completedFuture(Thread.currentThread()); + } + + @ExecuteOn(value = ThreadType.PLATFORM) + CompletableFuture evenMoreCpuIntensive() { + return CompletableFuture.completedFuture(Thread.currentThread()); + } + + @ExecuteOn(ThreadType.VIRTUAL) + CompletionStage onVirtualThread() { + return CompletableFuture.completedFuture(Thread.currentThread()); + } + + @ExecuteOn(value = ThreadType.EXECUTOR, executorName = "my-executor") + CompletableFuture onMyExecutor() { + return CompletableFuture.completedFuture(Thread.currentThread()); + } + + @ExecuteOn(ThreadType.VIRTUAL) + CompletionStage> verifyContextVirtual() { + return CompletableFuture.completedFuture( + Contexts.context().flatMap(context -> context.get("hello", String.class))); + } + + @ExecuteOn(ThreadType.PLATFORM) + CompletableFuture> verifyContextPlatform() { + return CompletableFuture.completedFuture( + Contexts.context().flatMap(context -> context.get("hello", String.class))); + } + + @ExecuteOn(ThreadType.VIRTUAL) + CompletableFuture eternallyBlocked() throws BrokenBarrierException, InterruptedException { + CyclicBarrier barrier = new CyclicBarrier(2); + barrier.await(); + return CompletableFuture.completedFuture(Thread.currentThread()); + } + + @ExecuteOn(ThreadType.VIRTUAL) + CompletableFuture alwaysFails() { + return CompletableFuture.failedFuture(new UnsupportedOperationException("Not supported")); + } + + @Produces + @Named("my-executor") + ExecutorService myExecutor() { + return Executors.newFixedThreadPool(2); + } + } + + @Test + void cpuIntensiveTest() throws ExecutionException, InterruptedException, TimeoutException { + CompletionStage completionStage = bean.cpuIntensive(); + Thread thread = completionStage.toCompletableFuture().get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + assertThat(thread.isVirtual(), is(false)); + assertThat(thread.getName().startsWith("my-platform-thread"), is(true)); + } + + @Test + void evenMoreCpuIntensiveTest() throws ExecutionException, InterruptedException, TimeoutException { + CompletableFuture completableFuture = bean.evenMoreCpuIntensive(); + Thread thread = completableFuture.get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + assertThat(thread.isVirtual(), is(false)); + assertThat(thread.getName().startsWith("my-platform-thread"), is(true)); + } + + @Test + void onVirtualThread() throws ExecutionException, InterruptedException, TimeoutException { + CompletionStage completionStage = bean.onVirtualThread(); + Thread thread = completionStage.toCompletableFuture().get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + assertThat(thread.isVirtual(), is(true)); + assertThat(thread.getName().startsWith("my-virtual-thread"), is(true)); + } + + @Test + void onMyExecutor() throws ExecutionException, InterruptedException, TimeoutException { + CompletableFuture completableFuture = bean.onMyExecutor(); + Thread thread = completableFuture.get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + assertThat(thread.isVirtual(), is(false)); + assertThat(thread.getName().startsWith("pool"), is(true)); + } + + @Test + void verifyContextVirtual() throws ExecutionException, InterruptedException, TimeoutException { + Context context = Contexts.globalContext(); + context.register("hello", "world"); + CompletionStage> completionStage = Contexts.runInContext(context, bean::verifyContextVirtual); + Optional optional = completionStage.toCompletableFuture().get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + assertThat(optional.orElseThrow(), is("world")); + } + + @Test + void verifyContextPlatform() throws ExecutionException, InterruptedException, TimeoutException { + Context context = Contexts.globalContext(); + context.register("hello", "world"); + CompletableFuture> completableFuture = Contexts.runInContext(context, bean::verifyContextPlatform); + Optional optional = completableFuture.get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + assertThat(optional.orElseThrow(), is("world")); + } + + @Test + void testEternallyBlocked() throws Exception { + CompletableFuture completableFuture = bean.eternallyBlocked(); + assertThrows(TimeoutException.class, + () -> completableFuture.get(SHORT_TIMEOUT, TimeUnit.MILLISECONDS)); + completableFuture.cancel(true); + assertThrows(CancellationException.class, + () -> completableFuture.get(LONG_TIMEOUT, TimeUnit.MILLISECONDS)); + } + + @Test + void testAlwaysFails() { + CompletableFuture completableFuture = bean.alwaysFails(); + try { + completableFuture.get(LONG_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + assertThat(e.getCause(), is(instanceOf(UnsupportedOperationException.class))); + } catch (Exception e) { + fail(); + } + } +} From 253e8a03c7d12bcc57d6a74e7eac15778de689ea Mon Sep 17 00:00:00 2001 From: Joe DiPol Date: Thu, 5 Dec 2024 17:19:06 -0800 Subject: [PATCH 33/36] Upgrade ASM and Byte Buddy for Java 24 support (#9571) --- applications/mp/pom.xml | 2 +- dependencies/pom.xml | 2 +- pom.xml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/applications/mp/pom.xml b/applications/mp/pom.xml index 6be5ed09bce..4afd4b5d3c6 100644 --- a/applications/mp/pom.xml +++ b/applications/mp/pom.xml @@ -58,7 +58,7 @@ org.hibernate.orm.tooling hibernate-enhance-maven-plugin ${version.plugin.hibernate-enhance} - + net.bytebuddy diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 9ab5bda6a4a..656cdf613be 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -40,7 +40,7 @@ 1.18 1.3.5 1.0.0 - 1.14.18 + 1.15.10 1.16.0 1.2 9.2.1 diff --git a/pom.xml b/pom.xml index 0b57c348ff2..cbb49120968 100644 --- a/pom.xml +++ b/pom.xml @@ -63,7 +63,7 @@ Changing these version requires approval for a new third party dependency! --> 1.7.0.Final - 9.7 + 9.7.1 10.12.5 2.14.0 2.4.14 @@ -703,7 +703,7 @@ org.hibernate.orm.tooling hibernate-enhance-maven-plugin ${version.plugin.hibernate-enhance} - + net.bytebuddy From 5f48279a9cb322b5089e01c4371763bd1175a02a Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Fri, 6 Dec 2024 11:17:33 -0500 Subject: [PATCH 34/36] Adds support for send(byte[],int,int) to server responses (#9569) Adds support for send(byte[], int, int) to ServerResponse. Signed-off-by: Santiago Pericas-Geertsen --- .../webserver/http2/Http2ServerResponse.java | 30 ++++-- .../webserver/tests/http2/SendBytesTest.java | 91 +++++++++++++++++++ .../webserver/tests/SendBytesTest.java | 79 ++++++++++++++++ .../webserver/http/ServerResponse.java | 14 ++- .../webserver/http/ServerResponseBase.java | 21 ++++- .../webserver/http1/Http1ServerResponse.java | 35 ++++--- 6 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java create mode 100644 webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java index 374cdb5ba28..23a597d9b29 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java @@ -77,11 +77,17 @@ public Http2ServerResponse header(Header header) { @Override public void send(byte[] entityBytes) { + send(entityBytes, 0, entityBytes.length); + } + + + @Override + public void send(byte[] entityBytes, int position, int length) { try { if (outputStreamFilter != null) { // in this case we must honor user's request to filter the stream try (OutputStream os = outputStream()) { - os.write(entityBytes); + os.write(entityBytes, position, length); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -98,14 +104,21 @@ public void send(byte[] entityBytes) { isSent = true; // handle content encoding - byte[] bytes = entityBytes(entityBytes); + int actualLength = length; + int actualPosition = position; + byte[] actualBytes = entityBytes(entityBytes, position, length); + if (entityBytes != actualBytes) { // encoding happened, new byte array + actualPosition = 0; + actualLength = actualBytes.length; + } headers.setIfAbsent(HeaderValues.create(HeaderNames.CONTENT_LENGTH, true, false, - String.valueOf(bytes.length))); - headers.setIfAbsent(HeaderValues.create(HeaderNames.DATE, true, false, DateTime.rfc1123String())); - + String.valueOf(actualLength))); + headers.setIfAbsent(HeaderValues.create(HeaderNames.DATE, true, + false, + DateTime.rfc1123String())); Http2Headers http2Headers = Http2Headers.create(headers); http2Headers.status(status()); headers.remove(Http2Headers.STATUS_NAME, it -> ctx.log(LOGGER, @@ -113,10 +126,13 @@ public void send(byte[] entityBytes) { "Status must be configured on response, " + "do not set HTTP/2 pseudo headers")); - boolean sendTrailers = request.headers().contains(HeaderValues.TE_TRAILERS) || headers.contains(HeaderNames.TRAILER); + boolean sendTrailers = request.headers().contains(HeaderValues.TE_TRAILERS) + || headers.contains(HeaderNames.TRAILER); http2Headers.validateResponse(); - bytesWritten += stream.writeHeadersWithData(http2Headers, bytes.length, BufferData.create(bytes), !sendTrailers); + bytesWritten += stream.writeHeadersWithData(http2Headers, actualLength, + BufferData.create(actualBytes, actualPosition, actualLength), + !sendTrailers); if (sendTrailers) { bytesWritten += stream.writeTrailers(Http2Headers.create(trailers)); diff --git a/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java new file mode 100644 index 00000000000..ef7e3828733 --- /dev/null +++ b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/SendBytesTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.tests.http2; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import io.helidon.http.Status; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +@ServerTest +class SendBytesTest { + private static final int START = 16; + private static final int LENGTH = 9; + private static final String ENTITY = "The quick brown fox jumps over the lazy dog"; + + private final HttpClient client; + private final URI uri; + + SendBytesTest(URI uri) { + this.uri = uri; + client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } + + @SetUpRoute + static void routing(HttpRules rules) { + rules.get("/sendAll", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8))) + .get("/sendPart", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8), START, LENGTH)); + } + + /** + * Test getting all the entity. + */ + @Test + void testAll() throws IOException, InterruptedException { + HttpResponse response = client.send(HttpRequest.newBuilder() + .uri(uri.resolve("/sendAll")) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode(), is(Status.OK_200.code())); + assertThat(response.version(), is(HttpClient.Version.HTTP_2)); + String entity = response.body(); + assertThat(entity, is(ENTITY)); + } + + /** + * Test getting part of the entity. + */ + @Test + void testPart() throws IOException, InterruptedException { + HttpResponse response = client.send(HttpRequest.newBuilder() + .uri(uri.resolve("/sendPart")) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode(), is(Status.OK_200.code())); + assertThat(response.version(), is(HttpClient.Version.HTTP_2)); + String entity = response.body(); + assertThat(entity, is(ENTITY.substring(START, START + LENGTH))); + } +} diff --git a/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java new file mode 100644 index 00000000000..a250ff397a7 --- /dev/null +++ b/webserver/tests/webserver/src/test/java/io/helidon/webserver/tests/SendBytesTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed 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 + * + * http://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 io.helidon.webserver.tests; + +import java.nio.charset.StandardCharsets; + +import io.helidon.http.Status; +import io.helidon.webclient.api.HttpClientResponse; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests sending a part of a byte array. + */ +@ServerTest +class SendBytesTest { + private static final int START = 16; + private static final int LENGTH = 9; + private static final String ENTITY = "The quick brown fox jumps over the lazy dog"; + + private final Http1Client http1Client; + + SendBytesTest(Http1Client http1Client) { + this.http1Client = http1Client; + } + + @SetUpRoute + static void routing(HttpRules rules) { + rules.get("/sendAll", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8))) + .get("/sendPart", (req, res) -> + res.send(ENTITY.getBytes(StandardCharsets.UTF_8), START, LENGTH)); + } + + /** + * Test getting all the entity. + */ + @Test + void testAll() { + try (HttpClientResponse r = http1Client.get("/sendAll").request()) { + String s = r.entity().as(String.class); + assertThat(r.status(), is(Status.OK_200)); + assertThat(s, is(ENTITY)); + } + } + + /** + * Test getting part of the entity. + */ + @Test + void testPart() { + try (HttpClientResponse r = http1Client.get("/sendPart").request()) { + String s = r.entity().as(String.class); + assertThat(r.status(), is(Status.OK_200)); + assertThat(s, is(ENTITY.substring(START, START + LENGTH))); + } + } +} diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java index 0e29c829150..8f2820e2cba 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponse.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package io.helidon.webserver.http; import java.io.OutputStream; +import java.util.Arrays; import java.util.Optional; import java.util.function.UnaryOperator; @@ -112,6 +113,17 @@ default ServerResponse header(String name, String... values) { */ void send(byte[] bytes); + /** + * Send a byte array response. + * + * @param bytes bytes to send + * @param position starting position + * @param length number of bytes send + */ + default void send(byte[] bytes, int position, int length) { + send(Arrays.copyOfRange(bytes, position, length)); + } + /** * Send an entity, a {@link io.helidon.http.media.MediaContext} will be used to serialize the entity. * diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java index b5ee32e61b9..3939c1125b0 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerResponseBase.java @@ -201,17 +201,30 @@ protected MediaContext mediaContext() { * if entity is empty. * * @param configuredEntity plain bytes - * @return encoded bytes + * @return encoded bytes or same entity array if encoding is disabled */ protected byte[] entityBytes(byte[] configuredEntity) { + return entityBytes(configuredEntity, 0, configuredEntity.length); + } + + /** + * Entity bytes encoded using content encoding. Does not attempt encoding + * if entity is empty. + * + * @param configuredEntity plain bytes + * @param position starting position + * @param length number of bytes + * @return encoded bytes or same entity array if encoding is disabled + */ + protected byte[] entityBytes(byte[] configuredEntity, int position, int length) { byte[] entity = configuredEntity; - if (contentEncodingContext.contentEncodingEnabled() && entity.length > 0) { + if (contentEncodingContext.contentEncodingEnabled() && length > 0) { ContentEncoder encoder = contentEncodingContext.encoder(requestHeaders); // we want to preserve optimization here, let's create a new byte array - ByteArrayOutputStream baos = new ByteArrayOutputStream(entity.length); + ByteArrayOutputStream baos = new ByteArrayOutputStream(length); OutputStream os = encoder.apply(baos); try { - os.write(entity); + os.write(entity, position, length); os.close(); } catch (IOException e) { throw new ServerConnectionException("Failed to write response", e); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java index 03abb2ce312..8336ebf0598 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerResponse.java @@ -191,16 +191,22 @@ public Http1ServerResponse header(Header header) { */ @Override public void send(byte[] bytes) { + send(bytes, 0, bytes.length); + } + + @Override + public void send(byte[] bytes, int position, int length) { // if no entity status, we cannot send bytes here - if (isNoEntityStatus && bytes.length > 0) { + if (isNoEntityStatus && length > 0) { status(noEntityInternalError(status())); return; } // send bytes to writer if (outputStreamFilter == null && !headers.contains(HeaderNames.TRAILER)) { - byte[] entity = entityBytes(bytes); - BufferData bufferData = responseBuffer(entity); + byte[] entity = entityBytes(bytes, position, length); + BufferData bufferData = (bytes != entity) ? responseBuffer(entity) + : responseBuffer(entity, position, length); // no encoding, same length bytesWritten = bufferData.available(); isSent = true; request.reset(); @@ -208,9 +214,9 @@ public void send(byte[] bytes) { afterSend(); } else { // we should skip encoders if no data is written (e.g. for GZIP) - boolean skipEncoders = (bytes.length == 0); + boolean skipEncoders = (length == 0); try (OutputStream os = outputStream(skipEncoders)) { - os.write(bytes); + os.write(bytes, position, length); } catch (IOException e) { throw new ServerConnectionException("Failed to write response", e); } @@ -371,6 +377,10 @@ private static void writeHeaders(io.helidon.http.Headers headers, BufferData buf } private BufferData responseBuffer(byte[] bytes) { + return responseBuffer(bytes, 0, bytes.length); + } + + private BufferData responseBuffer(byte[] bytes, int position, int length) { if (isSent) { throw new IllegalStateException("Response already sent"); } @@ -379,7 +389,6 @@ private BufferData responseBuffer(byte[] bytes) { + ", do not call send()."); } - int contentLength = bytes.length; boolean forcedChunkedEncoding = false; headers.setIfAbsent(HeaderValues.CONNECTION_KEEP_ALIVE); @@ -387,10 +396,8 @@ private BufferData responseBuffer(byte[] bytes) { headers.remove(HeaderNames.CONTENT_LENGTH); // chunked enforced (and even if empty entity, will be used) forcedChunkedEncoding = true; - } else { - if (!headers.contains(HeaderNames.CONTENT_LENGTH)) { - headers.contentLength(contentLength); - } + } else if (!headers.contains(HeaderNames.CONTENT_LENGTH)) { + headers.contentLength(length); } Status usedStatus = status(); @@ -398,20 +405,20 @@ private BufferData responseBuffer(byte[] bytes) { sendListener.headers(ctx, headers); // give some space for code and headers + entity - BufferData responseBuffer = BufferData.growing(256 + bytes.length); + BufferData responseBuffer = BufferData.growing(256 + length); nonEntityBytes(headers, usedStatus, responseBuffer, keepAlive, validateHeaders); if (forcedChunkedEncoding) { - byte[] hex = Integer.toHexString(contentLength).getBytes(StandardCharsets.US_ASCII); + byte[] hex = Integer.toHexString(length).getBytes(StandardCharsets.US_ASCII); responseBuffer.write(hex); responseBuffer.write('\r'); responseBuffer.write('\n'); - responseBuffer.write(bytes); + responseBuffer.write(bytes, position, length); responseBuffer.write('\r'); responseBuffer.write('\n'); responseBuffer.write(TERMINATING_CHUNK); } else { - responseBuffer.write(bytes); + responseBuffer.write(bytes, position, length); } sendListener.data(ctx, responseBuffer); From a69cc7f7bbf2d1b26a52747f1e662ace80ef18c4 Mon Sep 17 00:00:00 2001 From: Thibault Vallin Date: Mon, 9 Dec 2024 10:37:30 +0100 Subject: [PATCH 35/36] 4.x: Add additional eclipselink native image configuration --- .../reflect-config.json | 8 ++++++++ .../org.eclipse.persistence.core/resource-config.json | 3 +++ 2 files changed, 11 insertions(+) diff --git a/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/io/helidon/integrations/cdi/helidon-integrations-cdi-eclipselink/reflect-config.json b/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/io/helidon/integrations/cdi/helidon-integrations-cdi-eclipselink/reflect-config.json index efc118d5325..ff33688f35d 100644 --- a/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/io/helidon/integrations/cdi/helidon-integrations-cdi-eclipselink/reflect-config.json +++ b/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/io/helidon/integrations/cdi/helidon-integrations-cdi-eclipselink/reflect-config.json @@ -49,5 +49,13 @@ "name": "" } ] + }, + { + "name": "org.eclipse.persistence.internal.jpa.jpql.HermesParser", + "methods": [ + { + "name": "" + } + ] } ] diff --git a/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/org/eclipse/persistence/org.eclipse.persistence.core/resource-config.json b/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/org/eclipse/persistence/org.eclipse.persistence.core/resource-config.json index 3edd34f97cc..5df7f5cc1d8 100644 --- a/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/org/eclipse/persistence/org.eclipse.persistence.core/resource-config.json +++ b/integrations/cdi/eclipselink-cdi/src/main/resources/META-INF/native-image/org/eclipse/persistence/org.eclipse.persistence.core/resource-config.json @@ -6,6 +6,9 @@ { "name": "org.eclipse.persistence.exceptions.i18n.ValidationExceptionResource" }, + { + "name": "org.eclipse.persistence.exceptions.i18n.ConversionExceptionResource" + }, { "name": "org.eclipse.persistence.internal.localization.i18n.EclipseLinkLocalizationResource" }, From 4591886612a015f27d0a71081310b20290049e6a Mon Sep 17 00:00:00 2001 From: Santiago Pericas-Geertsen Date: Mon, 9 Dec 2024 13:32:20 -0500 Subject: [PATCH 36/36] Enables support to turn on/off proxy protocol in config. (#9577) --- .../webserver/ListenerConfigBlueprint.java | 1 + .../helidon/webserver/ListenerConfigTest.java | 20 +++++++++++++++++++ .../src/test/resources/application.yaml | 4 +++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ListenerConfigBlueprint.java b/webserver/webserver/src/main/java/io/helidon/webserver/ListenerConfigBlueprint.java index 61925e90da4..c5d1f6d70e5 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ListenerConfigBlueprint.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ListenerConfigBlueprint.java @@ -366,6 +366,7 @@ interface ListenerConfigBlueprint { * * @return proxy support status */ + @Option.Configured @Option.Default("false") boolean enableProxyProtocol(); diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/ListenerConfigTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/ListenerConfigTest.java index 23718f0388e..067b29e1125 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/ListenerConfigTest.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/ListenerConfigTest.java @@ -57,4 +57,24 @@ void testSpecificListenerConfigFromConfigFile() { assertThat(listenerConfig.shutdownGracePeriod().toMillis(), is(2000L)); } + @Test + void testEnableProxyProtocolConfig() { + Config config = Config.create(); + + // default is false in default socket + var webServerConfig = WebServer.builder().config(config.get("server")).buildPrototype(); + assertThat(webServerConfig.enableProxyProtocol(), is(false)); + ListenerConfig otherConfig = webServerConfig.sockets().get("other"); + assertThat(otherConfig.enableProxyProtocol(), is(false)); + + // set to true in default socket + var webServerConfig2 = WebServer.builder().config(config.get("server2")).buildPrototype(); + assertThat(webServerConfig2.enableProxyProtocol(), is(true)); + + // set to true in non-default socket + var webServerConfig3 = WebServer.builder().config(config.get("server3")).buildPrototype(); + assertThat(webServerConfig3.enableProxyProtocol(), is(false)); + ListenerConfig graceConfig = webServerConfig3.sockets().get("grace"); + assertThat(graceConfig.enableProxyProtocol(), is(true)); + } } diff --git a/webserver/webserver/src/test/resources/application.yaml b/webserver/webserver/src/test/resources/application.yaml index ba46fec6787..1fe789becc3 100644 --- a/webserver/webserver/src/test/resources/application.yaml +++ b/webserver/webserver/src/test/resources/application.yaml @@ -1,5 +1,5 @@ # -# Copyright (c) 2022, 2023 Oracle and/or its affiliates. +# Copyright (c) 2022, 2024 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -45,6 +45,7 @@ server2: port: 8079 host: 127.0.0.1 shutdown-grace-period: PT1S + enable-proxy-protocol: true connection-providers-discover-services: false media-context: @@ -57,6 +58,7 @@ server3: sockets: - name: "grace" shutdown-grace-period: PT2S + enable-proxy-protocol: true inject: permits-dynamic: true
ISO_8601 format examples: