diff --git a/.github/workflows/actions_build.yml b/.github/workflows/actions_build.yml index ddd7166a80c..14602c7abd7 100644 --- a/.github/workflows/actions_build.yml +++ b/.github/workflows/actions_build.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - on: [ ubicloud-standard-8, macos-12, windows-latest ] + on: [ ubicloud-standard-8, macos-latest, windows-latest ] java: [ 21 ] include: - java: 8 diff --git a/.github/workflows/publish-site.yml b/.github/workflows/publish-site.yml index 67e34bcb5b7..9311acb3874 100644 --- a/.github/workflows/publish-site.yml +++ b/.github/workflows/publish-site.yml @@ -1,6 +1,12 @@ name: Publish Armeria site on: + workflow_dispatch: + inputs: + version: + description: 'Release Version' + required: true + type: string push: tags: - armeria-* @@ -32,7 +38,11 @@ jobs: - name: Build the site run: | - ./gradlew --no-daemon --stacktrace --max-workers=2 --parallel -PgithubToken=${{ secrets.GITHUB_TOKEN }} site + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + ./gradlew --no-daemon --stacktrace --max-workers=2 --parallel -PgithubToken=${{ secrets.GITHUB_TOKEN }} -Pversion=${{ inputs.version }} site + else + ./gradlew --no-daemon --stacktrace --max-workers=2 --parallel -PgithubToken=${{ secrets.GITHUB_TOKEN }} site + fi - name: Deploy the site uses: peaceiris/actions-gh-pages@v4 diff --git a/.scalafmt.conf b/.scalafmt.conf index df746a77f3c..377d9a5a4f1 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.3.2" +version = "3.8.3" style = default diff --git a/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java b/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java index f91d2226685..3804c10c922 100644 --- a/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java +++ b/benchmarks/jmh/src/jmh/java/com/linecorp/armeria/server/RoutersBenchmark.java @@ -61,7 +61,7 @@ public class RoutersBenchmark { FALLBACK_SERVICE = newServiceConfig(Route.ofCatchAll()); HOST = new VirtualHost( "localhost", "localhost", 0, null, - null, SERVICES, FALLBACK_SERVICE, RejectedRouteHandler.DISABLED, + null, null, SERVICES, FALLBACK_SERVICE, RejectedRouteHandler.DISABLED, unused -> NOPLogger.NOP_LOGGER, FALLBACK_SERVICE.defaultServiceNaming(), FALLBACK_SERVICE.defaultLogName(), 0, 0, false, AccessLogWriter.disabled(), CommonPools.blockingTaskExecutor(), 0, SuccessFunction.ofDefault(), diff --git a/build.gradle b/build.gradle index 2a28be232be..c63e12570d5 100644 --- a/build.gradle +++ b/build.gradle @@ -117,6 +117,10 @@ allprojects { doFirst { addTestOutputListener({ descriptor, event -> if (event.message.contains('LEAK: ')) { + if (isCi) { + logger.warn("Leak is detected in ${descriptor.className}.${descriptor.displayName}\n" + + "${event.message}") + } hasLeak.set(true) } }) @@ -485,3 +489,7 @@ allprojects { } } } + +configure(projectsWithFlags('java', 'publish')) { + failOnVersionConflict(libs.protobuf.java) +} diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java b/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java index ac8d7cd7709..50cd5b966f1 100644 --- a/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractClientOptionsBuilder.java @@ -29,6 +29,8 @@ import java.util.function.Function; import java.util.function.Supplier; +import com.google.common.collect.ImmutableList; + import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.client.redirect.RedirectConfig; import com.linecorp.armeria.common.HttpHeaderNames; @@ -532,20 +534,20 @@ protected final ClientOptions buildOptions() { */ protected final ClientOptions buildOptions(@Nullable ClientOptions baseOptions) { final Collection> optVals = options.values(); - final int numOpts = optVals.size(); - final int extra = contextCustomizer == null ? 3 : 4; - final ClientOptionValue[] optValArray = optVals.toArray(new ClientOptionValue[numOpts + extra]); - optValArray[numOpts] = ClientOptions.DECORATION.newValue(decoration.build()); - optValArray[numOpts + 1] = ClientOptions.HEADERS.newValue(headers.build()); - optValArray[numOpts + 2] = ClientOptions.CONTEXT_HOOK.newValue(contextHook); + final ImmutableList.Builder> additionalValues = + ImmutableList.builder(); + additionalValues.addAll(optVals); + additionalValues.add(ClientOptions.DECORATION.newValue(decoration.build())); + additionalValues.add(ClientOptions.HEADERS.newValue(headers.build())); + additionalValues.add(ClientOptions.CONTEXT_HOOK.newValue(contextHook)); if (contextCustomizer != null) { - optValArray[numOpts + 3] = ClientOptions.CONTEXT_CUSTOMIZER.newValue(contextCustomizer); + additionalValues.add(ClientOptions.CONTEXT_CUSTOMIZER.newValue(contextCustomizer)); } if (baseOptions != null) { - return ClientOptions.of(baseOptions, optValArray); + return ClientOptions.of(baseOptions, additionalValues.build()); } else { - return ClientOptions.of(optValArray); + return ClientOptions.of(additionalValues.build()); } } } diff --git a/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java b/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java index 6849c11f216..ac4616ef6e4 100644 --- a/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java +++ b/core/src/main/java/com/linecorp/armeria/client/Bootstraps.java @@ -26,6 +26,8 @@ import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.common.SslContextFactory.SslContextMode; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; @@ -36,56 +38,42 @@ final class Bootstraps { - private final Bootstrap[][] inetBootstraps; - private final Bootstrap @Nullable [][] unixBootstraps; private final EventLoop eventLoop; private final SslContext sslCtxHttp1Only; private final SslContext sslCtxHttp1Or2; + @Nullable + private final SslContextFactory sslContextFactory; + + private final HttpClientFactory clientFactory; + private final Bootstrap inetBaseBootstrap; + @Nullable + private final Bootstrap unixBaseBootstrap; + private final Bootstrap[][] inetBootstraps; + private final Bootstrap @Nullable [][] unixBootstraps; - Bootstraps(HttpClientFactory clientFactory, EventLoop eventLoop, SslContext sslCtxHttp1Or2, - SslContext sslCtxHttp1Only) { + Bootstraps(HttpClientFactory clientFactory, EventLoop eventLoop, + SslContext sslCtxHttp1Or2, SslContext sslCtxHttp1Only, + @Nullable SslContextFactory sslContextFactory) { this.eventLoop = eventLoop; this.sslCtxHttp1Or2 = sslCtxHttp1Or2; this.sslCtxHttp1Only = sslCtxHttp1Only; + this.sslContextFactory = sslContextFactory; + this.clientFactory = clientFactory; + + inetBaseBootstrap = clientFactory.newInetBootstrap(); + inetBaseBootstrap.group(eventLoop); + inetBootstraps = staticBootstrapMap(inetBaseBootstrap); - final Bootstrap inetBaseBootstrap = clientFactory.newInetBootstrap(); - final Bootstrap unixBaseBootstrap = clientFactory.newUnixBootstrap(); - inetBootstraps = newBootstrapMap(inetBaseBootstrap, clientFactory, eventLoop); + unixBaseBootstrap = clientFactory.newUnixBootstrap(); if (unixBaseBootstrap != null) { - unixBootstraps = newBootstrapMap(unixBaseBootstrap, clientFactory, eventLoop); + unixBaseBootstrap.group(eventLoop); + unixBootstraps = staticBootstrapMap(unixBaseBootstrap); } else { unixBootstraps = null; } } - /** - * Returns a {@link Bootstrap} corresponding to the specified {@link SocketAddress} - * {@link SessionProtocol} and {@link SerializationFormat}. - */ - Bootstrap get(SocketAddress remoteAddress, SessionProtocol desiredProtocol, - SerializationFormat serializationFormat) { - if (!httpAndHttpsValues().contains(desiredProtocol)) { - throw new IllegalArgumentException("Unsupported session protocol: " + desiredProtocol); - } - - if (remoteAddress instanceof InetSocketAddress) { - return select(inetBootstraps, desiredProtocol, serializationFormat); - } - - assert remoteAddress instanceof DomainSocketAddress : remoteAddress; - - if (unixBootstraps == null) { - throw new IllegalArgumentException("Domain sockets are not supported by " + - eventLoop.getClass().getName()); - } - - return select(unixBootstraps, desiredProtocol, serializationFormat); - } - - private Bootstrap[][] newBootstrapMap(Bootstrap baseBootstrap, - HttpClientFactory clientFactory, - EventLoop eventLoop) { - baseBootstrap.group(eventLoop); + private Bootstrap[][] staticBootstrapMap(Bootstrap baseBootstrap) { final Set sessionProtocols = httpAndHttpsValues(); final Bootstrap[][] maps = (Bootstrap[][]) Array.newInstance( Bootstrap.class, SessionProtocol.values().length, 2); @@ -93,8 +81,8 @@ private Bootstrap[][] newBootstrapMap(Bootstrap baseBootstrap, // which will help us find a bug. for (SessionProtocol p : sessionProtocols) { final SslContext sslCtx = determineSslContext(p); - setBootstrap(baseBootstrap.clone(), clientFactory, maps, p, sslCtx, true); - setBootstrap(baseBootstrap.clone(), clientFactory, maps, p, sslCtx, false); + createAndSetBootstrap(baseBootstrap, maps, p, sslCtx, true); + createAndSetBootstrap(baseBootstrap, maps, p, sslCtx, false); } return maps; } @@ -106,22 +94,18 @@ SslContext determineSslContext(SessionProtocol desiredProtocol) { return desiredProtocol.isExplicitHttp1() ? sslCtxHttp1Only : sslCtxHttp1Or2; } - private static Bootstrap select(Bootstrap[][] bootstraps, SessionProtocol desiredProtocol, - SerializationFormat serializationFormat) { + private Bootstrap select(boolean isDomainSocket, SessionProtocol desiredProtocol, + SerializationFormat serializationFormat) { + final Bootstrap[][] bootstraps = isDomainSocket ? unixBootstraps : inetBootstraps; + assert bootstraps != null; return bootstraps[desiredProtocol.ordinal()][toIndex(serializationFormat)]; } - private static void setBootstrap(Bootstrap bootstrap, HttpClientFactory clientFactory, Bootstrap[][] maps, - SessionProtocol p, SslContext sslCtx, boolean webSocket) { - bootstrap.handler(new ChannelInitializer() { - @Override - protected void initChannel(Channel ch) throws Exception { - ch.pipeline().addLast(new HttpClientPipelineConfigurator( - clientFactory, webSocket, p, sslCtx)); - } - } - ); - maps[p.ordinal()][toIndex(webSocket)] = bootstrap; + private void createAndSetBootstrap(Bootstrap baseBootstrap, Bootstrap[][] maps, + SessionProtocol desiredProtocol, SslContext sslContext, + boolean webSocket) { + maps[desiredProtocol.ordinal()][toIndex(webSocket)] = newBootstrap(baseBootstrap, desiredProtocol, + sslContext, webSocket, false); } private static int toIndex(boolean webSocket) { @@ -131,4 +115,92 @@ private static int toIndex(boolean webSocket) { private static int toIndex(SerializationFormat serializationFormat) { return toIndex(serializationFormat == SerializationFormat.WS); } + + /** + * Returns a {@link Bootstrap} corresponding to the specified {@link SocketAddress} + * {@link SessionProtocol} and {@link SerializationFormat}. + */ + Bootstrap getOrCreate(SocketAddress remoteAddress, SessionProtocol desiredProtocol, + SerializationFormat serializationFormat) { + if (!httpAndHttpsValues().contains(desiredProtocol)) { + throw new IllegalArgumentException("Unsupported session protocol: " + desiredProtocol); + } + + final boolean isDomainSocket = remoteAddress instanceof DomainSocketAddress; + if (isDomainSocket && unixBaseBootstrap == null) { + throw new IllegalArgumentException("Domain sockets are not supported by " + + eventLoop.getClass().getName()); + } + + if (sslContextFactory == null || !desiredProtocol.isTls()) { + return select(isDomainSocket, desiredProtocol, serializationFormat); + } + + final Bootstrap baseBootstrap = isDomainSocket ? unixBaseBootstrap : inetBaseBootstrap; + assert baseBootstrap != null; + return newBootstrap(baseBootstrap, remoteAddress, desiredProtocol, serializationFormat); + } + + private Bootstrap newBootstrap(Bootstrap baseBootstrap, SocketAddress remoteAddress, + SessionProtocol desiredProtocol, + SerializationFormat serializationFormat) { + final boolean webSocket = serializationFormat == SerializationFormat.WS; + final SslContext sslContext = newSslContext(remoteAddress, desiredProtocol); + return newBootstrap(baseBootstrap, desiredProtocol, sslContext, webSocket, true); + } + + private Bootstrap newBootstrap(Bootstrap baseBootstrap, SessionProtocol desiredProtocol, + SslContext sslContext, boolean webSocket, boolean closeSslContext) { + final Bootstrap bootstrap = baseBootstrap.clone(); + bootstrap.handler(clientChannelInitializer(desiredProtocol, sslContext, webSocket, closeSslContext)); + return bootstrap; + } + + SslContext getOrCreateSslContext(SocketAddress remoteAddress, SessionProtocol desiredProtocol) { + if (sslContextFactory == null) { + return determineSslContext(desiredProtocol); + } else { + return newSslContext(remoteAddress, desiredProtocol); + } + } + + private SslContext newSslContext(SocketAddress remoteAddress, SessionProtocol desiredProtocol) { + final String hostname; + if (remoteAddress instanceof InetSocketAddress) { + hostname = ((InetSocketAddress) remoteAddress).getHostString(); + } else { + assert remoteAddress instanceof DomainSocketAddress; + hostname = "unix:" + ((DomainSocketAddress) remoteAddress).path(); + } + + final SslContextMode sslContextMode = + desiredProtocol.isExplicitHttp1() ? SslContextFactory.SslContextMode.CLIENT_HTTP1_ONLY + : SslContextFactory.SslContextMode.CLIENT; + assert sslContextFactory != null; + return sslContextFactory.getOrCreate(sslContextMode, hostname); + } + + boolean shouldReleaseSslContext(SslContext sslContext) { + return sslContext != sslCtxHttp1Only && sslContext != sslCtxHttp1Or2; + } + + void releaseSslContext(SslContext sslContext) { + if (sslContextFactory != null) { + sslContextFactory.release(sslContext); + } + } + + private ChannelInitializer clientChannelInitializer(SessionProtocol p, SslContext sslCtx, + boolean webSocket, boolean closeSslContext) { + return new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) throws Exception { + if (closeSslContext) { + ch.closeFuture().addListener(unused -> releaseSslContext(sslCtx)); + } + ch.pipeline().addLast(new HttpClientPipelineConfigurator( + clientFactory, webSocket, p, sslCtx)); + } + }; + } } diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java index 92a28d5c825..029ffab983e 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java @@ -24,10 +24,7 @@ import static io.netty.handler.codec.http2.Http2CodecUtil.MAX_INITIAL_WINDOW_SIZE; import static java.util.Objects.requireNonNull; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.IOError; -import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; import java.net.ProxySelector; @@ -53,7 +50,6 @@ import com.google.common.base.MoreObjects.ToStringHelper; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.google.common.io.ByteStreams; import com.google.common.primitives.Ints; import com.linecorp.armeria.client.proxy.ProxyConfig; @@ -63,12 +59,15 @@ import com.linecorp.armeria.common.Http1HeaderNaming; import com.linecorp.armeria.common.Request; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.TlsSetters; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.outlier.OutlierDetection; import com.linecorp.armeria.common.util.EventLoopGroups; import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.IgnoreHostsTrustManager; import com.linecorp.armeria.internal.common.RequestContextUtil; import com.linecorp.armeria.internal.common.util.ChannelUtil; @@ -127,6 +126,11 @@ public final class ClientFactoryBuilder implements TlsSetters { private final List> maxNumEventLoopsFunctions = new ArrayList<>(); private boolean tlsNoVerifySet; private final Set insecureHosts = new HashSet<>(); + @Nullable + private TlsProvider tlsProvider; + @Nullable + private ClientTlsConfig tlsConfig; + private boolean staticTlsSettingsSet; ClientFactoryBuilder() { connectTimeoutMillis(Flags.defaultConnectTimeoutMillis()); @@ -286,6 +290,7 @@ private void channelOptions(Map, Object> newChannelOptions) { */ public ClientFactoryBuilder tlsNoVerify() { checkState(insecureHosts.isEmpty(), "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + ensureNoTlsProvider(); tlsNoVerifySet = true; return this; } @@ -299,6 +304,7 @@ public ClientFactoryBuilder tlsNoVerify() { */ public ClientFactoryBuilder tlsNoVerifyHosts(String... insecureHosts) { checkState(!tlsNoVerifySet, "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + ensureNoTlsProvider(); this.insecureHosts.addAll(Arrays.asList(insecureHosts)); return this; } @@ -306,7 +312,10 @@ public ClientFactoryBuilder tlsNoVerifyHosts(String... insecureHosts) { /** * Configures SSL or TLS for client certificate authentication with the specified {@code keyCertChainFile} * and cleartext {@code keyFile}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(File keyCertChainFile, File keyFile) { return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile); @@ -315,18 +324,22 @@ public ClientFactoryBuilder tls(File keyCertChainFile, File keyFile) { /** * Configures SSL or TLS for client certificate authentication with the specified {@code keyCertChainFile}, * {@code keyFile} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword) { - requireNonNull(keyCertChainFile, "keyCertChainFile"); - requireNonNull(keyFile, "keyFile"); - return tlsCustomizer(customizer -> customizer.keyManager(keyCertChainFile, keyFile, keyPassword)); + return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile, keyPassword); } /** * Configures SSL or TLS for client certificate authentication with the specified * {@code keyCertChainInputStream} and cleartext {@code keyInputStream}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream); @@ -335,32 +348,26 @@ public ClientFactoryBuilder tls(InputStream keyCertChainInputStream, InputStream /** * Configures SSL or TLS for client certificate authentication with the specified * {@code keyCertChainInputStream} and {@code keyInputStream} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword) { requireNonNull(keyCertChainInputStream, "keyCertChainInputStream"); requireNonNull(keyInputStream, "keyInputStream"); - - // Retrieve the content of the given streams so that they can be consumed more than once. - final byte[] keyCertChain; - final byte[] key; - try { - keyCertChain = ByteStreams.toByteArray(keyCertChainInputStream); - key = ByteStreams.toByteArray(keyInputStream); - } catch (IOException e) { - throw new IOError(e); - } - - return tlsCustomizer(customizer -> customizer.keyManager(new ByteArrayInputStream(keyCertChain), - new ByteArrayInputStream(key), - keyPassword)); + return (ClientFactoryBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream, + keyPassword); } /** * Configures SSL or TLS for client certificate authentication with the specified cleartext * {@link PrivateKey} and {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} or {@link #tlsProvider(TlsProvider)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(PrivateKey key, X509Certificate... keyCertChain) { return (ClientFactoryBuilder) TlsSetters.super.tls(key, keyCertChain); @@ -369,7 +376,10 @@ public ClientFactoryBuilder tls(PrivateKey key, X509Certificate... keyCertChain) /** * Configures SSL or TLS for client certificate authentication with the specified cleartext * {@link PrivateKey} and {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} with {@link TlsKeyPair#of(PrivateKey, Iterable)} instead. */ + @Deprecated @Override public ClientFactoryBuilder tls(PrivateKey key, Iterable keyCertChain) { return (ClientFactoryBuilder) TlsSetters.super.tls(key, keyCertChain); @@ -378,7 +388,11 @@ public ClientFactoryBuilder tls(PrivateKey key, Iterable keyCertChain) { - requireNonNull(key, "key"); - requireNonNull(keyCertChain, "keyCertChain"); - - for (X509Certificate keyCert : keyCertChain) { - requireNonNull(keyCert, "keyCertChain contains null."); - } + return (ClientFactoryBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); + } - return tlsCustomizer(customizer -> customizer.keyManager(key, keyPassword, keyCertChain)); + /** + * Configures SSL or TLS for client certificate authentication with the specified {@link TlsKeyPair}. + */ + @Override + public ClientFactoryBuilder tls(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + return tlsCustomizer(customizer -> customizer.keyManager(tlsKeyPair.privateKey(), + tlsKeyPair.certificateChain())); } /** @@ -420,6 +440,8 @@ public ClientFactoryBuilder tls(KeyManagerFactory keyManagerFactory) { @Override public ClientFactoryBuilder tlsCustomizer(Consumer tlsCustomizer) { requireNonNull(tlsCustomizer, "tlsCustomizer"); + ensureNoTlsProvider(); + staticTlsSettingsSet = true; @SuppressWarnings("unchecked") final ClientFactoryOptionValue> oldTlsCustomizerValue = (ClientFactoryOptionValue>) @@ -439,6 +461,44 @@ public ClientFactoryBuilder tlsCustomizer(Consumer tl return this; } + /** + * Sets the {@link TlsProvider} that provides {@link TlsKeyPair}s for client certificate authentication. + *
+     * ClientFactory
+     *   .builder()
+     *   .tlsProvider(
+     *     TlsProvider.builder()
+     *                // Set the default key pair.
+     *                .keyPair(TlsKeyPair.of(...))
+     *                // Set the key pair for "example.com".
+     *                .keyPair("example.com", TlsKeyPair.of(...))
+     *                .build())
+     * 
+ */ + @UnstableApi + public ClientFactoryBuilder tlsProvider(TlsProvider tlsProvider) { + requireNonNull(tlsProvider, "tlsProvider"); + checkState(!staticTlsSettingsSet, + "Cannot configure the TlsProvider because static TLS settings have been set already."); + this.tlsProvider = tlsProvider; + tlsConfig = null; + return this; + } + + /** + * Sets the {@link TlsProvider} that provides {@link TlsKeyPair}s for client certificate authentication. + */ + @UnstableApi + public ClientFactoryBuilder tlsProvider(TlsProvider tlsProvider, ClientTlsConfig tlsConfig) { + tlsProvider(tlsProvider); + this.tlsConfig = requireNonNull(tlsConfig, "tlsConfig"); + return this; + } + + private void ensureNoTlsProvider() { + checkState(tlsProvider == null, "Cannot configure TLS settings because a TlsProvider has been set."); + } + /** * Allows the bad cipher suites listed in * RFC7540 for TLS handshake. @@ -959,10 +1019,17 @@ private ClientFactoryOptions buildOptions() { return ClientFactoryOptions.ADDRESS_RESOLVER_GROUP_FACTORY.newValue(addressResolverGroupFactory); }); - if (tlsNoVerifySet) { - tlsCustomizer(b -> b.trustManager(InsecureTrustManagerFactory.INSTANCE)); - } else if (!insecureHosts.isEmpty()) { - tlsCustomizer(b -> b.trustManager(IgnoreHostsTrustManager.of(insecureHosts))); + if (tlsProvider != null) { + option(ClientFactoryOptions.TLS_PROVIDER, tlsProvider); + if (tlsConfig != null) { + option(ClientFactoryOptions.TLS_CONFIG, tlsConfig); + } + } else { + if (tlsNoVerifySet) { + tlsCustomizer(b -> b.trustManager(InsecureTrustManagerFactory.INSTANCE)); + } else if (!insecureHosts.isEmpty()) { + tlsCustomizer(b -> b.trustManager(IgnoreHostsTrustManager.of(insecureHosts))); + } } final ClientFactoryOptions newOptions = ClientFactoryOptions.of(options.values()); diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java index 5042a49bdd5..ae12b569dd6 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientFactoryOptions.java @@ -35,6 +35,8 @@ import com.linecorp.armeria.common.Flags; import com.linecorp.armeria.common.Http1HeaderNaming; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.outlier.OutlierDetection; import com.linecorp.armeria.common.util.AbstractOptions; @@ -46,6 +48,7 @@ import io.netty.channel.ChannelPipeline; import io.netty.channel.EventLoop; import io.netty.channel.EventLoopGroup; +import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.resolver.AddressResolverGroup; @@ -107,6 +110,21 @@ public final class ClientFactoryOptions public static final ClientFactoryOption TLS_ENGINE_TYPE = ClientFactoryOption.define("tlsEngineType", Flags.tlsEngineType()); + /** + * The {@link TlsProvider} which provides the {@link TlsKeyPair} to create the + * {@link SslContext} for TLS handshake. + */ + @UnstableApi + public static final ClientFactoryOption TLS_PROVIDER = + ClientFactoryOption.define("TLS_PROVIDER", NullTlsProvider.INSTANCE); + + /** + * Ths {@link ClientTlsConfig} which is used to configure the client-side TLS. + */ + @UnstableApi + public static final ClientFactoryOption TLS_CONFIG = + ClientFactoryOption.define("TLS_CONFIG", ClientTlsConfig.NOOP); + /** * The factory that creates an {@link AddressResolverGroup} which resolves remote addresses into * {@link InetSocketAddress}es. @@ -654,6 +672,23 @@ public TlsEngineType tlsEngineType() { return get(TLS_ENGINE_TYPE); } + /** + * Returns the {@link TlsProvider} which provides the {@link TlsKeyPair} that is used to create the + * {@link SslContext} for TLS handshake. + */ + @UnstableApi + public TlsProvider tlsProvider() { + return get(TLS_PROVIDER); + } + + /** + * Returns the {@link ClientTlsConfig} which is used to configure the client-side {@link SslContext}. + */ + @UnstableApi + public ClientTlsConfig tlsConfig() { + return get(TLS_CONFIG); + } + /** * The {@link Consumer} that customizes the Netty {@link ChannelPipeline}. * This customizer is run right before {@link ChannelPipeline#connect(SocketAddress)} diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java new file mode 100644 index 00000000000..dea42bafd1c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfig.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.AbstractTlsConfig; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.SslContextBuilder; + +/** + * Provides client-side TLS configuration for {@link TlsProvider}. + */ +@UnstableApi +public final class ClientTlsConfig extends AbstractTlsConfig { + + static final ClientTlsConfig NOOP = builder().build(); + + /** + * Returns a new {@link ClientTlsConfigBuilder}. + */ + public static ClientTlsConfigBuilder builder() { + return new ClientTlsConfigBuilder(); + } + + private final boolean tlsNoVerifySet; + private final Set insecureHosts; + + ClientTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix, + Consumer tlsCustomizer, boolean tlsNoVerifySet, + Set insecureHosts) { + super(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer); + this.tlsNoVerifySet = tlsNoVerifySet; + this.insecureHosts = insecureHosts; + } + + /** + * Returns whether the verification of server's TLS certificate chain is disabled. + */ + public boolean tlsNoVerifySet() { + return tlsNoVerifySet; + } + + /** + * Returns the hosts for which the verification of server's TLS certificate chain is disabled. + */ + public Set insecureHosts() { + return insecureHosts; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ClientTlsConfig)) { + return false; + } + if (!super.equals(o)) { + return false; + } + + final ClientTlsConfig that = (ClientTlsConfig) o; + return tlsNoVerifySet == that.tlsNoVerifySet && insecureHosts.equals(that.insecureHosts); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), tlsNoVerifySet, insecureHosts); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("allowsUnsafeCiphers", allowsUnsafeCiphers()) + .add("meterIdPrefix", meterIdPrefix()) + .add("tlsCustomizer", tlsCustomizer()) + .add("tlsNoVerifySet", tlsNoVerifySet) + .add("insecureHosts", insecureHosts) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java new file mode 100644 index 00000000000..06ec1fd8a88 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/ClientTlsConfigBuilder.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import com.linecorp.armeria.common.AbstractTlsConfigBuilder; +import com.linecorp.armeria.common.annotation.UnstableApi; + +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +/** + * A builder class for creating a {@link ClientTlsConfig}. + */ +@UnstableApi +public final class ClientTlsConfigBuilder extends AbstractTlsConfigBuilder { + + private boolean tlsNoVerifySet; + private final Set insecureHosts = new HashSet<>(); + + ClientTlsConfigBuilder() {} + + /** + * Disables the verification of server's TLS certificate chain. If you want to disable verification for + * only specific hosts, use {@link #tlsNoVerifyHosts(String...)}. + * + *

Note: You should never use this in production but only for a testing purpose. + * + * @see InsecureTrustManagerFactory + * @see #tlsCustomizer(Consumer) + */ + public ClientTlsConfigBuilder tlsNoVerify() { + tlsNoVerifySet = true; + checkState(insecureHosts.isEmpty(), "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + return this; + } + + /** + * Disables the verification of server's TLS certificate chain for specific hosts. If you want to disable + * all verification, use {@link #tlsNoVerify()} . + * + *

Note: You should never use this in production but only for a testing purpose. + * + * @see #tlsCustomizer(Consumer) + */ + public ClientTlsConfigBuilder tlsNoVerifyHosts(String... insecureHosts) { + requireNonNull(insecureHosts, "insecureHosts"); + return tlsNoVerifyHosts(ImmutableList.copyOf(insecureHosts)); + } + + /** + * Disables the verification of server's TLS certificate chain for specific hosts. If you want to disable + * all verification, use {@link #tlsNoVerify()} . + * + *

Note: You should never use this in production but only for a testing purpose. + * + * @see #tlsCustomizer(Consumer) + */ + public ClientTlsConfigBuilder tlsNoVerifyHosts(Iterable insecureHosts) { + requireNonNull(insecureHosts, "insecureHosts"); + checkState(!tlsNoVerifySet, "tlsNoVerify() and tlsNoVerifyHosts() are mutually exclusive."); + insecureHosts.forEach(this.insecureHosts::add); + return this; + } + + /** + * Returns a newly-created {@link ClientTlsConfig} based on the properties of this builder. + */ + public ClientTlsConfig build() { + return new ClientTlsConfig(allowsUnsafeCiphers(), meterIdPrefix(), tlsCustomizer(), + tlsNoVerifySet, ImmutableSet.copyOf(insecureHosts)); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java b/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java index 57e9fa13261..2bd867e93f5 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpChannelPool.java @@ -55,6 +55,7 @@ import com.linecorp.armeria.common.util.AsyncCloseableSupport; import com.linecorp.armeria.internal.client.HttpSession; import com.linecorp.armeria.internal.client.PooledChannel; +import com.linecorp.armeria.internal.common.SslContextFactory; import com.linecorp.armeria.internal.common.util.ChannelUtil; import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals; @@ -101,6 +102,7 @@ final class HttpChannelPool implements AsyncCloseable { HttpChannelPool(HttpClientFactory clientFactory, EventLoop eventLoop, SslContext sslCtxHttp1Or2, SslContext sslCtxHttp1Only, + @Nullable SslContextFactory sslContextFactory, ConnectionPoolListener listener) { this.clientFactory = clientFactory; this.eventLoop = eventLoop; @@ -116,7 +118,8 @@ final class HttpChannelPool implements AsyncCloseable { .get(ChannelOption.CONNECT_TIMEOUT_MILLIS); assert connectTimeoutMillisBoxed != null; connectTimeoutMillis = connectTimeoutMillisBoxed; - bootstraps = new Bootstraps(clientFactory, eventLoop, sslCtxHttp1Or2, sslCtxHttp1Only); + bootstraps = new Bootstraps(clientFactory, eventLoop, sslCtxHttp1Or2, sslCtxHttp1Only, + sslContextFactory); } private void configureProxy(Channel ch, ProxyConfig proxyConfig, SessionProtocol desiredProtocol) { @@ -157,8 +160,11 @@ private void configureProxy(Channel ch, ProxyConfig proxyConfig, SessionProtocol ch.pipeline().addFirst(proxyHandler); if (proxyConfig instanceof ConnectProxyConfig && ((ConnectProxyConfig) proxyConfig).useTls()) { - final SslContext sslCtx = bootstraps.determineSslContext(desiredProtocol); + final SslContext sslCtx = bootstraps.getOrCreateSslContext(proxyAddress, desiredProtocol); ch.pipeline().addFirst(sslCtx.newHandler(ch.alloc())); + if (bootstraps.shouldReleaseSslContext(sslCtx)) { + ch.closeFuture().addListener(unused -> bootstraps.releaseSslContext(sslCtx)); + } } } @@ -382,7 +388,7 @@ void connect(SocketAddress remoteAddress, SessionProtocol desiredProtocol, @Nullable ClientConnectionTimingsBuilder timingsBuilder) { final Bootstrap bootstrap; try { - bootstrap = bootstraps.get(remoteAddress, desiredProtocol, serializationFormat); + bootstrap = bootstraps.getOrCreate(remoteAddress, desiredProtocol, serializationFormat); } catch (Exception e) { sessionPromise.tryFailure(e); return; diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java index ee65a69f054..d4d7aacb279 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java @@ -36,7 +36,6 @@ import org.slf4j.LoggerFactory; import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableList; import com.google.common.collect.MapMaker; import com.linecorp.armeria.client.endpoint.EndpointGroup; @@ -47,6 +46,7 @@ import com.linecorp.armeria.common.Scheme; import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.metric.MeterIdPrefix; import com.linecorp.armeria.common.metric.MoreMeterBinders; @@ -56,6 +56,7 @@ import com.linecorp.armeria.common.util.TlsEngineType; import com.linecorp.armeria.common.util.TransportType; import com.linecorp.armeria.internal.common.RequestTargetCache; +import com.linecorp.armeria.internal.common.SslContextFactory; import com.linecorp.armeria.internal.common.util.ChannelUtil; import com.linecorp.armeria.internal.common.util.SslContextUtil; @@ -89,12 +90,12 @@ final class HttpClientFactory implements ClientFactory { private static void setupTlsMetrics(List certificates, MeterRegistry registry) { final MeterIdPrefix meterIdPrefix = new MeterIdPrefix("armeria.client"); - try { - MoreMeterBinders.certificateMetrics(certificates, meterIdPrefix) - .bindTo(registry); - } catch (Exception ex) { - logger.warn("Failed to set up TLS certificate metrics: {}", certificates, ex); - } + try { + MoreMeterBinders.certificateMetrics(certificates, meterIdPrefix) + .bindTo(registry); + } catch (Exception ex) { + logger.warn("Failed to set up TLS certificate metrics: {}", certificates, ex); + } } private final EventLoopGroup workerGroup; @@ -104,6 +105,8 @@ private static void setupTlsMetrics(List certificates, MeterReg private final Bootstrap unixBaseBootstrap; private final SslContext sslCtxHttp1Or2; private final SslContext sslCtxHttp1Only; + @Nullable + private final SslContextFactory sslContextFactory; private final AddressResolverGroup addressResolverGroup; private final int http2InitialConnectionWindowSize; private final int http2InitialStreamWindowSize; @@ -176,19 +179,31 @@ private static void setupTlsMetrics(List certificates, MeterReg unixBaseBootstrap = null; } - final ImmutableList> tlsCustomizers = - ImmutableList.of(options.tlsCustomizer()); + final Consumer tlsCustomizer = + options.tlsCustomizer(); final boolean tlsAllowUnsafeCiphers = options.tlsAllowUnsafeCiphers(); final List keyCertChainCaptor = new ArrayList<>(); final TlsEngineType tlsEngineType = options.tlsEngineType(); sslCtxHttp1Or2 = SslContextUtil .createSslContext(SslContextBuilder::forClient, false, tlsEngineType, - tlsAllowUnsafeCiphers, tlsCustomizers, keyCertChainCaptor); + tlsAllowUnsafeCiphers, tlsCustomizer, keyCertChainCaptor); sslCtxHttp1Only = SslContextUtil .createSslContext(SslContextBuilder::forClient, true, tlsEngineType, - tlsAllowUnsafeCiphers, tlsCustomizers, keyCertChainCaptor); + tlsAllowUnsafeCiphers, tlsCustomizer, keyCertChainCaptor); setupTlsMetrics(keyCertChainCaptor, options.meterRegistry()); + final TlsProvider tlsProvider = options.tlsProvider(); + if (tlsProvider != NullTlsProvider.INSTANCE) { + ClientTlsConfig clientTlsConfig = options.tlsConfig(); + if (clientTlsConfig == ClientTlsConfig.NOOP) { + clientTlsConfig = null; + } + sslContextFactory = new SslContextFactory(tlsProvider, options.tlsEngineType(), clientTlsConfig, + options.meterRegistry()); + } else { + sslContextFactory = null; + } + http2InitialConnectionWindowSize = options.http2InitialConnectionWindowSize(); http2InitialStreamWindowSize = options.http2InitialStreamWindowSize(); http2MaxFrameSize = options.http2MaxFrameSize(); @@ -495,6 +510,13 @@ HttpChannelPool pool(EventLoop eventLoop) { return pools.computeIfAbsent(eventLoop, e -> new HttpChannelPool(this, eventLoop, sslCtxHttp1Or2, sslCtxHttp1Only, + sslContextFactory, connectionPoolListener())); } + + @VisibleForTesting + @Nullable + SslContextFactory sslContextFactory() { + return sslContextFactory; + } } diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java index 4d742e06a24..7f119e28da4 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientPipelineConfigurator.java @@ -159,7 +159,7 @@ private enum HttpPreference { HttpClientPipelineConfigurator(HttpClientFactory clientFactory, boolean webSocket, SessionProtocol sessionProtocol, - @Nullable SslContext sslCtx) { + SslContext sslCtx) { this.clientFactory = clientFactory; this.webSocket = webSocket; diff --git a/core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java b/core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java new file mode 100644 index 00000000000..90519034de4 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/NullTlsProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; + +enum NullTlsProvider implements TlsProvider { + INSTANCE; + + @Override + public @Nullable TlsKeyPair keyPair(String hostname) { + return null; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java b/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java index f913b070436..36c0815d70b 100644 --- a/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/RestClientPreparation.java @@ -173,6 +173,7 @@ public RestClientPreparation content(MediaType contentType, String content) { @Override @FormatMethod + @SuppressWarnings("FormatStringAnnotation") public RestClientPreparation content(@FormatString String format, Object... content) { delegate.content(format, content); return this; @@ -180,6 +181,7 @@ public RestClientPreparation content(@FormatString String format, Object... cont @Override @FormatMethod + @SuppressWarnings("FormatStringAnnotation") public RestClientPreparation content(MediaType contentType, @FormatString String format, Object... content) { delegate.content(contentType, format, content); diff --git a/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java b/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java index 535fc1c0e16..e90b0ee9302 100644 --- a/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java +++ b/core/src/main/java/com/linecorp/armeria/client/TransformingRequestPreparation.java @@ -25,6 +25,7 @@ import org.reactivestreams.Publisher; import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; import com.linecorp.armeria.common.Cookie; import com.linecorp.armeria.common.ExchangeType; @@ -192,7 +193,7 @@ public TransformingRequestPreparation content(String format, Object... con @Override @FormatMethod @SuppressWarnings("FormatStringAnnotation") - public TransformingRequestPreparation content(MediaType contentType, String format, + public TransformingRequestPreparation content(MediaType contentType, @FormatString String format, Object... content) { delegate.content(contentType, format, content); return this; diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java index e28d7fbdced..9d630d05be5 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/DynamicEndpointGroup.java @@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.linecorp.armeria.internal.client.endpoint.EndpointToStringUtil.toShortString; import static com.linecorp.armeria.internal.common.util.CollectionUtil.truncate; import static java.util.Objects.requireNonNull; @@ -32,6 +33,9 @@ import java.util.concurrent.locks.Lock; import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; @@ -51,6 +55,8 @@ */ public class DynamicEndpointGroup extends AbstractEndpointGroup implements ListenableAsyncCloseable { + private static final Logger logger = LoggerFactory.getLogger(DynamicEndpointGroup.class); + /** * Returns a newly created builder. */ @@ -223,6 +229,8 @@ protected final void addEndpoint(Endpoint e) { final List newEndpointsUnsorted = Lists.newArrayList(endpoints); newEndpointsUnsorted.add(e); endpoints = newEndpoints = ImmutableList.sortedCopyOf(newEndpointsUnsorted); + logger.info("An endpoint has been added: {}. Current endpoints: {}", + toShortString(e), toShortString(newEndpoints)); } finally { endpointsLock.unlock(); } @@ -238,12 +246,17 @@ protected final void removeEndpoint(Endpoint e) { final List newEndpoints; endpointsLock.lock(); try { - if (!allowEmptyEndpoints && endpoints.size() == 1) { + final List oldEndpoints = endpoints; + if (!allowEmptyEndpoints && oldEndpoints.size() == 1) { return; } - endpoints = newEndpoints = endpoints.stream() - .filter(endpoint -> !endpoint.equals(e)) - .collect(toImmutableList()); + endpoints = newEndpoints = oldEndpoints.stream() + .filter(endpoint -> !endpoint.equals(e)) + .collect(toImmutableList()); + if (endpoints.size() != oldEndpoints.size()) { + logger.info("An endpoint has been removed: {}. Current endpoints: {}", + toShortString(e), toShortString(newEndpoints)); + } } finally { endpointsLock.unlock(); } @@ -266,6 +279,7 @@ protected final void setEndpoints(Iterable endpoints) { return; } this.endpoints = newEndpoints; + logger.info("New endpoints have been set: {}", toShortString(newEndpoints)); } finally { endpointsLock.unlock(); } @@ -376,7 +390,7 @@ public String toString() { protected final String toString(Consumer builderMutator) { final StringBuilder buf = new StringBuilder(); buf.append(getClass().getSimpleName()); - buf.append("{selectionStrategy=").append(selectionStrategy.getClass()); + buf.append("{selector=").append(toStringSelector()); buf.append(", allowsEmptyEndpoints=").append(allowEmptyEndpoints); buf.append(", initialized=").append(initialEndpointsFuture.isDone()); buf.append(", numEndpoints=").append(endpoints.size()); @@ -385,6 +399,21 @@ protected final String toString(Consumer builderMutator) return buf.append('}').toString(); } + /** + * Returns the string representation of the {@link EndpointSelector} of this {@link DynamicEndpointGroup}. + * If the {@link EndpointSelector} is not created yet, it returns the class name of the + * {@link EndpointSelectionStrategy}. + */ + protected String toStringSelector() { + final EndpointSelector endpointSelector = selector.get(); + if (endpointSelector == null) { + // Return selection strategy if selector is not created yet. + return selectionStrategy.getClass().toString(); + } + + return endpointSelector.toString(); + } + private class InitialEndpointsFuture extends EventLoopCheckingFuture> { @Override diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/RoundRobinStrategy.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/RoundRobinStrategy.java index 48813621925..c9b9a86b905 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/RoundRobinStrategy.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/RoundRobinStrategy.java @@ -19,6 +19,8 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import com.google.common.base.MoreObjects; + import com.linecorp.armeria.client.ClientRequestContext; import com.linecorp.armeria.client.Endpoint; import com.linecorp.armeria.common.annotation.Nullable; @@ -57,5 +59,12 @@ public Endpoint selectNow(ClientRequestContext ctx) { final int currentSequence = sequence.getAndIncrement(); return endpoints.get(Math.abs(currentSequence % endpoints.size())); } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("endpoints", group().endpoints()) + .toString(); + } } } diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/StickyEndpointSelectionStrategy.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/StickyEndpointSelectionStrategy.java index 18415603dd2..14d48680e1b 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/StickyEndpointSelectionStrategy.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/StickyEndpointSelectionStrategy.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.function.ToLongFunction; +import com.google.common.base.MoreObjects; import com.google.common.hash.Hashing; import com.linecorp.armeria.client.ClientRequestContext; @@ -84,7 +85,6 @@ private static final class StickyEndpointSelector extends AbstractEndpointSelect @Nullable @Override public Endpoint selectNow(ClientRequestContext ctx) { - final List endpoints = group().endpoints(); if (endpoints.isEmpty()) { return null; @@ -94,5 +94,12 @@ public Endpoint selectNow(ClientRequestContext ctx) { final int nearest = Hashing.consistentHash(key, endpoints.size()); return endpoints.get(nearest); } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("endpoints", group().endpoints()) + .toString(); + } } } diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategy.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategy.java index c318c9124dd..88f124a84b1 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategy.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategy.java @@ -22,11 +22,10 @@ import static com.linecorp.armeria.client.endpoint.WeightRampingUpStrategyBuilder.defaultTransition; import static com.linecorp.armeria.internal.client.endpoint.EndpointAttributeKeys.createdAtNanos; import static com.linecorp.armeria.internal.client.endpoint.EndpointAttributeKeys.hasCreatedAtNanos; +import static com.linecorp.armeria.internal.client.endpoint.EndpointToStringUtil.toShortString; import static java.util.Objects.requireNonNull; -import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -37,6 +36,9 @@ import java.util.concurrent.TimeUnit; import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; @@ -76,6 +78,8 @@ */ final class WeightRampingUpStrategy implements EndpointSelectionStrategy { + private static final Logger logger = LoggerFactory.getLogger(WeightRampingUpStrategy.class); + private static final Ticker defaultTicker = Ticker.systemTicker(); private static final WeightedRandomDistributionEndpointSelector EMPTY_SELECTOR = new WeightedRandomDistributionEndpointSelector(ImmutableList.of()); @@ -130,8 +134,6 @@ final class RampingUpEndpointWeightSelector extends AbstractEndpointSelector { private final List endpointsFinishedRampingUp = new ArrayList<>(); - @VisibleForTesting - final Deque endpointsRampingUp = new ArrayDeque<>(); @VisibleForTesting final Map rampingUpWindowsMap = new HashMap<>(); private Object2LongOpenHashMap endpointCreatedTimestamps = new Object2LongOpenHashMap<>(); @@ -233,7 +235,25 @@ private void buildEndpointSelector() { endpointAndStep.endpoint().withWeight(endpointAndStep.currentWeight())); } } - endpointSelector = new WeightedRandomDistributionEndpointSelector(targetEndpointsBuilder.build()); + final List endpoints = targetEndpointsBuilder.build(); + if (rampingUpWindowsMap.isEmpty()) { + logger.info("Finished ramping up. endpoints: {}", toShortString(endpoints)); + } else { + logger.debug("Ramping up. endpoints: {}", toShortString(endpoints)); + } + + boolean found = false; + for (Endpoint endpoint : endpoints) { + if (endpoint.weight() > 0) { + found = true; + break; + } + } + if (!found) { + logger.warn("No valid endpoint with weight > 0. endpoints: {}", toShortString(endpoints)); + } + + endpointSelector = new WeightedRandomDistributionEndpointSelector(endpoints); } @VisibleForTesting @@ -288,6 +308,15 @@ private void close() { lock.unlock(); } } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("endpointSelector", endpointSelector) + .add("endpointsFinishedRampingUp", endpointsFinishedRampingUp) + .add("rampingUpWindowsMap", rampingUpWindowsMap) + .toString(); + } } private static int numStep(long rampingUpIntervalNanos, Ticker ticker, long createTimestamp) { diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyBuilder.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyBuilder.java index 1712186efbd..c8ef4a3c336 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyBuilder.java @@ -43,9 +43,17 @@ public final class WeightRampingUpStrategyBuilder { static final int DEFAULT_TOTAL_STEPS = 10; static final int DEFAULT_RAMPING_UP_TASK_WINDOW_MILLIS = 500; static final EndpointWeightTransition DEFAULT_LINEAR_TRANSITION = - (endpoint, currentStep, totalSteps) -> - // currentStep is never greater than totalSteps so we can cast long to int. - Ints.saturatedCast((long) endpoint.weight() * currentStep / totalSteps); + (endpoint, currentStep, totalSteps) -> { + // currentStep is never greater than totalSteps so we can cast long to int. + final int currentWeight = + Ints.saturatedCast((long) endpoint.weight() * currentStep / totalSteps); + if (endpoint.weight() > 0 && currentWeight == 0) { + // If the original weight is not 0, + // we should return 1 to make sure the endpoint is selected. + return 1; + } + return currentWeight; + }; static final EndpointWeightTransition defaultTransition = EndpointWeightTransition.linear(); private EndpointWeightTransition transition = defaultTransition; diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightedRoundRobinStrategy.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightedRoundRobinStrategy.java index d4def3f082a..4150420a1ae 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightedRoundRobinStrategy.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/WeightedRoundRobinStrategy.java @@ -17,11 +17,16 @@ package com.linecorp.armeria.client.endpoint; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.linecorp.armeria.internal.client.endpoint.EndpointToStringUtil.toShortString; import java.util.Comparator; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.Streams; @@ -31,6 +36,8 @@ final class WeightedRoundRobinStrategy implements EndpointSelectionStrategy { + private static final Logger logger = LoggerFactory.getLogger(WeightedRoundRobinStrategy.class); + static final WeightedRoundRobinStrategy INSTANCE = new WeightedRoundRobinStrategy(); private WeightedRoundRobinStrategy() {} @@ -63,6 +70,17 @@ private static final class WeightedRoundRobinSelector extends AbstractEndpointSe @Override protected void updateNewEndpoints(List endpoints) { + boolean found = false; + for (Endpoint endpoint : endpoints) { + if (endpoint.weight() > 0) { + found = true; + break; + } + } + if (!found) { + logger.warn("No valid endpoint with weight > 0. endpoints: {}", toShortString(endpoints)); + } + final EndpointsAndWeights endpointsAndWeights = this.endpointsAndWeights; if (endpointsAndWeights == null || endpointsAndWeights.endpoints != endpoints) { this.endpointsAndWeights = new EndpointsAndWeights(endpoints); @@ -94,6 +112,13 @@ private static final class EndpointsGroupByWeight { } } + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("endpointsAndWeights", endpointsAndWeights) + .toString(); + } + // // In general, assume the weights are w0 < w1 < ... < wM where M = N - 1, N is number of endpoints. // @@ -228,6 +253,16 @@ Endpoint selectEndpoint(int currentSequence) { return endpoints.get(Math.abs(currentSequence % endpoints.size())); } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("endpoints", endpoints) + .add("weighted", weighted) + .add("totalWeight", totalWeight) + .add("accumulatedGroups", accumulatedGroups) + .toString(); + } } } } diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java index 3cfc522b2ae..7c7e9ea5ff2 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/DefaultHealthCheckerContext.java @@ -186,7 +186,7 @@ public void updateHealth(double health) { } @Override - public void updateHealth(double health, ClientRequestContext ctx, + public void updateHealth(double health, @Nullable ClientRequestContext ctx, @Nullable ResponseHeaders headers, @Nullable Throwable cause) { final boolean isHealthy = health > 0; if (headers != null && headers.contains("x-envoy-degraded")) { diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroup.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroup.java index 1e480c2060f..637da21fe30 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroup.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroup.java @@ -378,7 +378,7 @@ public String toString() { .add("numEndpoints", endpoints.size()) .add("candidates", truncate(delegateEndpoints, 10)) .add("numCandidates", delegateEndpoints.size()) - .add("selectionStrategy", selectionStrategy().getClass()) + .add("selector", toStringSelector()) .add("initialized", whenReady().isDone()) .add("initialSelectionTimeoutMillis", initialSelectionTimeoutMillis) .add("selectionTimeoutMillis", selectionTimeoutMillis) diff --git a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java index 5e950cc3ca7..e055377cbfb 100644 --- a/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/endpoint/healthcheck/HealthCheckedEndpointGroupBuilder.java @@ -23,7 +23,7 @@ import com.linecorp.armeria.client.Endpoint; import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.common.util.AsyncCloseable; -import com.linecorp.armeria.internal.client.endpoint.healthcheck.HttpHealthChecker; +import com.linecorp.armeria.internal.client.endpoint.healthcheck.DefaultHttpHealthChecker; /** * A builder for creating a new {@link HealthCheckedEndpointGroup} that sends HTTP health check requests. @@ -73,8 +73,8 @@ private static class HttpHealthCheckerFactory implements Function tlsCustomizer; + + /** + * Creates a new instance. + */ + protected AbstractTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix, + Consumer tlsCustomizer) { + this.allowsUnsafeCiphers = allowsUnsafeCiphers; + this.meterIdPrefix = meterIdPrefix; + this.tlsCustomizer = tlsCustomizer; + } + + /** + * Returns whether to allow the bad cipher suites listed in + * RFC7540 for TLS handshake. + */ + public final boolean allowsUnsafeCiphers() { + return allowsUnsafeCiphers; + } + + /** + * Sets the {@link MeterIdPrefix} for the TLS metrics. + */ + @Nullable + public final MeterIdPrefix meterIdPrefix() { + return meterIdPrefix; + } + + /** + * Returns the {@link Consumer} which can arbitrarily configure the {@link SslContextBuilder}. + */ + public final Consumer tlsCustomizer() { + return tlsCustomizer; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AbstractTlsConfig)) { + return false; + } + final AbstractTlsConfig that = (AbstractTlsConfig) o; + return allowsUnsafeCiphers == that.allowsUnsafeCiphers && + Objects.equals(meterIdPrefix, that.meterIdPrefix) && + tlsCustomizer.equals(that.tlsCustomizer); + } + + @Override + public int hashCode() { + return Objects.hash(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java new file mode 100644 index 00000000000..77e51eb2df0 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsConfigBuilder.java @@ -0,0 +1,123 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.util.function.Consumer; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.SslContextBuilder; + +/** + * A skeletal builder implementation for {@link TlsProvider}. + */ +@UnstableApi +public abstract class AbstractTlsConfigBuilder> { + + private static final Consumer NOOP = b -> {}; + + private boolean allowsUnsafeCiphers; + private Consumer tlsCustomizer = NOOP; + @Nullable + private MeterIdPrefix meterIdPrefix; + + /** + * Creates a new instance. + */ + protected AbstractTlsConfigBuilder() {} + + /** + * Allows the bad cipher suites listed in + * RFC7540 for TLS handshake. + * + *

Note that enabling this option increases the security risk of your connection. + * Use it only when you must communicate with a legacy system that does not support + * secure cipher suites. + * See Section 9.2.2, RFC7540 for + * more information. This option is disabled by default. + * + * @param allowsUnsafeCiphers Whether to allow the unsafe ciphers + * + * @deprecated It's not recommended to enable this option. Use it only when you have no other way to + * communicate with an insecure peer than this. + */ + @Deprecated + public SELF allowsUnsafeCiphers(boolean allowsUnsafeCiphers) { + this.allowsUnsafeCiphers = allowsUnsafeCiphers; + return self(); + } + + /** + * Returns whether to allow the bad cipher suites listed in + * RFC7540 for TLS handshake. + */ + protected final boolean allowsUnsafeCiphers() { + return allowsUnsafeCiphers; + } + + /** + * Adds the {@link Consumer} which can arbitrarily configure the {@link SslContextBuilder} that will be + * applied to the SSL session. For example, use {@link SslContextBuilder#trustManager(TrustManagerFactory)} + * to configure a custom server CA or {@link SslContextBuilder#keyManager(KeyManagerFactory)} to configure + * a client certificate for SSL authorization. + */ + public SELF tlsCustomizer(Consumer tlsCustomizer) { + requireNonNull(tlsCustomizer, "tlsCustomizer"); + if (this.tlsCustomizer == NOOP) { + //noinspection unchecked + this.tlsCustomizer = (Consumer) tlsCustomizer; + } else { + this.tlsCustomizer = this.tlsCustomizer.andThen(tlsCustomizer); + } + return self(); + } + + /** + * Returns the {@link Consumer} which can arbitrarily configure the {@link SslContextBuilder}. + */ + protected final Consumer tlsCustomizer() { + return tlsCustomizer; + } + + /** + * Sets the {@link MeterIdPrefix} for the TLS metrics. + */ + public SELF meterIdPrefix(MeterIdPrefix meterIdPrefix) { + this.meterIdPrefix = requireNonNull(meterIdPrefix, "meterIdPrefix"); + return self(); + } + + /** + * Returns the {@link MeterIdPrefix} for TLS metrics. + */ + @Nullable + protected final MeterIdPrefix meterIdPrefix() { + return meterIdPrefix; + } + + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/Flags.java b/core/src/main/java/com/linecorp/armeria/common/Flags.java index b3327c7d06d..3fc9c0acbcd 100644 --- a/core/src/main/java/com/linecorp/armeria/common/Flags.java +++ b/core/src/main/java/com/linecorp/armeria/common/Flags.java @@ -641,7 +641,7 @@ private static void detectTlsEngineAndDumpOpenSslInfo() { /* forceHttp1 */ false, tlsEngineType, /* tlsAllowUnsafeCiphers */ false, - ImmutableList.of(), null).newEngine(ByteBufAllocator.DEFAULT); + null, null).newEngine(ByteBufAllocator.DEFAULT); logger.info("All available SSL protocols: {}", ImmutableList.copyOf(engine.getSupportedProtocols())); logger.info("Default enabled SSL protocols: {}", SslContextUtil.DEFAULT_PROTOCOLS); diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java b/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java index 3f27b8b8fda..82055d79a79 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java @@ -938,6 +938,12 @@ public final class HttpHeaderNames { */ public static final AsciiString CDN_LOOP = create("CDN-Loop"); + /** + * The HTTP {@code + * Sec-CH-UA-Form-Factors} header field name. + */ + public static final AsciiString SEC_CH_UA_FORM_FACTORS = create("Sec-CH-UA-Form-Factors"); + private static final Map map; private static final Map inverseMap; diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java b/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java index 1d75eb565fe..6c4f337c761 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpRequest.java @@ -21,6 +21,7 @@ import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Formatter; import java.util.List; import java.util.Locale; @@ -47,6 +48,7 @@ import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamTimeoutMode; import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.internal.common.DefaultHttpRequest; import com.linecorp.armeria.internal.common.DefaultSplitHttpRequest; @@ -816,4 +818,16 @@ default HttpRequest subscribeOn(EventExecutor eventExecutor) { requireNonNull(eventExecutor, "eventExecutor"); return of(headers(), HttpMessage.super.subscribeOn(eventExecutor)); } + + @UnstableApi + @Override + default HttpRequest timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + @UnstableApi + @Override + default HttpRequest timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + return of(headers(), HttpMessage.super.timeout(timeoutDuration, timeoutMode)); + } } diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java b/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java index 21de0c4cda5..32d2b810d3e 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpResponse.java @@ -53,6 +53,7 @@ import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.stream.PublisherBasedStreamMessage; import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamTimeoutMode; import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.common.util.Exceptions; import com.linecorp.armeria.internal.common.AbortedHttpResponse; @@ -1196,4 +1197,16 @@ default HttpResponse recover(Class causeClass, default HttpResponse subscribeOn(EventExecutor eventExecutor) { return of(HttpMessage.super.subscribeOn(eventExecutor)); } + + @UnstableApi + @Override + default HttpResponse timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + @UnstableApi + @Override + default HttpResponse timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + return of(HttpMessage.super.timeout(timeoutDuration, timeoutMode)); + } } diff --git a/core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java b/core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java new file mode 100644 index 00000000000..c350d228f19 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/MappedTlsProvider.java @@ -0,0 +1,106 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.internal.common.TlsProviderUtil.normalizeHostname; +import static java.util.Objects.requireNonNull; + +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; + +final class MappedTlsProvider implements TlsProvider { + + private final Map tlsKeyPairs; + private final Map> trustedCertificates; + + MappedTlsProvider(Map tlsKeyPairs, + Map> trustedCertificates) { + this.tlsKeyPairs = tlsKeyPairs; + this.trustedCertificates = trustedCertificates; + } + + @Nullable + @Override + public TlsKeyPair keyPair(String hostname) { + requireNonNull(hostname, "hostname"); + return find(hostname, tlsKeyPairs); + } + + @Override + public List trustedCertificates(String hostname) { + final List certs = find(hostname, trustedCertificates); + return firstNonNull(certs, ImmutableList.of()); + } + + @Nullable + private static T find(String hostname, Map map) { + if ("*".equals(hostname)) { + return map.get("*"); + } + hostname = normalizeHostname(hostname); + + T value = map.get(hostname); + if (value != null) { + return value; + } + + // No exact match, let's try a wildcard match. + final int idx = hostname.indexOf('.'); + if (idx != -1) { + value = map.get(hostname.substring(idx)); + if (value != null) { + return value; + } + } + // Try to find the default one. + return map.get("*"); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MappedTlsProvider)) { + return false; + } + final MappedTlsProvider that = (MappedTlsProvider) o; + return tlsKeyPairs.equals(that.tlsKeyPairs) && + trustedCertificates.equals(that.trustedCertificates); + } + + @Override + public int hashCode() { + return Objects.hash(tlsKeyPairs, trustedCertificates); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tlsKeyPairs", tlsKeyPairs) + .add("trustedCertificates", trustedCertificates) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/MediaType.java b/core/src/main/java/com/linecorp/armeria/common/MediaType.java index 2ee4c7461cd..c16f1cf4562 100644 --- a/core/src/main/java/com/linecorp/armeria/common/MediaType.java +++ b/core/src/main/java/com/linecorp/armeria/common/MediaType.java @@ -827,6 +827,11 @@ private static MediaType addKnownType(MediaType mediaType) { public static final MediaType GRAPHQL_RESPONSE_JSON = createConstant(APPLICATION_TYPE, "graphql-response+json"); + /** + * Markdown type. + */ + public static final MediaType MD_UTF_8 = createConstantUtf8(TEXT_TYPE, "markdown"); + private static final Charset NO_CHARSET = new Charset("NO_CHARSET", null) { @Override public boolean contains(Charset cs) { diff --git a/core/src/main/java/com/linecorp/armeria/common/MediaTypeNames.java b/core/src/main/java/com/linecorp/armeria/common/MediaTypeNames.java index b6ffe6a27ba..c51d6f278ee 100644 --- a/core/src/main/java/com/linecorp/armeria/common/MediaTypeNames.java +++ b/core/src/main/java/com/linecorp/armeria/common/MediaTypeNames.java @@ -630,5 +630,10 @@ public final class MediaTypeNames { */ public static final String GRAPHQL_RESPONSE_JSON = "application/graphql-response+json"; + /** + * {@value #MD_UTF_8}. + */ + public static final String MD_UTF_8 = "text/markdown; charset=utf-8"; + private MediaTypeNames() {} } diff --git a/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java b/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java index 5b7241a52f8..b3a7b434540 100644 --- a/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java +++ b/core/src/main/java/com/linecorp/armeria/common/RequestTarget.java @@ -131,6 +131,14 @@ static RequestTarget forClient(String reqTarget, @Nullable String prefix) { */ String maybePathWithMatrixVariables(); + /** + * Returns the server-side raw path of this {@link RequestTarget} from the ":path" header. + * Unlike {@link #path()}, the returned string is the original path without any normalization. + * For client-side target it always returns {@code null}. + */ + @Nullable + String rawPath(); + /** * Returns the query of this {@link RequestTarget}. */ diff --git a/core/src/main/java/com/linecorp/armeria/common/ResponseCompleteException.java b/core/src/main/java/com/linecorp/armeria/common/ResponseCompleteException.java index 5f767a94064..04035b2d132 100644 --- a/core/src/main/java/com/linecorp/armeria/common/ResponseCompleteException.java +++ b/core/src/main/java/com/linecorp/armeria/common/ResponseCompleteException.java @@ -26,16 +26,22 @@ public final class ResponseCompleteException extends CancellationException { private static final long serialVersionUID = 6090278381004263949L; - private static final ResponseCompleteException INSTANCE = new ResponseCompleteException(); + private static final ResponseCompleteException INSTANCE = new ResponseCompleteException(false); /** * Returns the singleton {@link ResponseCompleteException}. */ public static ResponseCompleteException get() { - return INSTANCE; + if (Flags.verboseExceptionSampler().isSampled(ResponseCompleteException.class)) { + return new ResponseCompleteException(); + } else { + return INSTANCE; + } } - private ResponseCompleteException() { + private ResponseCompleteException() {} + + private ResponseCompleteException(@SuppressWarnings("unused") boolean dummy) { super(null, null, false, false); } } diff --git a/core/src/main/java/com/linecorp/armeria/common/ResponseEntity.java b/core/src/main/java/com/linecorp/armeria/common/ResponseEntity.java index ff43d3bc975..a6ee974aa4f 100644 --- a/core/src/main/java/com/linecorp/armeria/common/ResponseEntity.java +++ b/core/src/main/java/com/linecorp/armeria/common/ResponseEntity.java @@ -18,7 +18,6 @@ import static java.util.Objects.requireNonNull; -import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; /** @@ -30,8 +29,9 @@ public interface ResponseEntity extends HttpEntity { /** * Returns a newly created {@link ResponseEntity} with the specified {@link ResponseHeaders}. */ - static ResponseEntity of(ResponseHeaders headers) { - return of(headers, null, HttpHeaders.of()); + static ResponseEntity of(ResponseHeaders headers) { + requireNonNull(headers, "headers"); + return new DefaultResponseEntity<>(headers, null, HttpHeaders.of()); } /** @@ -39,27 +39,49 @@ static ResponseEntity of(ResponseHeaders headers) { * {@code content}. */ static ResponseEntity of(ResponseHeaders headers, T content) { - requireNonNull(content, "content"); return of(headers, content, HttpHeaders.of()); } /** - * Returns a newly created {@link ResponseEntity} with the specified {@code content} and - * {@link HttpStatus#OK} status. + * Returns a newly created {@link ResponseEntity} with the specified {@link ResponseHeaders}, + * {@code content} and {@linkplain HttpHeaders trailers}. */ - static ResponseEntity of(T content) { + static ResponseEntity of(ResponseHeaders headers, T content, HttpHeaders trailers) { + requireNonNull(headers, "headers"); requireNonNull(content, "content"); - return of(ResponseHeaders.of(HttpStatus.OK), content, HttpHeaders.of()); + requireNonNull(trailers, "trailers"); + return new DefaultResponseEntity<>(headers, content, trailers); } /** - * Returns a newly created {@link ResponseEntity} with the specified {@link ResponseHeaders}, + * Returns a newly created {@link ResponseEntity} with the specified {@link HttpStatus}. + */ + static ResponseEntity of(HttpStatus status) { + return of(ResponseHeaders.of(status)); + } + + /** + * Returns a newly created {@link ResponseEntity} with the specified {@link HttpStatus} and + * {@code content}. + */ + static ResponseEntity of(HttpStatus status, T content) { + return of(ResponseHeaders.of(status), content); + } + + /** + * Returns a newly created {@link ResponseEntity} with the specified {@link HttpStatus}, * {@code content} and {@linkplain HttpHeaders trailers}. */ - static ResponseEntity of(ResponseHeaders headers, @Nullable T content, HttpHeaders trailers) { - requireNonNull(headers, "headers"); - requireNonNull(trailers, "trailers"); - return new DefaultResponseEntity<>(headers, content, trailers); + static ResponseEntity of(HttpStatus status, T content, HttpHeaders trailers) { + return of(ResponseHeaders.of(status), content, trailers); + } + + /** + * Returns a newly created {@link ResponseEntity} with the specified {@code content} and + * {@link HttpStatus#OK} status. + */ + static ResponseEntity of(T content) { + return of(HttpStatus.OK, content); } /** diff --git a/core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java b/core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java new file mode 100644 index 00000000000..b254a85e1f9 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/StaticTlsProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import com.google.common.base.MoreObjects; + +final class StaticTlsProvider implements TlsProvider { + + private final TlsKeyPair tlsKeyPair; + + StaticTlsProvider(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + this.tlsKeyPair = tlsKeyPair; + } + + @Override + public TlsKeyPair keyPair(String hostname) { + return tlsKeyPair; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StaticTlsProvider)) { + return false; + } + final StaticTlsProvider that = (StaticTlsProvider) o; + return tlsKeyPair.equals(that.tlsKeyPair); + } + + @Override + public int hashCode() { + return tlsKeyPair.hashCode(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("tlsKeyPair", tlsKeyPair) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java b/core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java new file mode 100644 index 00000000000..112183f3f78 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/StreamTimeoutException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import java.time.Duration; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.stream.StreamMessage; + +/** + * A {@link TimeoutException} raised when a stream operation exceeds the configured timeout. + * + * @see StreamMessage#timeout(Duration) + */ +public final class StreamTimeoutException extends TimeoutException { + + private static final long serialVersionUID = 7585558758307122722L; + + /** + * Creates a new instance with the specified {@code message}. + */ + public StreamTimeoutException(@Nullable String message) { + super(message); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java b/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java index 0d12d8def59..5e64edf5711 100644 --- a/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java +++ b/core/src/main/java/com/linecorp/armeria/common/StringMultimap.java @@ -722,7 +722,7 @@ final void addObjectWithoutNotifying(IN_NAME name, Iterable values) { final void addObject(IN_NAME name, Object value) { final NAME normalizedName = normalizeName(name); requireNonNull(value, "value"); - addObjectAndNotify(normalizedName, fromObject(value), true); + addObjectAndNotify(normalizedName, value, true); } final void addObject(IN_NAME name, Iterable values) { diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java b/core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java new file mode 100644 index 00000000000..51f5f2a8092 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TlsKeyPair.java @@ -0,0 +1,178 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import static com.linecorp.armeria.internal.common.util.CertificateUtil.toPrivateKey; +import static com.linecorp.armeria.internal.common.util.CertificateUtil.toX509Certificates; +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.InputStream; +import java.security.KeyException; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.List; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.SystemInfo; +import com.linecorp.armeria.internal.common.util.SelfSignedCertificate; + +/** + * A pair of a {@link PrivateKey} and a {@link X509Certificate} chain. + */ +@UnstableApi +public final class TlsKeyPair { + + /** + * Creates a new {@link TlsKeyPair} from the specified key {@link InputStream}, and certificate chain + * {@link InputStream}. + */ + public static TlsKeyPair of(InputStream keyInputStream, InputStream certificateChainInputStream) { + return of(keyInputStream, null, certificateChainInputStream); + } + + /** + * Creates a new {@link TlsKeyPair} from the specified key {@link InputStream}, key password + * {@link InputStream} and certificate chain {@link InputStream}. + */ + public static TlsKeyPair of(InputStream keyInputStream, @Nullable String keyPassword, + InputStream certificateChainInputStream) { + requireNonNull(keyInputStream, "keyInputStream"); + requireNonNull(certificateChainInputStream, "certificateChainInputStream"); + try { + final List certs = toX509Certificates(certificateChainInputStream); + final PrivateKey key = toPrivateKey(keyInputStream, keyPassword); + return of(key, certs); + } catch (CertificateException | KeyException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Creates a new {@link TlsKeyPair} from the specified key file and certificate chain file. + */ + public static TlsKeyPair of(File keyFile, File certificateChainFile) { + return of(keyFile, null, certificateChainFile); + } + + /** + * Creates a new {@link TlsKeyPair} from the specified key file, key password and certificate chain + * file. + */ + public static TlsKeyPair of(File keyFile, @Nullable String keyPassword, File certificateChainFile) { + requireNonNull(keyFile, "keyFile"); + requireNonNull(certificateChainFile, "certificateChainFile"); + try { + final List certs = toX509Certificates(certificateChainFile); + final PrivateKey key = toPrivateKey(keyFile, keyPassword); + return of(key, certs); + } catch (CertificateException | KeyException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Creates a new {@link TlsKeyPair} from the specified {@link PrivateKey} and {@link X509Certificate}s. + */ + public static TlsKeyPair of(PrivateKey key, X509Certificate... certificateChain) { + requireNonNull(certificateChain, "certificateChain"); + return of(key, ImmutableList.copyOf(certificateChain)); + } + + /** + * Creates a new {@link TlsKeyPair} from the specified {@link PrivateKey} and {@link X509Certificate}s. + */ + public static TlsKeyPair of(PrivateKey key, Iterable certificateChain) { + requireNonNull(key, "key"); + requireNonNull(certificateChain, "certificateChain"); + return new TlsKeyPair(key, ImmutableList.copyOf(certificateChain)); + } + + /** + * Generates a self-signed certificate for the specified {@code hostname}. + */ + public static TlsKeyPair ofSelfSigned(String hostname) { + requireNonNull(hostname, "hostname"); + try { + final SelfSignedCertificate ssc = new SelfSignedCertificate(hostname); + return of(ssc.key(), ssc.cert()); + } catch (CertificateException e) { + throw new IllegalStateException("Failed to create a self-signed certificate for " + hostname, e); + } + } + + /** + * Generates a self-signed certificate for the local hostname. + */ + public static TlsKeyPair ofSelfSigned() { + return ofSelfSigned(SystemInfo.hostname()); + } + + private final PrivateKey privateKey; + private final List certificateChain; + + private TlsKeyPair(PrivateKey privateKey, List certificateChain) { + this.privateKey = privateKey; + this.certificateChain = certificateChain; + } + + /** + * Returns the private key. + */ + public PrivateKey privateKey() { + return privateKey; + } + + /** + * Returns the certificate chain. + */ + public List certificateChain() { + return certificateChain; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof TlsKeyPair)) { + return false; + } + + final TlsKeyPair that = (TlsKeyPair) o; + return privateKey.equals(that.privateKey) && certificateChain.equals(that.certificateChain); + } + + @Override + public int hashCode() { + return privateKey.hashCode() * 31 + certificateChain.hashCode(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("privateKey", "****") + .add("certificateChain", certificateChain) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsProvider.java b/core/src/main/java/com/linecorp/armeria/common/TlsProvider.java new file mode 100644 index 00000000000..d7256fcd217 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TlsProvider.java @@ -0,0 +1,87 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.security.cert.X509Certificate; +import java.util.List; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Provides {@link TlsKeyPair}s for TLS handshakes. + */ +@UnstableApi +@FunctionalInterface +public interface TlsProvider { + + /** + * Returns a {@link TlsProvider} which always returns the specified {@link TlsKeyPair}. + */ + static TlsProvider of(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + return builder().keyPair(tlsKeyPair).build(); + } + + /** + * Returns a newly created {@link TlsProviderBuilder}. + * + *

Example usage: + *

{@code
+     * TlsProvider
+     *   .builder()
+     *   // Set the default key pair.
+     *   .keyPair(TlsKeyPair.of(...))
+     *   // Set the key pair for "api.example.com".
+     *   .keyPair("api.example.com", TlsKeyPair.of(...))
+     *   // Set the key pair for "web.example.com".
+     *   .keyPair("web.example.com", TlsKeyPair.of(...))
+     *   .build();
+     * }
+ */ + static TlsProviderBuilder builder() { + return new TlsProviderBuilder(); + } + + /** + * Finds a {@link TlsKeyPair} for the specified {@code hostname}. + * + *

If no matching {@link TlsKeyPair} is found for a hostname, {@code "*"} will be specified to get the + * default {@link TlsKeyPair}. + * If no default {@link TlsKeyPair} is found, {@code null} will be returned. + * + *

Note that this operation is executed in an event loop thread, so it should not be blocked. + */ + @Nullable + TlsKeyPair keyPair(String hostname); + + /** + * Returns trusted certificates for verifying the remote endpoint's certificate. + * + *

If no matching {@link X509Certificate}s are found for a hostname, {@code "*"} will be specified to get + * the default {@link X509Certificate}s. + * The system default will be used if this method returns null. + * + *

Note that this operation is executed in an event loop thread, so it should not be blocked. + */ + @Nullable + default List trustedCertificates(String hostname) { + return null; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java b/core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java new file mode 100644 index 00000000000..39af95aa721 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/TlsProviderBuilder.java @@ -0,0 +1,155 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.client.ClientFactoryBuilder; +import com.linecorp.armeria.internal.common.TlsProviderUtil; +import com.linecorp.armeria.server.ServerBuilder; + +/** + * A builder for {@link TlsProvider}. + * + * @see ClientFactoryBuilder#tlsProvider(TlsProvider) + * @see ServerBuilder#tlsProvider(TlsProvider) + */ +public final class TlsProviderBuilder { + + private final ImmutableMap.Builder tlsKeyPairsBuilder = ImmutableMap.builder(); + private final ImmutableMap.Builder> x509CertificateBuilder = + ImmutableMap.builder(); + + /** + * Creates a new instance. + */ + TlsProviderBuilder() {} + + /** + * Sets the {@link TlsKeyPair} for the specified (optionally wildcard) {@code hostname}. + * + *

DNS wildcard is supported as hostname. + * The wildcard will only match one sub-domain deep and only when wildcard is used as the most-left label. + * For example, *.armeria.dev will match foo.armeria.dev but NOT bar.foo.armeria.dev + * + *

Note that {@code "*"} is a special hostname which matches any hostname which may be used to find the + * {@link TlsKeyPair} for the {@linkplain ServerBuilder#defaultVirtualHost() default virtual host}. + * + *

The {@link TlsKeyPair} will be used for + * client certificate authentication + * when it is used for a client. + */ + public TlsProviderBuilder keyPair(String hostname, TlsKeyPair tlsKeyPair) { + requireNonNull(hostname, "hostname"); + requireNonNull(tlsKeyPair, "tlsKeyPair"); + tlsKeyPairsBuilder.put(normalize(hostname), tlsKeyPair); + return this; + } + + /** + * Sets the default {@link TlsKeyPair} which is used when no {@link TlsKeyPair} is specified for a hostname. + * + *

The {@link TlsKeyPair} will be used for + * client certificate authentication + * when it is used for a client. + */ + public TlsProviderBuilder keyPair(TlsKeyPair tlsKeyPair) { + return keyPair("*", tlsKeyPair); + } + + /** + * Sets the specified {@link X509Certificate}s to the trusted certificates that will be used for verifying + * the remote endpoint's certificate. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(String hostname, X509Certificate... trustedCertificates) { + requireNonNull(trustedCertificates, "trustedCertificates"); + return trustedCertificates(hostname, ImmutableList.copyOf(trustedCertificates)); + } + + /** + * Sets the specified {@link X509Certificate}s to the trusted certificates that will be used for verifying + * the specified {@code hostname}'s certificate. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(String hostname, + Iterable trustedCertificates) { + requireNonNull(hostname, "hostname"); + requireNonNull(trustedCertificates, "trustedCertificates"); + x509CertificateBuilder.put(normalize(hostname), ImmutableList.copyOf(trustedCertificates)); + return this; + } + + /** + * Sets the default {@link X509Certificate}s to the trusted certificates that is used for verifying + * the remote endpoint's certificate if no specific trusted certificates are set for a hostname. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(X509Certificate... trustedCertificates) { + requireNonNull(trustedCertificates, "trustedCertificates"); + return trustedCertificates(ImmutableList.copyOf(trustedCertificates)); + } + + /** + * Sets the default {@link X509Certificate}s to the trusted certificates that is used for verifying + * the remote endpoint's certificate if no specific trusted certificates are set for a hostname. + * + *

The system default will be used if no specific trusted certificates are set for a hostname and no + * default trusted certificates are set. + */ + public TlsProviderBuilder trustedCertificates(Iterable trustedCertificates) { + return trustedCertificates("*", trustedCertificates); + } + + private static String normalize(String hostname) { + if ("*".equals(hostname)) { + return "*"; + } else { + return TlsProviderUtil.normalizeHostname(hostname); + } + } + + /** + * Returns a newly-created {@link TlsProvider} instance. + */ + public TlsProvider build() { + final Map keyPairMappings = tlsKeyPairsBuilder.build(); + if (keyPairMappings.isEmpty()) { + throw new IllegalStateException("No TLS key pair is set."); + } + + final Map> trustedCerts = x509CertificateBuilder.build(); + if (keyPairMappings.size() == 1 && keyPairMappings.containsKey("*") && trustedCerts.isEmpty()) { + return new StaticTlsProvider(keyPairMappings.get("*")); + } + + return new MappedTlsProvider(keyPairMappings, trustedCerts); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java b/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java index f8691b12e0e..cf42957a4f6 100644 --- a/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java +++ b/core/src/main/java/com/linecorp/armeria/common/TlsSetters.java @@ -16,8 +16,6 @@ package com.linecorp.armeria.common; -import static java.util.Objects.requireNonNull; - import java.io.File; import java.io.InputStream; import java.security.PrivateKey; @@ -26,8 +24,6 @@ import javax.net.ssl.KeyManagerFactory; -import com.google.common.collect.ImmutableList; - import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -42,62 +38,100 @@ public interface TlsSetters { /** * Configures SSL or TLS with the specified {@code keyCertChainFile} * and cleartext {@code keyFile}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(File keyCertChainFile, File keyFile) { - return tls(keyCertChainFile, keyFile, null); + return tls(TlsKeyPair.of(keyFile, keyCertChainFile)); } /** * Configures SSL or TLS with the specified {@code keyCertChainFile}, * {@code keyFile} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ - TlsSetters tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword); + @Deprecated + default TlsSetters tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword) { + return tls(TlsKeyPair.of(keyFile, keyPassword, keyCertChainFile)); + } /** * Configures SSL or TLS with the specified {@code keyCertChainInputStream} and * cleartext {@code keyInputStream}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { - return tls(keyCertChainInputStream, keyInputStream, null); + return tls(TlsKeyPair.of(keyInputStream, null, keyCertChainInputStream)); } /** * Configures SSL or TLS of this with the specified {@code keyCertChainInputStream}, * {@code keyInputStream} and {@code keyPassword}. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ - TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream, - @Nullable String keyPassword); + @Deprecated + default TlsSetters tls(InputStream keyCertChainInputStream, InputStream keyInputStream, + @Nullable String keyPassword) { + return tls(TlsKeyPair.of(keyInputStream, keyPassword, keyCertChainInputStream)); + } /** * Configures SSL or TLS with the specified cleartext {@link PrivateKey} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(PrivateKey key, X509Certificate... keyCertChain) { - return tls(key, null, keyCertChain); + return tls(TlsKeyPair.of(key, keyCertChain)); } /** * Configures SSL or TLS with the specified cleartext {@link PrivateKey} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(PrivateKey key, Iterable keyCertChain) { - return tls(key, null, keyCertChain); + return tls(TlsKeyPair.of(key, keyCertChain)); } /** * Configures SSL or TLS with the specified {@link PrivateKey}, {@code keyPassword} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. */ + @Deprecated default TlsSetters tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain) { - return tls(key, keyPassword, ImmutableList.copyOf(requireNonNull(keyCertChain, "keyCertChain"))); + // keyPassword is not required for PrivateKey since it is not encrypted. + return tls(TlsKeyPair.of(key, keyCertChain)); } /** * Configures SSL or TLS with the specified {@link PrivateKey}, {@code keyPassword} and * {@link X509Certificate} chain. + * + * @deprecated Use {@link #tls(TlsKeyPair)} instead. + */ + @Deprecated + default TlsSetters tls(PrivateKey key, @Nullable String keyPassword, + Iterable keyCertChain) { + // keyPassword is not required for PrivateKey since it is not encrypted. + return tls(TlsKeyPair.of(key, keyCertChain)); + } + + /** + * Configures SSL or TLS with the specified {@link TlsKeyPair}. */ - TlsSetters tls(PrivateKey key, @Nullable String keyPassword, - Iterable keyCertChain); + @UnstableApi + TlsSetters tls(TlsKeyPair tlsKeyPair); /** * Configures SSL or TLS with the specified {@link KeyManagerFactory}. diff --git a/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java b/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java index 7cd223bb086..250eed2fae3 100644 --- a/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java +++ b/core/src/main/java/com/linecorp/armeria/common/logging/DefaultRequestLog.java @@ -992,11 +992,7 @@ public void requestContent(@Nullable Object requestContent, @Nullable Object raw ctx.updateRpcRequest((RpcRequest) requestContent); } updateFlags(RequestLogProperty.REQUEST_CONTENT); - - final int requestCompletionFlags = RequestLogProperty.FLAGS_REQUEST_COMPLETE & ~deferredFlags; - if (isAvailable(requestCompletionFlags)) { - setNamesIfAbsent(); - } + setNamesIfAbsent(); } @Nullable @@ -1104,12 +1100,11 @@ private void endRequest0(@Nullable Throwable requestCause, long requestEndTimeNa } } - // Set names if request content is not deferred or it was deferred but has been set - // before the request completion. - if (!hasInterestedFlags(deferredFlags, RequestLogProperty.REQUEST_CONTENT) || - isAvailable(RequestLogProperty.REQUEST_CONTENT)) { + // Set names if request content is not deferred + if (!hasInterestedFlags(deferredFlags, RequestLogProperty.REQUEST_CONTENT)) { setNamesIfAbsent(); } + this.requestEndTimeNanos = requestEndTimeNanos; if (requestCause instanceof HttpStatusException || requestCause instanceof HttpResponseException) { diff --git a/core/src/main/java/com/linecorp/armeria/common/logging/RequestLogBuilder.java b/core/src/main/java/com/linecorp/armeria/common/logging/RequestLogBuilder.java index 6b1badfb1a9..61915cfaaa6 100644 --- a/core/src/main/java/com/linecorp/armeria/common/logging/RequestLogBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/common/logging/RequestLogBuilder.java @@ -109,12 +109,16 @@ void session(@Nullable Channel channel, SessionProtocol sessionProtocol, @Nullab *

  • A path pattern and HTTP method name for {@link HttpService}
  • * * This property is often used as a meter tag or distributed trace's span name. + * Note that calling {@link #responseContent(Object, Object)} will automatically fill + * {@link RequestLogProperty#NAME}, so {@link #name(String, String)} must be called beforehand. */ void name(String serviceName, String name); /** * Sets the human-readable name of the {@link Request}, such as RPC method name, annotated service method * name or HTTP method name. This property is often used as a meter tag or distributed trace's span name. + * Note that calling {@link #responseContent(Object, Object)} will automatically fill + * {@link RequestLogProperty#NAME}, so {@link #name(String)} must be called beforehand. */ void name(String name); diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java b/core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java new file mode 100644 index 00000000000..5a7659ec70e --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/metric/AbstractCloseableMeterBinder.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common.metric; + +import java.util.ArrayList; +import java.util.List; + +import com.linecorp.armeria.internal.common.util.ReentrantShortLock; + +abstract class AbstractCloseableMeterBinder implements CloseableMeterBinder { + + private final List closingTasks = new ArrayList<>(); + private final ReentrantShortLock lock = new ReentrantShortLock(); + + protected final void addClosingTask(Runnable closingTask) { + lock.lock(); + try { + closingTasks.add(closingTask); + } finally { + lock.unlock(); + } + } + + @Override + public void close() { + lock.lock(); + try { + for (Runnable task : closingTasks) { + task.run(); + } + closingTasks.clear(); + } finally { + lock.unlock(); + } + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java b/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java index ed0bc1a6e1c..212526e9eff 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/CertificateMetrics.java @@ -23,15 +23,29 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import com.google.common.base.MoreObjects; + import com.linecorp.armeria.internal.common.util.CertificateUtil; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.binder.MeterBinder; -final class CertificateMetrics implements MeterBinder { +/** + * A {@link MeterBinder} that provides metrics for TLS certificates. + * The following stats are currently exported per registered {@link MeterIdPrefix}. + * + *
      + *
    • "tls.certificate.validity" (gauge) - 1 if TLS certificate is in validity period, 0 if certificate + * is not in validity period
    • + *
    • "tls.certificate.validity.days" (gauge) - Duration in days before TLS certificate expires, which + * becomes -1 if certificate is expired
    • + *
    + */ +public final class CertificateMetrics extends AbstractCloseableMeterBinder { private final List certificates; private final MeterIdPrefix meterIdPrefix; @@ -43,33 +57,55 @@ final class CertificateMetrics implements MeterBinder { @Override public void bindTo(MeterRegistry registry) { + final List meters = new ArrayList<>(certificates.size() * 2); for (X509Certificate certificate : certificates) { final String commonName = firstNonNull(CertificateUtil.getCommonName(certificate), ""); - Gauge.builder(meterIdPrefix.name("tls.certificate.validity"), certificate, x509Cert -> { - try { - x509Cert.checkValidity(); - } catch (CertificateExpiredException | CertificateNotYetValidException e) { - return 0; - } - return 1; - }) - .description("1 if TLS certificate is in validity period, 0 if certificate is not in " + - "validity period") - .tags("common.name", commonName) - .tags(meterIdPrefix.tags()) - .register(registry); + final Gauge validityMeter = + Gauge.builder(meterIdPrefix.name("tls.certificate.validity"), certificate, x509Cert -> { + try { + x509Cert.checkValidity(); + } catch (CertificateExpiredException | CertificateNotYetValidException e) { + return 0; + } + return 1; + }) + .description( + "1 if TLS certificate is in validity period, 0 if certificate is not in " + + "validity period") + .tags("common.name", commonName) + .tags(meterIdPrefix.tags()) + .register(registry); + meters.add(validityMeter); - Gauge.builder(meterIdPrefix.name("tls.certificate.validity.days"), certificate, x509Cert -> { - final Duration diff = Duration.between(Instant.now(), - x509Cert.getNotAfter().toInstant()); - return diff.isNegative() ? -1 : diff.toDays(); - }) - .description("Duration in days before TLS certificate expires, which becomes -1 " + - "if certificate is expired") - .tags("common.name", commonName) - .tags(meterIdPrefix.tags()) - .register(registry); + final Gauge validityDaysMeter = + Gauge.builder(meterIdPrefix.name("tls.certificate.validity.days"), certificate, + x509Cert -> { + final Instant notAfter = x509Cert.getNotAfter().toInstant(); + final Duration diff = + Duration.between(Instant.now(), notAfter); + return diff.toDays(); + }) + .description("Duration in days before TLS certificate expires, which becomes -1 " + + "if certificate is expired") + .tags("common.name", commonName) + .tags(meterIdPrefix.tags()) + .register(registry); + meters.add(validityDaysMeter); } + + addClosingTask(() -> { + for (Gauge meter : meters) { + registry.remove(meter); + } + }); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("certificates", certificates) + .add("meterIdPrefix", meterIdPrefix) + .toString(); } } diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java b/core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java new file mode 100644 index 00000000000..ac0f7fb5943 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/metric/CloseableMeterBinder.java @@ -0,0 +1,31 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common.metric; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.SafeCloseable; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; + +/** + * A {@link MeterBinder} that cleans up the registered metrics by + * {@link MeterBinder#bindTo(MeterRegistry)} via {@link SafeCloseable#close()}. + */ +@UnstableApi +public interface CloseableMeterBinder extends MeterBinder, SafeCloseable { +} diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java b/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java index 35eac7a8b64..760486b7b81 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/EventLoopMetrics.java @@ -41,7 +41,7 @@ * - the total number of IO tasks waiting to be run on event loops * **/ -final class EventLoopMetrics implements MeterBinder { +public final class EventLoopMetrics extends AbstractCloseableMeterBinder { private final EventLoopGroup eventLoopGroup; private final MeterIdPrefix idPrefix; @@ -58,6 +58,8 @@ final class EventLoopMetrics implements MeterBinder { public void bindTo(MeterRegistry registry) { final Self metrics = MicrometerUtil.register(registry, idPrefix, Self.class, Self::new); metrics.add(eventLoopGroup); + + addClosingTask(() -> metrics.remove(eventLoopGroup)); } /** @@ -79,6 +81,10 @@ void add(EventLoopGroup eventLoopGroup) { registry.add(eventLoopGroup); } + void remove(EventLoopGroup eventLoopGroup) { + registry.remove(eventLoopGroup); + } + double numWorkers() { int result = 0; for (EventLoopGroup group : registry) { @@ -97,7 +103,7 @@ void add(EventLoopGroup eventLoopGroup) { for (EventLoopGroup group : registry) { // Purge event loop groups that were shutdown. if (group.isShutdown()) { - registry.remove(group); + remove(group); continue; } for (EventExecutor eventLoop : group) { diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/MeterIdPrefixFunction.java b/core/src/main/java/com/linecorp/armeria/common/metric/MeterIdPrefixFunction.java index 48c6f6bb7fe..cf0e331eed3 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/MeterIdPrefixFunction.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/MeterIdPrefixFunction.java @@ -55,6 +55,7 @@ public interface MeterIdPrefixFunction { *
  • Client-side tags:
      *
    • {@code method} - RPC method name or {@link HttpMethod#name()} if RPC method name is not * available
    • + *
    • {@code service} - RPC service name or innermost service class name
    • *
    • {@code httpStatus} - {@link HttpStatus#code()}
    • *
  • * diff --git a/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java b/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java index f687b914efc..4c82f8d993c 100644 --- a/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java +++ b/core/src/main/java/com/linecorp/armeria/common/metric/MoreMeterBinders.java @@ -25,17 +25,26 @@ import com.google.common.collect.ImmutableList; +import com.linecorp.armeria.common.Flags; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.internal.common.util.CertificateUtil; import io.micrometer.core.instrument.binder.MeterBinder; +import io.micrometer.core.instrument.binder.netty4.NettyAllocatorMetrics; +import io.netty.buffer.PooledByteBufAllocator; import io.netty.channel.EventLoopGroup; /** - * Provides useful {@link MeterBinder}s to monitor various Armeria components. + * Provides useful {@link MeterBinder}s to monitor various Armeria components. */ public final class MoreMeterBinders { + static { + // Bind the default Netty allocator metrics to the default MeterRegistry. + new NettyAllocatorMetrics(PooledByteBufAllocator.DEFAULT) + .bindTo(Flags.meterRegistry()); + } + /** * Returns a new {@link MeterBinder} to observe Netty's {@link EventLoopGroup}s. The following stats are * currently exported per registered {@link MeterIdPrefix}. @@ -47,7 +56,7 @@ public final class MoreMeterBinders { * */ @UnstableApi - public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, String name) { + public static CloseableMeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, String name) { requireNonNull(name, "name"); return eventLoopMetrics(eventLoopGroup, new MeterIdPrefix("armeria.netty." + name)); } @@ -63,7 +72,8 @@ public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, String * */ @UnstableApi - public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, MeterIdPrefix meterIdPrefix) { + public static CloseableMeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, + MeterIdPrefix meterIdPrefix) { return new EventLoopMetrics(eventLoopGroup, meterIdPrefix); } @@ -82,7 +92,8 @@ public static MeterBinder eventLoopMetrics(EventLoopGroup eventLoopGroup, MeterI * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(X509Certificate certificate, MeterIdPrefix meterIdPrefix) { + public static CloseableMeterBinder certificateMetrics(X509Certificate certificate, + MeterIdPrefix meterIdPrefix) { requireNonNull(certificate, "certificate"); return certificateMetrics(ImmutableList.of(certificate), meterIdPrefix); } @@ -102,8 +113,8 @@ public static MeterBinder certificateMetrics(X509Certificate certificate, MeterI * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(Iterable certificates, - MeterIdPrefix meterIdPrefix) { + public static CloseableMeterBinder certificateMetrics(Iterable certificates, + MeterIdPrefix meterIdPrefix) { requireNonNull(certificates, "certificates"); requireNonNull(meterIdPrefix, "meterIdPrefix"); return new CertificateMetrics(ImmutableList.copyOf(certificates), meterIdPrefix); @@ -124,7 +135,7 @@ public static MeterBinder certificateMetrics(Iterable * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(File keyCertChainFile, MeterIdPrefix meterIdPrefix) + public static CloseableMeterBinder certificateMetrics(File keyCertChainFile, MeterIdPrefix meterIdPrefix) throws CertificateException { requireNonNull(keyCertChainFile, "keyCertChainFile"); return certificateMetrics(CertificateUtil.toX509Certificates(keyCertChainFile), meterIdPrefix); @@ -145,7 +156,8 @@ public static MeterBinder certificateMetrics(File keyCertChainFile, MeterIdPrefi * @param meterIdPrefix the prefix to use for all metrics */ @UnstableApi - public static MeterBinder certificateMetrics(InputStream keyCertChainFile, MeterIdPrefix meterIdPrefix) + public static CloseableMeterBinder certificateMetrics(InputStream keyCertChainFile, + MeterIdPrefix meterIdPrefix) throws CertificateException { requireNonNull(keyCertChainFile, "keyCertChainFile"); return certificateMetrics(CertificateUtil.toX509Certificates(keyCertChainFile), meterIdPrefix); diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java index 60727d202bc..9c5d0c96e3b 100644 --- a/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java +++ b/core/src/main/java/com/linecorp/armeria/common/stream/StreamMessage.java @@ -29,6 +29,7 @@ import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -49,6 +50,7 @@ import com.linecorp.armeria.common.CommonPools; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.StreamTimeoutException; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.util.Exceptions; @@ -1210,4 +1212,48 @@ default StreamMessage subscribeOn(EventExecutor eventExecutor) { requireNonNull(eventExecutor, "eventExecutor"); return new SubscribeOnStreamMessage<>(this, eventExecutor); } + + /** + * Configures a timeout for the stream based on the specified duration with + * {@link StreamTimeoutMode#UNTIL_NEXT}. If no events are received within the specified duration, + * the stream will be terminated with a {@link StreamTimeoutException}. + * + *

    Example usage: + *

    {@code
    +     * StreamMessage stream = ...;
    +     * // An item must be received within 10 seconds of the previous item to avoid a timeout.
    +     * StreamMessage timeoutStream = stream.timeout(Duration.ofSeconds(10));
    +     * }
    + * + * @param timeoutDuration the duration before a timeout occurs + * @return a new {@link StreamMessage} with the specified timeout duration and default mode + */ + @UnstableApi + default StreamMessage timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + /** + * Configures a timeout for the stream based on the specified duration and mode. >If no events are received + * within the specified duration, the stream will be terminated with a {@link StreamTimeoutException}. + * + *

    Example usage: + *

    {@code
    +     * StreamMessage stream = ...;
    +     * StreamMessage timeoutStream = stream.timeout(
    +     *     Duration.ofSeconds(10),
    +     *     StreamTimeoutMode.UNTIL_FIRST
    +     * );
    +     * }
    + * + * @param timeoutDuration the duration before a timeout occurs + * @param timeoutMode the mode in which the timeout is applied (see {@link StreamTimeoutMode} for details) + * @return a new {@link StreamMessage} with the specified timeout duration and mode applied + */ + @UnstableApi + default StreamMessage timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + requireNonNull(timeoutDuration, "timeoutDuration"); + requireNonNull(timeoutMode, "timeoutMode"); + return new TimeoutStreamMessage<>(this, timeoutDuration, timeoutMode); + } } diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java b/core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java new file mode 100644 index 00000000000..620fec4bcad --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/stream/StreamTimeoutMode.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common.stream; + +import com.linecorp.armeria.common.StreamTimeoutException; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Stream Timeout Mode consists of three modes. + * + *
      + *
    • {@link #UNTIL_FIRST} - Based on the first data chunk. + * If the first data chunk is not received within the specified time, + * a {@link StreamTimeoutException} is thrown.
    • + *
    • {@link #UNTIL_NEXT} - Based on each data chunk. + * If each data chunk is not received within the specified time after the previous chunk, + * a {@link StreamTimeoutException} is thrown.
    • + *
    • {@link #UNTIL_EOS} - Based on the entire stream. + * If all data chunks are not received within the specified time before the end of the stream, + * a {@link StreamTimeoutException} is thrown.
    • + *
    + */ +@UnstableApi +public enum StreamTimeoutMode { + + /** + * Based on the first data chunk. + * If the first data chunk is not received within the specified time, + * a {@link StreamTimeoutException} is thrown. + */ + UNTIL_FIRST, + + /** + * Based on each data chunk. + * If each data chunk is not received within the specified time after the previous chunk, + * a {@link StreamTimeoutException} is thrown. + */ + UNTIL_NEXT, + + /** + * Based on the entire stream. + * If all data chunks are not received within the specified time before the end of the stream, + * a {@link StreamTimeoutException} is thrown. + */ + UNTIL_EOS +} diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java new file mode 100644 index 00000000000..4e13c462d68 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/stream/TimeoutStreamMessage.java @@ -0,0 +1,229 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common.stream; + +import static java.util.Objects.requireNonNull; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.StreamTimeoutException; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.unsafe.PooledObjects; + +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.ScheduledFuture; + +/** + * This class provides timeout functionality to a base StreamMessage. + * If data is not received within the specified time, a {@link StreamTimeoutException} is thrown. + * + *

    The timeout functionality helps to release resources and throw appropriate exceptions + * if the stream becomes inactive or data is not received within a certain time frame, + * thereby improving system efficiency. + * + * @param the type of the elements signaled + */ +final class TimeoutStreamMessage implements StreamMessage { + + private final StreamMessage delegate; + private final Duration timeoutDuration; + private final StreamTimeoutMode timeoutMode; + + /** + * Creates a new TimeoutStreamMessage with the specified base stream message and timeout settings. + * + * @param delegate the original stream message + * @param timeoutDuration the duration before a timeout occurs + * @param timeoutMode the mode in which the timeout is applied (see {@link StreamTimeoutMode} for details) + */ + TimeoutStreamMessage(StreamMessage delegate, Duration timeoutDuration, + StreamTimeoutMode timeoutMode) { + this.delegate = requireNonNull(delegate, "delegate"); + this.timeoutDuration = requireNonNull(timeoutDuration, "timeoutDuration"); + this.timeoutMode = requireNonNull(timeoutMode, "timeoutMode"); + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public long demand() { + return delegate.demand(); + } + + @Override + public CompletableFuture whenComplete() { + return delegate.whenComplete(); + } + + /** + * Subscribes the given subscriber to this stream with timeout logic applied. + * + * @param subscriber the subscriber to this stream + * @param executor the executor for running timeout tasks and stream operations + * @param options subscription options + * @see StreamMessage#subscribe(Subscriber, EventExecutor, SubscriptionOption...) + */ + @Override + public void subscribe(Subscriber subscriber, EventExecutor executor, + SubscriptionOption... options) { + delegate.subscribe(new TimeoutSubscriber<>(subscriber, executor, timeoutDuration, timeoutMode), + executor, options); + } + + @Override + public void abort() { + delegate.abort(); + } + + @Override + public void abort(Throwable cause) { + delegate.abort(cause); + } + + static final class TimeoutSubscriber implements Runnable, Subscriber, Subscription { + + private static final String TIMEOUT_MESSAGE = "Stream timed out after %d ms (timeout mode: %s)"; + private final Subscriber delegate; + private final EventExecutor executor; + private final StreamTimeoutMode timeoutMode; + private final Duration timeoutDuration; + private final long timeoutNanos; + @Nullable + private ScheduledFuture timeoutFuture; + @Nullable + private Subscription subscription; + private long lastEventTimeNanos; + private boolean completed; + private volatile boolean canceled; + + TimeoutSubscriber(Subscriber delegate, EventExecutor executor, Duration timeoutDuration, + StreamTimeoutMode timeoutMode) { + this.delegate = requireNonNull(delegate, "delegate"); + this.executor = requireNonNull(executor, "executor"); + this.timeoutDuration = requireNonNull(timeoutDuration, "timeoutDuration"); + timeoutNanos = timeoutDuration.toNanos(); + this.timeoutMode = requireNonNull(timeoutMode, "timeoutMode"); + } + + private ScheduledFuture scheduleTimeout(long delay) { + return executor.schedule(this, delay, TimeUnit.NANOSECONDS); + } + + void cancelSchedule() { + if (timeoutFuture != null && !timeoutFuture.isCancelled()) { + timeoutFuture.cancel(false); + } + } + + @Override + public void run() { + if (timeoutMode == StreamTimeoutMode.UNTIL_NEXT) { + final long currentTimeNanos = System.nanoTime(); + final long elapsedNanos = currentTimeNanos - lastEventTimeNanos; + + if (elapsedNanos < timeoutNanos) { + final long delayNanos = timeoutNanos - elapsedNanos; + timeoutFuture = scheduleTimeout(delayNanos); + return; + } + } + completed = true; + delegate.onError(new StreamTimeoutException( + String.format(TIMEOUT_MESSAGE, timeoutDuration.toMillis(), timeoutMode))); + assert subscription != null; + subscription.cancel(); + } + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + delegate.onSubscribe(this); + if (completed || canceled) { + return; + } + lastEventTimeNanos = System.nanoTime(); + timeoutFuture = scheduleTimeout(timeoutNanos); + } + + @Override + public void onNext(T t) { + if (completed || canceled) { + PooledObjects.close(t); + return; + } + switch (timeoutMode) { + case UNTIL_NEXT: + lastEventTimeNanos = System.nanoTime(); + break; + case UNTIL_FIRST: + cancelSchedule(); + timeoutFuture = null; + break; + case UNTIL_EOS: + break; + } + delegate.onNext(t); + } + + @Override + public void onError(Throwable throwable) { + if (completed) { + return; + } + completed = true; + cancelSchedule(); + delegate.onError(throwable); + } + + @Override + public void onComplete() { + if (completed) { + return; + } + completed = true; + cancelSchedule(); + delegate.onComplete(); + } + + @Override + public void request(long l) { + assert subscription != null; + subscription.request(l); + } + + @Override + public void cancel() { + canceled = true; + cancelSchedule(); + assert subscription != null; + subscription.cancel(); + } + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java b/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java index 10ae7240377..85e68b3591e 100644 --- a/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java +++ b/core/src/main/java/com/linecorp/armeria/common/websocket/WebSocket.java @@ -15,8 +15,11 @@ */ package com.linecorp.armeria.common.websocket; +import java.time.Duration; + import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.stream.StreamMessage; +import com.linecorp.armeria.common.stream.StreamTimeoutMode; import com.linecorp.armeria.internal.common.websocket.WebSocketWrapper; /** @@ -39,4 +42,16 @@ static WebSocketWriter streaming() { static WebSocket of(StreamMessage delegate) { return new WebSocketWrapper(delegate); } + + @UnstableApi + @Override + default WebSocket timeout(Duration timeoutDuration) { + return timeout(timeoutDuration, StreamTimeoutMode.UNTIL_NEXT); + } + + @UnstableApi + @Override + default WebSocket timeout(Duration timeoutDuration, StreamTimeoutMode timeoutMode) { + return of(StreamMessage.super.timeout(timeoutDuration, timeoutMode)); + } } diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java index 50f04b2c901..2e0f91d0efb 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java @@ -96,7 +96,7 @@ private CompletableFuture> resolveOne(DnsQuestionContext ctx, Dn }); future.handle((unused0, unused1) -> { // Maybe cancel the timeout scheduler. - ctx.cancel(); + ctx.setComplete(); return null; }); return future; @@ -112,7 +112,7 @@ CompletableFuture> resolveAll(DnsQuestionContext ctx, List { assert executor.inEventLoop(); - maybeCompletePreferredRecords(future, questions, results, order, records, cause); + maybeCompletePreferredRecords(ctx, future, questions, results, order, records, cause); return null; }); } @@ -140,7 +140,8 @@ CompletableFuture> resolveAll(DnsQuestionContext ctx, List> future, + static void maybeCompletePreferredRecords(DnsQuestionContext ctx, + CompletableFuture> future, List questions, Object[] results, int order, @Nullable List records, @@ -170,6 +171,7 @@ static void maybeCompletePreferredRecords(CompletableFuture> fut // Found a successful result. assert result instanceof List; future.complete(Collections.unmodifiableList((List) result)); + ctx.setComplete(); return; } @@ -181,6 +183,7 @@ static void maybeCompletePreferredRecords(CompletableFuture> fut unknownHostException.addSuppressed((Throwable) result); } future.completeExceptionally(unknownHostException); + ctx.setComplete(); } public DnsCache dnsCache() { diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java index a7147526fb5..35c8440d570 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java @@ -29,6 +29,7 @@ final class DnsQuestionContext { private final long queryTimeoutMillis; private final CompletableFuture whenCancelled = new CompletableFuture<>(); private final ScheduledFuture scheduledFuture; + private boolean complete; DnsQuestionContext(EventExecutor executor, long queryTimeoutMillis) { this.queryTimeoutMillis = queryTimeoutMillis; @@ -48,12 +49,21 @@ boolean isCancelled() { return whenCancelled.isCompletedExceptionally(); } - void cancel() { + void cancelScheduler() { if (!scheduledFuture.isDone()) { scheduledFuture.cancel(false); } } + void setComplete() { + complete = true; + cancelScheduler(); + } + + boolean isCompleted() { + return complete; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -65,6 +75,7 @@ public boolean equals(Object o) { final DnsQuestionContext that = (DnsQuestionContext) o; return queryTimeoutMillis == that.queryTimeoutMillis && + complete == that.complete && whenCancelled.equals(that.whenCancelled) && scheduledFuture.equals(that.scheduledFuture); } @@ -74,6 +85,7 @@ public int hashCode() { int result = whenCancelled.hashCode(); result = 31 * result + scheduledFuture.hashCode(); result = 31 * result + (int) queryTimeoutMillis; + result = 31 * result + (complete ? 1 : 0); return result; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java index 712c5189927..431d9e993c0 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java @@ -16,6 +16,7 @@ package com.linecorp.armeria.internal.client.dns; +import static com.google.common.base.MoreObjects.firstNonNull; import static com.google.common.collect.ImmutableList.toImmutableList; import java.util.List; @@ -28,6 +29,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.AbstractUnwrappable; @@ -60,15 +62,18 @@ private static List validateSearchDomain(List searchDomains) { return null; } String normalized = searchDomain; - if (searchDomain.charAt(0) != '.') { - normalized = '.' + searchDomain; + if (searchDomain.charAt(0) == '.') { + // Remove the leading dot. + normalized = searchDomain.substring(1); } - if (searchDomain.charAt(searchDomain.length() - 1) != '.') { + if (normalized.charAt(normalized.length() - 1) != '.') { + // Add a trailing dot. normalized += '.'; } try { // Try to create a sample DnsQuestion to validate the search domain. - DnsQuestionWithoutTrailingDot.of("localhost" + normalized, DnsRecordType.A); + DnsQuestionWithoutTrailingDot.of("localhost." + normalized, + DnsRecordType.A); return normalized; } catch (Exception ex) { logger.warn("Ignoring a malformed search domain: '{}'", searchDomain, ex); @@ -96,6 +101,11 @@ private CompletableFuture> resolve0(DnsQuestionContext ctx, new IllegalStateException("resolver is closed already")); } + if (ctx.isCompleted()) { + // Other DnsRecordType may be resolved already. + return UnmodifiableFuture.completedFuture(ImmutableList.of()); + } + return unwrap().resolve(ctx, question).handle((records, cause) -> { if (records != null) { return UnmodifiableFuture.completedFuture(records); @@ -126,14 +136,18 @@ static final class SearchDomainQuestionContext { private final DnsQuestion original; private final String originalName; private final List searchDomains; + private final int numSearchDomains; private final boolean shouldStartWithHostname; + private final boolean hasTrailingDot; private volatile int numAttemptsSoFar; SearchDomainQuestionContext(DnsQuestion original, List searchDomains, int ndots) { this.original = original; this.searchDomains = searchDomains; + numSearchDomains = searchDomains.size(); originalName = original.name(); - shouldStartWithHostname = hasNDots(originalName, ndots); + hasTrailingDot = originalName.endsWith("."); + shouldStartWithHostname = hasNDots(originalName, ndots) || hasTrailingDot || numSearchDomains == 0; } private static boolean hasNDots(String hostname, int ndots) { @@ -157,32 +171,46 @@ DnsQuestion nextQuestion() { @Nullable private DnsQuestion nextQuestion0() { final int numAttemptsSoFar = this.numAttemptsSoFar; - if (numAttemptsSoFar == 0) { - if (originalName.endsWith(".") || searchDomains.isEmpty()) { - return original; - } - if (shouldStartWithHostname) { - return newQuestion(originalName + '.'); + + final int searchDomainPos; + if (shouldStartWithHostname) { + searchDomainPos = numAttemptsSoFar - 1; + } else { + if (numAttemptsSoFar == numSearchDomains) { + // The last attempt uses the hostname itself. + searchDomainPos = -1; } else { - return newQuestion(originalName + searchDomains.get(0)); + searchDomainPos = numAttemptsSoFar; } } - int nextSearchDomainPos = numAttemptsSoFar; - if (shouldStartWithHostname) { - nextSearchDomainPos = numAttemptsSoFar - 1; + if (searchDomainPos >= numSearchDomains) { + // No more search domain to try. + return null; } - if (nextSearchDomainPos < searchDomains.size()) { - return newQuestion(originalName + searchDomains.get(nextSearchDomainPos)); - } - if (nextSearchDomainPos == searchDomains.size() && !shouldStartWithHostname) { - return newQuestion(originalName + '.'); + final String searchDomain; + // -1 means the hostname itself. + if (searchDomainPos == -1) { + searchDomain = null; + } else { + searchDomain = searchDomains.get(searchDomainPos); } - return null; + + return newQuestion(searchDomain); } - private DnsQuestion newQuestion(String hostname) { + private DnsQuestion newQuestion(@Nullable String searchDomain) { + searchDomain = firstNonNull(searchDomain, ""); + final String hostname; + if (hasTrailingDot) { + if (searchDomain.isEmpty()) { + return original; + } + hostname = originalName + searchDomain; + } else { + hostname = originalName + '.' + searchDomain; + } // - As the search domain is validated already, DnsQuestionWithoutTrailingDot should not raise an // exception. // - Use originalName to delete the cache value in RefreshingAddressResolver when the DnsQuestion diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/EndpointToStringUtil.java b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/EndpointToStringUtil.java new file mode 100644 index 00000000000..35ac7e92dcd --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/EndpointToStringUtil.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.client.endpoint; + +import java.util.List; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals; + +public final class EndpointToStringUtil { + + public static String toShortString(List endpoints) { + try (TemporaryThreadLocals acquired = TemporaryThreadLocals.acquire()) { + final StringBuilder builder = acquired.stringBuilder(); + builder.append('['); + for (int i = 0; i < endpoints.size(); i++) { + if (i > 0) { + builder.append(", "); + } + final Endpoint endpoint = endpoints.get(i); + toShortString(builder, endpoint); + } + builder.append(']'); + return builder.toString(); + } + } + + public static String toShortString(Endpoint endpoint) { + try (TemporaryThreadLocals acquired = TemporaryThreadLocals.acquire()) { + final StringBuilder builder = acquired.stringBuilder(); + toShortString(builder, endpoint); + return builder.toString(); + } + } + + private static void toShortString(StringBuilder builder, Endpoint endpoint) { + builder.append(endpoint.host()); + if (endpoint.hasIpAddr() && !endpoint.isIpAddrOnly()) { + builder.append('/').append(endpoint.ipAddr()); + } + if (endpoint.hasPort()) { + builder.append(':').append(endpoint.port()); + } + builder.append(" (weight: ").append(endpoint.weight()).append(')'); + } + + private EndpointToStringUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/WeightedRandomDistributionSelector.java b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/WeightedRandomDistributionSelector.java index 0c0d40ed807..a36f8a00f14 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/WeightedRandomDistributionSelector.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/WeightedRandomDistributionSelector.java @@ -22,6 +22,7 @@ import java.util.concurrent.locks.ReentrantLock; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.concurrent.GuardedBy; @@ -107,6 +108,17 @@ public T select() { throw new Error("Should never reach here"); } + @SuppressWarnings("GuardedBy") + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("allEntries", allEntries) + .add("currentEntries", currentEntries) + .add("total", total) + .add("remaining", remaining) + .toString(); + } + public abstract static class AbstractEntry { private int counter; diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/DefaultHttpHealthChecker.java b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/DefaultHttpHealthChecker.java new file mode 100644 index 00000000000..8cb07039e34 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/DefaultHttpHealthChecker.java @@ -0,0 +1,353 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.client.endpoint.healthcheck; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.ClientRequestContextCaptor; +import com.linecorp.armeria.client.Clients; +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.ResponseTimeoutException; +import com.linecorp.armeria.client.SimpleDecoratingHttpClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckedEndpointGroup; +import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckerContext; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpObject; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.HttpStatusClass; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestHeadersBuilder; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.stream.SubscriptionOption; +import com.linecorp.armeria.common.util.AsyncCloseableSupport; +import com.linecorp.armeria.common.util.TimeoutMode; +import com.linecorp.armeria.internal.common.util.ReentrantShortLock; +import com.linecorp.armeria.unsafe.PooledObjects; + +import io.netty.util.AsciiString; +import io.netty.util.concurrent.ScheduledFuture; + +public final class DefaultHttpHealthChecker implements HttpHealthChecker { + + private static final Logger logger = LoggerFactory.getLogger(DefaultHttpHealthChecker.class); + + private static final AsciiString ARMERIA_LPHC = HttpHeaderNames.of("armeria-lphc"); + + private final ReentrantLock lock = new ReentrantShortLock(); + private final HealthCheckerContext ctx; + private final WebClient webClient; + private final String authority; + private final String path; + private final boolean useGet; + private boolean wasHealthy; + private int maxLongPollingSeconds; + private int pingIntervalSeconds; + @Nullable + private HttpResponse lastResponse; + private final AsyncCloseableSupport closeable = AsyncCloseableSupport.of(this::closeAsync); + + public DefaultHttpHealthChecker(HealthCheckerContext ctx, Endpoint endpoint, String path, boolean useGet, + SessionProtocol protocol, @Nullable String host) { + this.ctx = ctx; + webClient = WebClient.builder(protocol, endpoint) + .options(ctx.clientOptions()) + .decorator(ResponseTimeoutUpdater::new) + .build(); + authority = host != null ? host : endpoint.authority(); + this.path = path; + this.useGet = useGet; + } + + public void start() { + check(); + } + + private void check() { + lock(); + try { + if (closeable.isClosing()) { + return; + } + + final RequestHeaders headers; + final RequestHeadersBuilder builder = + RequestHeaders.builder(useGet ? HttpMethod.GET : HttpMethod.HEAD, path) + .authority(authority); + if (maxLongPollingSeconds > 0) { + headers = builder.add(HttpHeaderNames.IF_NONE_MATCH, + wasHealthy ? "\"healthy\"" : "\"unhealthy\"") + .add(HttpHeaderNames.PREFER, "wait=" + maxLongPollingSeconds) + .build(); + } else { + headers = builder.build(); + } + + try (ClientRequestContextCaptor reqCtxCaptor = Clients.newContextCaptor()) { + lastResponse = webClient.execute(headers); + final ClientRequestContext reqCtx = reqCtxCaptor.get(); + lastResponse.subscribe(new HealthCheckResponseSubscriber(reqCtx, lastResponse), + reqCtx.eventLoop().withoutContext(), + SubscriptionOption.WITH_POOLED_OBJECTS); + } + } finally { + unlock(); + } + } + + @Override + public CompletableFuture closeAsync() { + return closeable.closeAsync(); + } + + private synchronized void closeAsync(CompletableFuture future) { + lock(); + try { + if (lastResponse == null) { + // Called even before the first request is sent. + future.complete(null); + } else { + lastResponse.abort(); + lastResponse.whenComplete().handle((unused1, unused2) -> future.complete(null)); + } + } finally { + unlock(); + } + } + + @Override + public void close() { + closeable.close(); + } + + private final class ResponseTimeoutUpdater extends SimpleDecoratingHttpClient { + ResponseTimeoutUpdater(HttpClient delegate) { + super(delegate); + } + + @Override + public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception { + if (maxLongPollingSeconds > 0) { + ctx.setResponseTimeoutMillis(TimeoutMode.EXTEND, + TimeUnit.SECONDS.toMillis(maxLongPollingSeconds)); + } + return unwrap().execute(ctx, req); + } + } + + private class HealthCheckResponseSubscriber implements Subscriber { + + private final ClientRequestContext reqCtx; + private final HttpResponse res; + @Nullable + private Subscription subscription; + @Nullable + private ResponseHeaders responseHeaders; + private boolean isHealthy; + private boolean receivedExpectedResponse; + private boolean updatedHealth; + + @Nullable + private ScheduledFuture pingCheckFuture; + private long lastPingTimeNanos; + + HealthCheckResponseSubscriber(ClientRequestContext reqCtx, HttpResponse res) { + this.reqCtx = reqCtx; + this.res = res; + } + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + maybeSchedulePingCheck(); + } + + @Override + public void onNext(HttpObject obj) { + assert subscription != null; + + if (closeable.isClosing()) { + subscription.cancel(); + return; + } + + try { + if (!(obj instanceof ResponseHeaders)) { + PooledObjects.close(obj); + return; + } + + final ResponseHeaders headers = (ResponseHeaders) obj; + responseHeaders = headers; + updateLongPollingSettings(headers); + + final HttpStatus status = headers.status(); + final HttpStatusClass statusClass = status.codeClass(); + switch (statusClass) { + case INFORMATIONAL: + maybeSchedulePingCheck(); + break; + case SERVER_ERROR: + receivedExpectedResponse = true; + break; + case SUCCESS: + isHealthy = true; + receivedExpectedResponse = true; + break; + default: + if (status == HttpStatus.NOT_MODIFIED) { + isHealthy = wasHealthy; + receivedExpectedResponse = true; + } else { + // Do not use long polling on an unexpected status for safety. + maxLongPollingSeconds = 0; + + if (statusClass == HttpStatusClass.CLIENT_ERROR) { + logger.warn("{} Unexpected 4xx health check response: {} A 4xx response " + + "generally indicates a misconfiguration of the client. " + + "Did you happen to forget to configure the {}'s client options?", + reqCtx, headers, HealthCheckedEndpointGroup.class.getSimpleName()); + } else { + logger.warn("{} Unexpected health check response: {}", reqCtx, headers); + } + } + } + } finally { + subscription.request(1); + } + } + + @Override + public void onError(Throwable t) { + updateHealth(t); + } + + @Override + public void onComplete() { + updateHealth(null); + } + + private void updateLongPollingSettings(ResponseHeaders headers) { + final String longPollingSettings = headers.get(ARMERIA_LPHC); + if (longPollingSettings == null) { + maxLongPollingSeconds = 0; + pingIntervalSeconds = 0; + return; + } + + final int commaPos = longPollingSettings.indexOf(','); + int maxLongPollingSeconds = 0; + int pingIntervalSeconds = 0; + try { + maxLongPollingSeconds = Integer.max( + 0, Integer.parseInt(longPollingSettings.substring(0, commaPos).trim())); + pingIntervalSeconds = Integer.max( + 0, Integer.parseInt(longPollingSettings.substring(commaPos + 1).trim())); + } catch (Exception e) { + // Ignore malformed settings. + } + + DefaultHttpHealthChecker.this.maxLongPollingSeconds = maxLongPollingSeconds; + if (maxLongPollingSeconds > 0 && pingIntervalSeconds < maxLongPollingSeconds) { + DefaultHttpHealthChecker.this.pingIntervalSeconds = pingIntervalSeconds; + } else { + DefaultHttpHealthChecker.this.pingIntervalSeconds = 0; + } + } + + // TODO(trustin): Remove once https://github.com/line/armeria/issues/1063 is fixed. + private void maybeSchedulePingCheck() { + lastPingTimeNanos = System.nanoTime(); + + if (pingCheckFuture != null) { + return; + } + + final int pingIntervalSeconds = DefaultHttpHealthChecker.this.pingIntervalSeconds; + if (pingIntervalSeconds <= 0) { + return; + } + + final long pingTimeoutNanos = TimeUnit.SECONDS.toNanos(pingIntervalSeconds) * 2; + pingCheckFuture = reqCtx.eventLoop().withoutContext().scheduleWithFixedDelay(() -> { + if (System.nanoTime() - lastPingTimeNanos >= pingTimeoutNanos) { + // Did not receive a ping on time. + final ResponseTimeoutException cause = ResponseTimeoutException.get(); + res.abort(cause); + isHealthy = false; + receivedExpectedResponse = false; + updateHealth(cause); + } + }, 1, 1, TimeUnit.SECONDS); + } + + private void updateHealth(@Nullable Throwable cause) { + if (pingCheckFuture != null) { + pingCheckFuture.cancel(false); + } + + if (closeable.isClosing() || updatedHealth) { + return; + } + + updatedHealth = true; + + ctx.updateHealth(isHealthy ? 1 : 0, reqCtx, responseHeaders, cause); + wasHealthy = isHealthy; + + final ScheduledExecutorService executor = ctx.executor(); + try { + // Send a long polling check immediately if: + // - Server has long polling enabled. + // - Server responded with 2xx or 5xx. + if (maxLongPollingSeconds > 0 && receivedExpectedResponse) { + executor.execute(DefaultHttpHealthChecker.this::check); + } else { + executor.schedule(DefaultHttpHealthChecker.this::check, + ctx.nextDelayMillis(), TimeUnit.MILLISECONDS); + } + } catch (RejectedExecutionException ignored) { + // Can happen if the Endpoint being checked has been disappeared from + // the delegate EndpointGroup. See HealthCheckedEndpointGroupTest.disappearedEndpoint(). + } + } + } + + private void lock() { + lock.lock(); + } + + private void unlock() { + lock.unlock(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java index 6c9f7a4c220..831a09810fd 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/healthcheck/HttpHealthChecker.java @@ -13,342 +13,10 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.internal.client.endpoint.healthcheck; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +package com.linecorp.armeria.internal.client.endpoint.healthcheck; -import com.linecorp.armeria.client.ClientRequestContext; -import com.linecorp.armeria.client.ClientRequestContextCaptor; -import com.linecorp.armeria.client.Clients; -import com.linecorp.armeria.client.Endpoint; -import com.linecorp.armeria.client.HttpClient; -import com.linecorp.armeria.client.ResponseTimeoutException; -import com.linecorp.armeria.client.SimpleDecoratingHttpClient; -import com.linecorp.armeria.client.WebClient; -import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckedEndpointGroup; -import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckerContext; -import com.linecorp.armeria.common.HttpHeaderNames; -import com.linecorp.armeria.common.HttpMethod; -import com.linecorp.armeria.common.HttpObject; -import com.linecorp.armeria.common.HttpRequest; -import com.linecorp.armeria.common.HttpResponse; -import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.armeria.common.HttpStatusClass; -import com.linecorp.armeria.common.RequestHeaders; -import com.linecorp.armeria.common.RequestHeadersBuilder; -import com.linecorp.armeria.common.ResponseHeaders; -import com.linecorp.armeria.common.SessionProtocol; -import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.stream.SubscriptionOption; import com.linecorp.armeria.common.util.AsyncCloseable; -import com.linecorp.armeria.common.util.AsyncCloseableSupport; -import com.linecorp.armeria.common.util.TimeoutMode; -import com.linecorp.armeria.internal.common.util.ReentrantShortLock; -import com.linecorp.armeria.unsafe.PooledObjects; - -import io.netty.util.AsciiString; -import io.netty.util.concurrent.ScheduledFuture; - -public final class HttpHealthChecker implements AsyncCloseable { - - private static final Logger logger = LoggerFactory.getLogger(HttpHealthChecker.class); - - private static final AsciiString ARMERIA_LPHC = HttpHeaderNames.of("armeria-lphc"); - - private final ReentrantLock lock = new ReentrantShortLock(); - private final HealthCheckerContext ctx; - private final WebClient webClient; - private final String authority; - private final String path; - private final boolean useGet; - private boolean wasHealthy; - private int maxLongPollingSeconds; - private int pingIntervalSeconds; - @Nullable - private HttpResponse lastResponse; - private final AsyncCloseableSupport closeable = AsyncCloseableSupport.of(this::closeAsync); - - public HttpHealthChecker(HealthCheckerContext ctx, Endpoint endpoint, String path, boolean useGet, - SessionProtocol protocol, @Nullable String host) { - this.ctx = ctx; - webClient = WebClient.builder(protocol, endpoint) - .options(ctx.clientOptions()) - .decorator(ResponseTimeoutUpdater::new) - .build(); - authority = host != null ? host : endpoint.authority(); - this.path = path; - this.useGet = useGet; - } - - public void start() { - check(); - } - - private void check() { - lock(); - try { - if (closeable.isClosing()) { - return; - } - - final RequestHeaders headers; - final RequestHeadersBuilder builder = - RequestHeaders.builder(useGet ? HttpMethod.GET : HttpMethod.HEAD, path) - .authority(authority); - if (maxLongPollingSeconds > 0) { - headers = builder.add(HttpHeaderNames.IF_NONE_MATCH, - wasHealthy ? "\"healthy\"" : "\"unhealthy\"") - .add(HttpHeaderNames.PREFER, "wait=" + maxLongPollingSeconds) - .build(); - } else { - headers = builder.build(); - } - - try (ClientRequestContextCaptor reqCtxCaptor = Clients.newContextCaptor()) { - lastResponse = webClient.execute(headers); - final ClientRequestContext reqCtx = reqCtxCaptor.get(); - lastResponse.subscribe(new HealthCheckResponseSubscriber(reqCtx, lastResponse), - reqCtx.eventLoop().withoutContext(), - SubscriptionOption.WITH_POOLED_OBJECTS); - } - } finally { - unlock(); - } - } - - @Override - public CompletableFuture closeAsync() { - return closeable.closeAsync(); - } - - private synchronized void closeAsync(CompletableFuture future) { - lock(); - try { - if (lastResponse == null) { - // Called even before the first request is sent. - future.complete(null); - } else { - lastResponse.abort(); - lastResponse.whenComplete().handle((unused1, unused2) -> future.complete(null)); - } - } finally { - unlock(); - } - } - - @Override - public void close() { - closeable.close(); - } - - private final class ResponseTimeoutUpdater extends SimpleDecoratingHttpClient { - ResponseTimeoutUpdater(HttpClient delegate) { - super(delegate); - } - - @Override - public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception { - if (maxLongPollingSeconds > 0) { - ctx.setResponseTimeoutMillis(TimeoutMode.EXTEND, - TimeUnit.SECONDS.toMillis(maxLongPollingSeconds)); - } - return unwrap().execute(ctx, req); - } - } - - private class HealthCheckResponseSubscriber implements Subscriber { - - private final ClientRequestContext reqCtx; - private final HttpResponse res; - @Nullable - private Subscription subscription; - @Nullable - private ResponseHeaders responseHeaders; - private boolean isHealthy; - private boolean receivedExpectedResponse; - private boolean updatedHealth; - - @Nullable - private ScheduledFuture pingCheckFuture; - private long lastPingTimeNanos; - - HealthCheckResponseSubscriber(ClientRequestContext reqCtx, HttpResponse res) { - this.reqCtx = reqCtx; - this.res = res; - } - - @Override - public void onSubscribe(Subscription subscription) { - this.subscription = subscription; - subscription.request(1); - maybeSchedulePingCheck(); - } - - @Override - public void onNext(HttpObject obj) { - assert subscription != null; - - if (closeable.isClosing()) { - subscription.cancel(); - return; - } - - try { - if (!(obj instanceof ResponseHeaders)) { - PooledObjects.close(obj); - return; - } - - final ResponseHeaders headers = (ResponseHeaders) obj; - responseHeaders = headers; - updateLongPollingSettings(headers); - - final HttpStatus status = headers.status(); - final HttpStatusClass statusClass = status.codeClass(); - switch (statusClass) { - case INFORMATIONAL: - maybeSchedulePingCheck(); - break; - case SERVER_ERROR: - receivedExpectedResponse = true; - break; - case SUCCESS: - isHealthy = true; - receivedExpectedResponse = true; - break; - default: - if (status == HttpStatus.NOT_MODIFIED) { - isHealthy = wasHealthy; - receivedExpectedResponse = true; - } else { - // Do not use long polling on an unexpected status for safety. - maxLongPollingSeconds = 0; - - if (statusClass == HttpStatusClass.CLIENT_ERROR) { - logger.warn("{} Unexpected 4xx health check response: {} A 4xx response " + - "generally indicates a misconfiguration of the client. " + - "Did you happen to forget to configure the {}'s client options?", - reqCtx, headers, HealthCheckedEndpointGroup.class.getSimpleName()); - } else { - logger.warn("{} Unexpected health check response: {}", reqCtx, headers); - } - } - } - } finally { - subscription.request(1); - } - } - - @Override - public void onError(Throwable t) { - updateHealth(t); - } - - @Override - public void onComplete() { - updateHealth(null); - } - - private void updateLongPollingSettings(ResponseHeaders headers) { - final String longPollingSettings = headers.get(ARMERIA_LPHC); - if (longPollingSettings == null) { - maxLongPollingSeconds = 0; - pingIntervalSeconds = 0; - return; - } - - final int commaPos = longPollingSettings.indexOf(','); - int maxLongPollingSeconds = 0; - int pingIntervalSeconds = 0; - try { - maxLongPollingSeconds = Integer.max( - 0, Integer.parseInt(longPollingSettings.substring(0, commaPos).trim())); - pingIntervalSeconds = Integer.max( - 0, Integer.parseInt(longPollingSettings.substring(commaPos + 1).trim())); - } catch (Exception e) { - // Ignore malformed settings. - } - - HttpHealthChecker.this.maxLongPollingSeconds = maxLongPollingSeconds; - if (maxLongPollingSeconds > 0 && pingIntervalSeconds < maxLongPollingSeconds) { - HttpHealthChecker.this.pingIntervalSeconds = pingIntervalSeconds; - } else { - HttpHealthChecker.this.pingIntervalSeconds = 0; - } - } - - // TODO(trustin): Remove once https://github.com/line/armeria/issues/1063 is fixed. - private void maybeSchedulePingCheck() { - lastPingTimeNanos = System.nanoTime(); - - if (pingCheckFuture != null) { - return; - } - - final int pingIntervalSeconds = HttpHealthChecker.this.pingIntervalSeconds; - if (pingIntervalSeconds <= 0) { - return; - } - - final long pingTimeoutNanos = TimeUnit.SECONDS.toNanos(pingIntervalSeconds) * 2; - pingCheckFuture = reqCtx.eventLoop().withoutContext().scheduleWithFixedDelay(() -> { - if (System.nanoTime() - lastPingTimeNanos >= pingTimeoutNanos) { - // Did not receive a ping on time. - final ResponseTimeoutException cause = ResponseTimeoutException.get(); - res.abort(cause); - isHealthy = false; - receivedExpectedResponse = false; - updateHealth(cause); - } - }, 1, 1, TimeUnit.SECONDS); - } - - private void updateHealth(@Nullable Throwable cause) { - if (pingCheckFuture != null) { - pingCheckFuture.cancel(false); - } - - if (closeable.isClosing() || updatedHealth) { - return; - } - - updatedHealth = true; - - ctx.updateHealth(isHealthy ? 1 : 0, reqCtx, responseHeaders, cause); - wasHealthy = isHealthy; - - final ScheduledExecutorService executor = ctx.executor(); - try { - // Send a long polling check immediately if: - // - Server has long polling enabled. - // - Server responded with 2xx or 5xx. - if (maxLongPollingSeconds > 0 && receivedExpectedResponse) { - executor.execute(HttpHealthChecker.this::check); - } else { - executor.schedule(HttpHealthChecker.this::check, - ctx.nextDelayMillis(), TimeUnit.MILLISECONDS); - } - } catch (RejectedExecutionException ignored) { - // Can happen if the Endpoint being checked has been disappeared from - // the delegate EndpointGroup. See HealthCheckedEndpointGroupTest.disappearedEndpoint(). - } - } - } - - private void lock() { - lock.lock(); - } - private void unlock() { - lock.unlock(); - } +public interface HttpHealthChecker extends AsyncCloseable { } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java b/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java index d5006ce9157..75cb29a3da1 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/DefaultRequestTarget.java @@ -163,16 +163,8 @@ boolean mustPreserveEncoding(int cp) { private static final Bytes EMPTY_BYTES = new Bytes(0); private static final Bytes SLASH_BYTES = new Bytes(new byte[] { '/' }); - private static final RequestTarget INSTANCE_ASTERISK = createWithoutValidation( - RequestTargetForm.ASTERISK, - null, - null, - null, - -1, - "*", - "*", - null, - null); + private static final RequestTarget CLIENT_INSTANCE_ASTERISK = createAsterisk(false); + private static final RequestTarget SERVER_INSTANCE_ASTERISK = createAsterisk(true); /** * The main implementation of {@link RequestTarget#forServer(String)}. @@ -233,9 +225,9 @@ public static RequestTarget forClient(String reqTarget, @Nullable String prefix) public static RequestTarget createWithoutValidation( RequestTargetForm form, @Nullable String scheme, @Nullable String authority, @Nullable String host, int port, String path, String pathWithMatrixVariables, - @Nullable String query, @Nullable String fragment) { + @Nullable String rawPath, @Nullable String query, @Nullable String fragment) { return new DefaultRequestTarget( - form, scheme, authority, host, port, path, pathWithMatrixVariables, query, fragment); + form, scheme, authority, host, port, path, pathWithMatrixVariables, rawPath, query, fragment); } private final RequestTargetForm form; @@ -249,6 +241,8 @@ public static RequestTarget createWithoutValidation( private final String path; private final String maybePathWithMatrixVariables; @Nullable + private final String rawPath; + @Nullable private final String query; @Nullable private final String fragment; @@ -256,7 +250,7 @@ public static RequestTarget createWithoutValidation( private DefaultRequestTarget(RequestTargetForm form, @Nullable String scheme, @Nullable String authority, @Nullable String host, int port, - String path, String maybePathWithMatrixVariables, + String path, String maybePathWithMatrixVariables, @Nullable String rawPath, @Nullable String query, @Nullable String fragment) { assert (scheme != null && authority != null && host != null) || @@ -270,6 +264,7 @@ private DefaultRequestTarget(RequestTargetForm form, @Nullable String scheme, this.port = port; this.path = path; this.maybePathWithMatrixVariables = maybePathWithMatrixVariables; + this.rawPath = rawPath; this.query = query; this.fragment = fragment; } @@ -312,6 +307,12 @@ public String maybePathWithMatrixVariables() { return maybePathWithMatrixVariables; } + @Override + @Nullable + public String rawPath() { + return rawPath; + } + @Nullable @Override public String query() { @@ -380,6 +381,21 @@ public String toString() { } } + private static RequestTarget createAsterisk(boolean server) { + final String rawPath = server ? "*" : null; + return createWithoutValidation( + RequestTargetForm.ASTERISK, + null, + null, + null, + -1, + "*", + "*", + rawPath, + null, + null); + } + @Nullable private static RequestTarget slowForServer(String reqTarget, boolean allowSemicolonInPathComponent, boolean allowDoubleDotsInQueryString) { @@ -411,7 +427,7 @@ private static RequestTarget slowForServer(String reqTarget, boolean allowSemico // Reject a relative path and accept an asterisk (e.g. OPTIONS * HTTP/1.1). if (isRelativePath(path)) { if (query == null && path.length == 1 && path.data[0] == '*') { - return INSTANCE_ASTERISK; + return SERVER_INSTANCE_ASTERISK; } else { // Do not accept a relative path. return null; @@ -443,6 +459,7 @@ private static RequestTarget slowForServer(String reqTarget, boolean allowSemico -1, matrixVariablesRemovedPath, encodedPath, + reqTarget, encodeQueryToPercents(query), null); } @@ -622,7 +639,7 @@ private static RequestTarget slowForClient(String reqTarget, // Accept an asterisk (e.g. OPTIONS * HTTP/1.1). if (query == null && path.length == 1 && path.data[0] == '*') { - return INSTANCE_ASTERISK; + return CLIENT_INSTANCE_ASTERISK; } final String encodedPath; @@ -645,7 +662,9 @@ private static RequestTarget slowForClient(String reqTarget, null, -1, encodedPath, - encodedPath, encodedQuery, + encodedPath, + null, + encodedQuery, encodedFragment); } } @@ -692,6 +711,7 @@ private static DefaultRequestTarget newAbsoluteTarget( port, encodedPath, encodedPath, + null, encodedQuery, encodedFragment); } diff --git a/core/src/main/java/com/linecorp/armeria/client/IgnoreHostsTrustManager.java b/core/src/main/java/com/linecorp/armeria/internal/common/IgnoreHostsTrustManager.java similarity index 70% rename from core/src/main/java/com/linecorp/armeria/client/IgnoreHostsTrustManager.java rename to core/src/main/java/com/linecorp/armeria/internal/common/IgnoreHostsTrustManager.java index 276ac650db7..bb3943a409d 100644 --- a/core/src/main/java/com/linecorp/armeria/client/IgnoreHostsTrustManager.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/IgnoreHostsTrustManager.java @@ -1,35 +1,20 @@ /* - * Copyright 2020 LINE Corporation + * Copyright 2024 LINE Corporation * - * LINE Corporation licenses this file to you under the Apache License, - * version 2.0 (the "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at: + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: * - * https://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ -/* - * Copyright (C) 2020 Square, Inc. - * - * 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 - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. */ -package com.linecorp.armeria.client; +package com.linecorp.armeria.internal.common; import static java.util.Objects.requireNonNull; @@ -51,7 +36,7 @@ /** * An implementation of {@link X509ExtendedTrustManager} that skips verification on the list of allowed hosts. */ -final class IgnoreHostsTrustManager extends X509ExtendedTrustManager { +public final class IgnoreHostsTrustManager extends X509ExtendedTrustManager { // Forked from okhttp-4.9.0 // https://github.com/square/okhttp/blob/1364ea44ae1f1c4b5a1cc32e4e7b51d23cb78517/okhttp-tls/src/main/kotlin/okhttp3/tls/internal/InsecureExtendedTrustManager.kt @@ -59,7 +44,7 @@ final class IgnoreHostsTrustManager extends X509ExtendedTrustManager { /** * Returns new {@link IgnoreHostsTrustManager} instance. */ - static IgnoreHostsTrustManager of(Set insecureHosts) { + public static IgnoreHostsTrustManager of(Set insecureHosts) { X509ExtendedTrustManager delegate = null; try { final TrustManagerFactory trustManagerFactory = TrustManagerFactory @@ -82,7 +67,7 @@ static IgnoreHostsTrustManager of(Set insecureHosts) { private final X509ExtendedTrustManager delegate; private final Set insecureHosts; - IgnoreHostsTrustManager(X509ExtendedTrustManager delegate, Set insecureHosts) { + public IgnoreHostsTrustManager(X509ExtendedTrustManager delegate, Set insecureHosts) { this.delegate = delegate; this.insecureHosts = insecureHosts; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java b/core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java new file mode 100644 index 00000000000..e68ddbfaf07 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java @@ -0,0 +1,337 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.internal.common; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.internal.common.util.SslContextUtil.createSslContext; + +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientTlsConfig; +import com.linecorp.armeria.common.AbstractTlsConfig; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.metric.CloseableMeterBinder; +import com.linecorp.armeria.common.metric.MeterIdPrefix; +import com.linecorp.armeria.common.metric.MoreMeterBinders; +import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.util.ReentrantShortLock; +import com.linecorp.armeria.server.ServerTlsConfig; + +import io.micrometer.core.instrument.MeterRegistry; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.netty.util.ReferenceCountUtil; + +public final class SslContextFactory { + + private static final MeterIdPrefix SERVER_METER_ID_PREFIX = + new MeterIdPrefix("armeria.server", "hostname.pattern", "UNKNOWN"); + private static final MeterIdPrefix CLIENT_METER_ID_PREFIX = + new MeterIdPrefix("armeria.client"); + + private final Map cache = new HashMap<>(); + private final Map reverseCache = new HashMap<>(); + + private final TlsProvider tlsProvider; + private final TlsEngineType engineType; + private final MeterRegistry meterRegistry; + @Nullable + private final AbstractTlsConfig tlsConfig; + @Nullable + private final MeterIdPrefix meterIdPrefix; + private final boolean allowsUnsafeCiphers; + + private final ReentrantShortLock lock = new ReentrantShortLock(); + + public SslContextFactory(TlsProvider tlsProvider, TlsEngineType engineType, + @Nullable AbstractTlsConfig tlsConfig, MeterRegistry meterRegistry) { + // TODO(ikhoon): Support OPENSSL_REFCNT engine type. + assert engineType.sslProvider() != SslProvider.OPENSSL_REFCNT; + + this.tlsProvider = tlsProvider; + this.engineType = engineType; + this.meterRegistry = meterRegistry; + if (tlsConfig != null) { + this.tlsConfig = tlsConfig; + meterIdPrefix = tlsConfig.meterIdPrefix(); + allowsUnsafeCiphers = tlsConfig.allowsUnsafeCiphers(); + } else { + this.tlsConfig = null; + meterIdPrefix = null; + allowsUnsafeCiphers = false; + } + } + + /** + * Returns an {@link SslContext} for the specified {@link SslContextMode} and {@link TlsKeyPair}. + * Note that the returned {@link SslContext} should be released via + * {@link ReferenceCountUtil#release(Object)} when it is no longer used. + */ + public SslContext getOrCreate(SslContextMode mode, String hostname) { + lock.lock(); + try { + final TlsKeyPair tlsKeyPair = findTlsKeyPair(mode, hostname); + final List trustedCertificates = findTrustedCertificates(hostname); + final CacheKey cacheKey = new CacheKey(mode, tlsKeyPair, trustedCertificates); + final SslContextHolder contextHolder = cache.computeIfAbsent(cacheKey, this::create); + contextHolder.retain(); + reverseCache.putIfAbsent(contextHolder.sslContext(), cacheKey); + return contextHolder.sslContext(); + } finally { + lock.unlock(); + } + } + + public void release(SslContext sslContext) { + lock.lock(); + try { + final CacheKey cacheKey = reverseCache.get(sslContext); + final SslContextHolder contextHolder = cache.get(cacheKey); + assert contextHolder != null : "sslContext not found in the cache: " + sslContext; + + if (contextHolder.release()) { + final SslContextHolder removed = cache.remove(cacheKey); + assert removed == contextHolder; + reverseCache.remove(sslContext); + contextHolder.destroy(); + } + } finally { + lock.unlock(); + } + } + + @Nullable + private TlsKeyPair findTlsKeyPair(SslContextMode mode, String hostname) { + TlsKeyPair tlsKeyPair = tlsProvider.keyPair(hostname); + if (tlsKeyPair == null) { + // Try to find the default TLS key pair. + tlsKeyPair = tlsProvider.keyPair("*"); + } + if (mode == SslContextMode.SERVER && tlsKeyPair == null) { + // A TlsKeyPair must exist for a server. + throw new IllegalStateException("No TLS key pair found for " + hostname); + } + return tlsKeyPair; + } + + private List findTrustedCertificates(String hostname) { + List certs = tlsProvider.trustedCertificates(hostname); + if (certs == null) { + certs = tlsProvider.trustedCertificates("*"); + } + return firstNonNull(certs, ImmutableList.of()); + } + + private SslContextHolder create(CacheKey key) { + final MeterIdPrefix meterIdPrefix = meterIdPrefix(key.mode); + final SslContext sslContext = newSslContext(key); + final ImmutableList.Builder builder = ImmutableList.builder(); + if (key.tlsKeyPair != null) { + builder.addAll(key.tlsKeyPair.certificateChain()); + } + if (!key.trustedCertificates.isEmpty()) { + builder.addAll(key.trustedCertificates); + } + final List certs = builder.build(); + CloseableMeterBinder meterBinder = null; + if (!certs.isEmpty()) { + meterBinder = MoreMeterBinders.certificateMetrics(certs, meterIdPrefix); + meterBinder.bindTo(meterRegistry); + } + return new SslContextHolder(sslContext, meterBinder); + } + + private SslContext newSslContext(CacheKey key) { + final SslContextMode mode = key.mode(); + final TlsKeyPair tlsKeyPair = key.tlsKeyPair(); + final List trustedCerts = key.trustedCertificates(); + if (mode == SslContextMode.SERVER) { + assert tlsKeyPair != null; + return createSslContext( + () -> { + final SslContextBuilder contextBuilder = SslContextBuilder.forServer( + tlsKeyPair.privateKey(), + tlsKeyPair.certificateChain()); + if (!trustedCerts.isEmpty()) { + contextBuilder.trustManager(trustedCerts); + } + applyTlsConfig(contextBuilder); + return contextBuilder; + }, + false, engineType, allowsUnsafeCiphers, + null, null); + } else { + final boolean forceHttp1 = mode == SslContextMode.CLIENT_HTTP1_ONLY; + return createSslContext( + () -> { + final SslContextBuilder contextBuilder = SslContextBuilder.forClient(); + if (tlsKeyPair != null) { + contextBuilder.keyManager(tlsKeyPair.privateKey(), tlsKeyPair.certificateChain()); + } + if (!trustedCerts.isEmpty()) { + contextBuilder.trustManager(trustedCerts); + } + applyTlsConfig(contextBuilder); + return contextBuilder; + }, + forceHttp1, engineType, allowsUnsafeCiphers, null, null); + } + } + + private void applyTlsConfig(SslContextBuilder contextBuilder) { + if (tlsConfig == null) { + return; + } + + if (tlsConfig instanceof ServerTlsConfig) { + final ServerTlsConfig serverTlsConfig = (ServerTlsConfig) tlsConfig; + contextBuilder.clientAuth(serverTlsConfig.clientAuth()); + } else { + final ClientTlsConfig clientTlsConfig = (ClientTlsConfig) tlsConfig; + if (clientTlsConfig.tlsNoVerifySet()) { + contextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE); + } else if (!clientTlsConfig.insecureHosts().isEmpty()) { + contextBuilder.trustManager(IgnoreHostsTrustManager.of(clientTlsConfig.insecureHosts())); + } + } + tlsConfig.tlsCustomizer().accept(contextBuilder); + } + + private MeterIdPrefix meterIdPrefix(SslContextMode mode) { + MeterIdPrefix meterIdPrefix = this.meterIdPrefix; + if (meterIdPrefix == null) { + if (mode == SslContextMode.SERVER) { + meterIdPrefix = SERVER_METER_ID_PREFIX; + } else { + meterIdPrefix = CLIENT_METER_ID_PREFIX; + } + } + return meterIdPrefix; + } + + @VisibleForTesting + public int numCachedContexts() { + return cache.size(); + } + + public enum SslContextMode { + SERVER, + CLIENT_HTTP1_ONLY, + CLIENT + } + + private static final class CacheKey { + private final SslContextMode mode; + @Nullable + private final TlsKeyPair tlsKeyPair; + + private final List trustedCertificates; + + private CacheKey(SslContextMode mode, @Nullable TlsKeyPair tlsKeyPair, + List trustedCertificates) { + this.mode = mode; + this.tlsKeyPair = tlsKeyPair; + this.trustedCertificates = trustedCertificates; + } + + SslContextMode mode() { + return mode; + } + + @Nullable + TlsKeyPair tlsKeyPair() { + return tlsKeyPair; + } + + public List trustedCertificates() { + return trustedCertificates; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CacheKey)) { + return false; + } + final CacheKey that = (CacheKey) o; + return mode == that.mode && + Objects.equals(tlsKeyPair, that.tlsKeyPair) && + trustedCertificates.equals(that.trustedCertificates); + } + + @Override + public int hashCode() { + return Objects.hash(mode, tlsKeyPair, trustedCertificates); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("mode", mode) + .add("tlsKeyPair", tlsKeyPair) + .add("trustedCertificates", trustedCertificates) + .toString(); + } + } + + private static final class SslContextHolder { + private final SslContext sslContext; + @Nullable + private final CloseableMeterBinder meterBinder; + private long refCnt; + + SslContextHolder(SslContext sslContext, @Nullable CloseableMeterBinder meterBinder) { + this.sslContext = sslContext; + this.meterBinder = meterBinder; + } + + SslContext sslContext() { + return sslContext; + } + + void retain() { + refCnt++; + } + + boolean release() { + refCnt--; + assert refCnt >= 0 : "refCount: " + refCnt; + return refCnt == 0; + } + + void destroy() { + if (meterBinder != null) { + meterBinder.close(); + } + } + } +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java new file mode 100644 index 00000000000..0940ce5711c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/common/TlsProviderUtil.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.internal.common; + +import java.net.IDN; +import java.util.Locale; + +public final class TlsProviderUtil { + + // Forked from https://github.com/netty/netty/blob/60430c80e7f8718ecd07ac31e01297b42a176b87/common/src/main/java/io/netty/util/DomainWildcardMappingBuilder.java#L78 + + /** + * IDNA ASCII conversion and case normalization. + */ + public static String normalizeHostname(String hostname) { + if (hostname.isEmpty() || hostname.charAt(0) == '.') { + throw new IllegalArgumentException("Hostname '" + hostname + "' not valid"); + } + if (needsNormalization(hostname)) { + hostname = IDN.toASCII(hostname, IDN.ALLOW_UNASSIGNED); + } + hostname = hostname.toLowerCase(Locale.US); + + if (hostname.charAt(0) == '*') { + if (hostname.length() < 3 || hostname.charAt(1) != '.') { + throw new IllegalArgumentException("Wildcard Hostname '" + hostname + "'not valid"); + } + return hostname.substring(1); + } + return hostname; + } + + private static boolean needsNormalization(String hostname) { + final int length = hostname.length(); + for (int i = 0; i < length; i++) { + final int c = hostname.charAt(i); + if (c > 0x7F) { + return true; + } + } + return false; + } + + private TlsProviderUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java index f66d8002835..b90dce5d441 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java @@ -19,6 +19,8 @@ import java.io.File; import java.io.InputStream; +import java.security.KeyException; +import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; @@ -41,6 +43,7 @@ import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.Exceptions; import io.netty.buffer.ByteBufAllocator; import io.netty.handler.ssl.ApplicationProtocolNegotiator; @@ -91,6 +94,29 @@ public static List toX509Certificates(InputStream in) throws Ce return ImmutableList.copyOf(SslContextProtectedAccessHack.toX509CertificateList(in)); } + public static PrivateKey toPrivateKey(File file, @Nullable String keyPassword) throws KeyException { + requireNonNull(file, "file"); + return MinifiedBouncyCastleProvider.call(() -> { + try { + return SslContextProtectedAccessHack.privateKey(file, keyPassword); + } catch (KeyException e) { + return Exceptions.throwUnsafely(e); + } + }); + } + + public static PrivateKey toPrivateKey(InputStream keyInputStream, @Nullable String keyPassword) + throws KeyException { + requireNonNull(keyInputStream, "keyInputStream"); + return MinifiedBouncyCastleProvider.call(() -> { + try { + return SslContextProtectedAccessHack.privateKey(keyInputStream, keyPassword); + } catch (KeyException e) { + return Exceptions.throwUnsafely(e); + } + }); + } + private static final class SslContextProtectedAccessHack extends SslContext { static X509Certificate[] toX509CertificateList(File file) throws CertificateException { @@ -101,6 +127,29 @@ static X509Certificate[] toX509CertificateList(InputStream in) throws Certificat return SslContext.toX509Certificates(in); } + static PrivateKey privateKey(File file, @Nullable String keyPassword) throws KeyException { + try { + return SslContext.toPrivateKey(file, keyPassword); + } catch (Exception e) { + if (e instanceof KeyException) { + throw (KeyException) e; + } + throw new KeyException("Fail to read a private key file: " + file.getName(), e); + } + } + + static PrivateKey privateKey(InputStream keyInputStream, @Nullable String keyPassword) + throws KeyException { + try { + return SslContext.toPrivateKey(keyInputStream, keyPassword); + } catch (Exception e) { + if (e instanceof KeyException) { + throw (KeyException) e; + } + throw new KeyException("Fail to parse a private key", e); + } + } + @Override public boolean isClient() { throw new UnsupportedOperationException(); diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java index 5826eb9bc25..9e50999dc78 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/KeyStoreUtil.java @@ -33,32 +33,31 @@ import com.google.common.collect.ImmutableList; import com.google.common.io.ByteStreams; +import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; -import io.netty.util.internal.EmptyArrays; - public final class KeyStoreUtil { - public static KeyPair load(File keyStoreFile, - @Nullable String keyStorePassword, - @Nullable String keyPassword, - @Nullable String alias) throws IOException, GeneralSecurityException { + public static TlsKeyPair load(File keyStoreFile, + @Nullable String keyStorePassword, + @Nullable String keyPassword, + @Nullable String alias) throws IOException, GeneralSecurityException { try (InputStream in = new FileInputStream(keyStoreFile)) { return load(in, keyStorePassword, keyPassword, alias, keyStoreFile); } } - public static KeyPair load(InputStream keyStoreStream, - @Nullable String keyStorePassword, - @Nullable String keyPassword, - @Nullable String alias) throws IOException, GeneralSecurityException { + public static TlsKeyPair load(InputStream keyStoreStream, + @Nullable String keyStorePassword, + @Nullable String keyPassword, + @Nullable String alias) throws IOException, GeneralSecurityException { return load(keyStoreStream, keyStorePassword, keyPassword, alias, null); } - private static KeyPair load(InputStream keyStoreStream, - @Nullable String keyStorePassword, - @Nullable String keyPassword, - @Nullable String alias, - @Nullable File keyStoreFile) + private static TlsKeyPair load(InputStream keyStoreStream, + @Nullable String keyStorePassword, + @Nullable String keyPassword, + @Nullable String alias, + @Nullable File keyStoreFile) throws IOException, GeneralSecurityException { try (InputStream in = new BufferedInputStream(keyStoreStream, 8192)) { @@ -117,7 +116,7 @@ private static KeyPair load(InputStream keyStoreStream, assert certificateChain != null; - return new KeyPair(privateKey, certificateChain); + return TlsKeyPair.of(privateKey, certificateChain); } } @@ -165,22 +164,4 @@ private static IllegalArgumentException newException(String message, @Nullable F } private KeyStoreUtil() {} - - public static final class KeyPair { - private final PrivateKey privateKey; - private final List certificateChain; - - private KeyPair(PrivateKey privateKey, Iterable certificateChain) { - this.privateKey = privateKey; - this.certificateChain = ImmutableList.copyOf(certificateChain); - } - - public PrivateKey privateKey() { - return privateKey; - } - - public X509Certificate[] certificateChain() { - return certificateChain.toArray(EmptyArrays.EMPTY_X509_CERTIFICATES); - } - } } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java index 2a67d63f5e4..3e41a171115 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java @@ -98,7 +98,7 @@ public final class SslContextUtil { public static SslContext createSslContext( Supplier builderSupplier, boolean forceHttp1, TlsEngineType tlsEngineType, boolean tlsAllowUnsafeCiphers, - Iterable> userCustomizers, + @Nullable Consumer userCustomizer, @Nullable List keyCertChainCaptor) { return MinifiedBouncyCastleProvider.call(() -> { @@ -127,7 +127,9 @@ public static SslContext createSslContext( builder.protocols(protocols.toArray(EmptyArrays.EMPTY_STRINGS)) .ciphers(DEFAULT_CIPHERS, SupportedCipherSuiteFilter.INSTANCE); - userCustomizers.forEach(customizer -> customizer.accept(builder)); + if (userCustomizer != null) { + userCustomizer.accept(builder); + } // We called user customization logic before setting ALPN to make sure they don't break // compatibility with HTTP/2. diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java b/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java index db0d19b7d58..7aad575adc6 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/CorsHeaderUtil.java @@ -25,16 +25,17 @@ import com.google.common.base.Strings; import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpHeadersBuilder; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.RequestHeaders; -import com.linecorp.armeria.common.ResponseHeaders; -import com.linecorp.armeria.common.ResponseHeadersBuilder; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.cors.CorsConfig; import com.linecorp.armeria.server.cors.CorsPolicy; +import com.linecorp.armeria.server.cors.CorsService; import io.netty.util.AsciiString; +import io.netty.util.AttributeKey; /** * A utility class related to CORS headers. @@ -47,15 +48,8 @@ public final class CorsHeaderUtil { public static final String DELIMITER = ","; private static final Joiner HEADER_JOINER = Joiner.on(DELIMITER); - public static ResponseHeaders addCorsHeaders(ServiceRequestContext ctx, CorsConfig corsConfig, - ResponseHeaders responseHeaders) { - final HttpRequest httpRequest = ctx.request(); - final ResponseHeadersBuilder responseHeadersBuilder = responseHeaders.toBuilder(); - - setCorsResponseHeaders(ctx, httpRequest, responseHeadersBuilder, corsConfig); - - return responseHeadersBuilder.build(); - } + private static final AttributeKey IS_CORS_SET = + AttributeKey.valueOf(CorsService.class, "IS_CORS_SET"); /** * Emit CORS headers if origin was found. @@ -64,7 +58,7 @@ public static ResponseHeaders addCorsHeaders(ServiceRequestContext ctx, CorsConf * @param headers the headers to modify */ public static void setCorsResponseHeaders(ServiceRequestContext ctx, HttpRequest req, - ResponseHeadersBuilder headers, CorsConfig config) { + HttpHeadersBuilder headers, CorsConfig config) { final CorsPolicy policy = setCorsOrigin(ctx, req, headers, config); if (policy != null) { setCorsAllowCredentials(headers, policy); @@ -73,7 +67,7 @@ public static void setCorsResponseHeaders(ServiceRequestContext ctx, HttpRequest } } - public static void setCorsAllowCredentials(ResponseHeadersBuilder headers, CorsPolicy policy) { + public static void setCorsAllowCredentials(HttpHeadersBuilder headers, CorsPolicy policy) { // The string "*" cannot be used for a resource that supports credentials. // https://www.w3.org/TR/cors/#resource-requests if (policy.isCredentialsAllowed() && @@ -82,7 +76,7 @@ public static void setCorsAllowCredentials(ResponseHeadersBuilder headers, CorsP } } - private static void setCorsExposeHeaders(ResponseHeadersBuilder headers, CorsPolicy corsPolicy) { + private static void setCorsExposeHeaders(HttpHeadersBuilder headers, CorsPolicy corsPolicy) { if (corsPolicy.exposedHeaders().isEmpty()) { return; } @@ -90,7 +84,7 @@ private static void setCorsExposeHeaders(ResponseHeadersBuilder headers, CorsPol headers.set(HttpHeaderNames.ACCESS_CONTROL_EXPOSE_HEADERS, joinExposedHeaders(corsPolicy)); } - public static void setCorsAllowHeaders(RequestHeaders requestHeaders, ResponseHeadersBuilder headers, + public static void setCorsAllowHeaders(RequestHeaders requestHeaders, HttpHeadersBuilder headers, CorsPolicy corsPolicy) { if (corsPolicy.shouldAllowAllRequestHeaders()) { final String header = requestHeaders.get(HttpHeaderNames.ACCESS_CONTROL_REQUEST_HEADERS); @@ -118,7 +112,7 @@ public static void setCorsAllowHeaders(RequestHeaders requestHeaders, ResponseHe */ @Nullable public static CorsPolicy setCorsOrigin(ServiceRequestContext ctx, HttpRequest request, - ResponseHeadersBuilder headers, CorsConfig config) { + HttpHeadersBuilder headers, CorsConfig config) { final String origin = request.headers().get(HttpHeaderNames.ORIGIN); if (origin != null) { @@ -149,26 +143,26 @@ public static CorsPolicy setCorsOrigin(ServiceRequestContext ctx, HttpRequest re return null; } - private static void setCorsOrigin(ResponseHeadersBuilder headers, String origin) { + private static void setCorsOrigin(HttpHeadersBuilder headers, String origin) { headers.set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, origin); } - private static void echoCorsRequestOrigin(HttpRequest request, ResponseHeadersBuilder headers) { + private static void echoCorsRequestOrigin(HttpRequest request, HttpHeadersBuilder headers) { final String origin = request.headers().get(HttpHeaderNames.ORIGIN); if (origin != null) { setCorsOrigin(headers, origin); } } - private static void addCorsVaryHeader(ResponseHeadersBuilder headers) { + private static void addCorsVaryHeader(HttpHeadersBuilder headers) { headers.add(HttpHeaderNames.VARY, HttpHeaderNames.ORIGIN.toString()); } - private static void setCorsAnyOrigin(ResponseHeadersBuilder headers) { + private static void setCorsAnyOrigin(HttpHeadersBuilder headers) { setCorsOrigin(headers, ANY_ORIGIN); } - private static void setCorsNullOrigin(ResponseHeadersBuilder headers) { + private static void setCorsNullOrigin(HttpHeadersBuilder headers) { setCorsOrigin(headers, NULL_ORIGIN); } @@ -204,6 +198,19 @@ private static String joinAllowedRequestHeaders(CorsPolicy corsPolicy) { return joinHeaders(corsPolicy.allowedRequestHeaders()); } + public static boolean isForbiddenOrigin(CorsConfig config, ServiceRequestContext ctx, RequestHeaders req) { + return config.isShortCircuit() && + config.getPolicy(req.get(HttpHeaderNames.ORIGIN), ctx.routingContext()) == null; + } + + public static boolean isCorsHeadersSet(ServiceRequestContext ctx) { + return ctx.hasAttr(IS_CORS_SET); + } + + public static void corsHeadersSet(ServiceRequestContext ctx) { + ctx.setAttr(IS_CORS_SET, true); + } + private CorsHeaderUtil() { } } diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java b/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java index 6424d96071c..7da08ba953e 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java @@ -305,6 +305,14 @@ public String decodedMappedPath() { return routingResult.decodedPath(); } + @Override + public String rawPath() { + final String rawPath = requestTarget().rawPath(); + // rawPath should not be null for server-side targets. + assert rawPath != null; + return rawPath; + } + @Override public URI uri() { final HttpRequest request = request(); diff --git a/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseHandler.java b/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseHandler.java index e49e5b8a120..1d2fb516b19 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -36,7 +36,6 @@ import com.linecorp.armeria.common.logging.RequestLogBuilder; import com.linecorp.armeria.common.logging.RequestLogProperty; import com.linecorp.armeria.common.stream.ClosedStreamException; -import com.linecorp.armeria.common.util.SafeCloseable; import com.linecorp.armeria.internal.common.CancellationScheduler.CancellationTask; import com.linecorp.armeria.internal.server.DefaultServiceRequestContext; @@ -221,21 +220,6 @@ final void endLogRequestAndResponse(@Nullable Throwable cause) { } } - /** - * Writes an access log if the {@link TransientServiceOption#WITH_ACCESS_LOGGING} option is enabled for - * the {@link #service()}. - */ - final void maybeWriteAccessLog() { - final ServiceConfig config = reqCtx.config(); - if (config.transientServiceOptions().contains(TransientServiceOption.WITH_ACCESS_LOGGING)) { - reqCtx.log().whenComplete().thenAccept(log -> { - try (SafeCloseable ignored = reqCtx.push()) { - config.accessLogWriter().log(log); - } - }); - } - } - /** * Schedules a request timeout. */ diff --git a/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java b/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java index 97ab6156740..e674102286a 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java +++ b/core/src/main/java/com/linecorp/armeria/server/AbstractHttpResponseSubscriber.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -18,6 +18,7 @@ import static com.google.common.base.MoreObjects.firstNonNull; import static com.linecorp.armeria.internal.common.HttpHeadersUtil.mergeTrailers; +import static com.linecorp.armeria.server.AccessLogWriterUtil.maybeWriteAccessLog; import java.nio.channels.ClosedChannelException; import java.util.concurrent.CompletableFuture; @@ -339,7 +340,7 @@ private void succeed() { cause = requestLog.responseCause(); } endLogRequestAndResponse(cause); - maybeWriteAccessLog(); + maybeWriteAccessLog(reqCtx); } } @@ -348,7 +349,7 @@ void fail(Throwable cause) { if (tryComplete(cause)) { setDone(true); endLogRequestAndResponse(cause); - maybeWriteAccessLog(); + maybeWriteAccessLog(reqCtx); } } diff --git a/core/src/main/java/com/linecorp/armeria/server/AccessLogWriterUtil.java b/core/src/main/java/com/linecorp/armeria/server/AccessLogWriterUtil.java new file mode 100644 index 00000000000..fdc1306644c --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/AccessLogWriterUtil.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.server.logging.AccessLogWriter; + +/** + * Utility class for {@link AccessLogWriter}. + */ +final class AccessLogWriterUtil { + + /** + * Writes an access log if the {@link TransientServiceOption#WITH_ACCESS_LOGGING} option is enabled + * for the {@link ServiceConfig#transientServiceOptions()} and the {@link ServiceConfig#accessLogWriter()} + * is not {@link AccessLogWriter#disabled()} for the given {@link ServiceRequestContext#config()}. + */ + static void maybeWriteAccessLog(ServiceRequestContext reqCtx) { + final ServiceConfig config = reqCtx.config(); + if (shouldWriteAccessLog(config)) { + reqCtx.log().whenComplete().thenAccept(log -> { + try (SafeCloseable ignored = reqCtx.push()) { + config.accessLogWriter().log(log); + } + }); + } + } + + /** + * Returns whether an access log should be written. + * + */ + private static boolean shouldWriteAccessLog(ServiceConfig config) { + return config.accessLogWriter() != AccessLogWriter.disabled() && + config.transientServiceOptions().contains(TransientServiceOption.WITH_ACCESS_LOGGING); + } + + private AccessLogWriterUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/server/AggregatedHttpResponseHandler.java b/core/src/main/java/com/linecorp/armeria/server/AggregatedHttpResponseHandler.java index ec59df678e8..d6e5657d54d 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AggregatedHttpResponseHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/AggregatedHttpResponseHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -17,6 +17,7 @@ package com.linecorp.armeria.server; import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.server.AccessLogWriterUtil.maybeWriteAccessLog; import java.nio.channels.ClosedChannelException; import java.util.concurrent.CompletableFuture; @@ -125,7 +126,7 @@ private void recoverAndWrite(Throwable cause) { void fail(Throwable cause) { if (tryComplete(cause)) { endLogRequestAndResponse(cause); - maybeWriteAccessLog(); + maybeWriteAccessLog(reqCtx); } } @@ -185,7 +186,7 @@ void handleWriteComplete(ChannelFuture future, boolean isSuccess, @Nullable Thro } } endLogRequestAndResponse(cause); - maybeWriteAccessLog(); + maybeWriteAccessLog(reqCtx); } return; } diff --git a/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java b/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java index d8ea993a6f1..7cf222863f0 100644 --- a/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/CorsServerErrorHandler.java @@ -16,15 +16,15 @@ package com.linecorp.armeria.server; -import static com.linecorp.armeria.internal.server.CorsHeaderUtil.addCorsHeaders; +import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.isCorsPreflightRequest; +import static com.linecorp.armeria.internal.server.CorsHeaderUtil.isForbiddenOrigin; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.RequestHeaders; -import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.server.cors.CorsConfig; +import com.linecorp.armeria.internal.server.CorsHeaderUtil; import com.linecorp.armeria.server.cors.CorsService; /** @@ -50,49 +50,46 @@ public AggregatedHttpResponse renderStatus(@Nullable ServiceRequestContext ctx, @Nullable RequestHeaders headers, HttpStatus status, @Nullable String description, @Nullable Throwable cause) { - - if (ctx == null) { - return serverErrorHandler.renderStatus(null, serviceConfig, headers, status, description, cause); - } - - final CorsService corsService = ctx.findService(CorsService.class); - if (corsService == null) { - return serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, description, cause); - } - - final AggregatedHttpResponse res = serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, - description, cause); - - if (res == null) { - return serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, description, cause); - } - - final CorsConfig corsConfig = corsService.config(); - final ResponseHeaders updatedResponseHeaders = addCorsHeaders(ctx, corsConfig, - res.headers()); - - return AggregatedHttpResponse.of(updatedResponseHeaders, res.content()); + return serverErrorHandler.renderStatus(ctx, serviceConfig, headers, status, description, cause); } @Nullable @Override public HttpResponse onServiceException(ServiceRequestContext ctx, Throwable cause) { - if (cause instanceof HttpResponseException) { - final HttpResponse oldRes = serverErrorHandler.onServiceException(ctx, cause); - if (oldRes == null) { - return null; - } - final CorsService corsService = ctx.findService(CorsService.class); - if (corsService == null) { - return oldRes; - } - return oldRes.recover(HttpResponseException.class, ex -> { - return ex.httpResponse() - .mapHeaders(oldHeaders -> addCorsHeaders(ctx, corsService.config(), oldHeaders)); + final CorsService corsService = ctx.findService(CorsService.class); + if (shouldSetCorsHeaders(corsService, ctx)) { + assert corsService != null; + ctx.mutateAdditionalResponseHeaders(builder -> { + CorsHeaderUtil.setCorsResponseHeaders(ctx, ctx.request(), builder, corsService.config()); }); - } else { - return serverErrorHandler.onServiceException(ctx, cause); } + return serverErrorHandler.onServiceException(ctx, cause); + } + + /** + * Sets CORS headers for + * simple CORS requests or main requests. + * Preflight requests is unsupported because we don't know if it is safe to perform the main request. + */ + private static boolean shouldSetCorsHeaders(@Nullable CorsService corsService, ServiceRequestContext ctx) { + if (corsService == null) { + // No CorsService is configured. + return false; + } + if (CorsHeaderUtil.isCorsHeadersSet(ctx)) { + // CORS headers were set by CorsService. + return false; + } + final RequestHeaders headers = ctx.request().headers(); + if (isCorsPreflightRequest(headers)) { + return false; + } + //noinspection RedundantIfStatement + if (isForbiddenOrigin(corsService.config(), ctx, headers)) { + return false; + } + + return true; } @Nullable diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java b/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java index 9bddd7a0946..9212e3484c3 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -13,6 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ + package com.linecorp.armeria.server; import static com.google.common.base.MoreObjects.firstNonNull; @@ -23,6 +24,7 @@ import static com.linecorp.armeria.internal.client.ClosedStreamExceptionUtil.newClosedSessionException; import static com.linecorp.armeria.internal.common.HttpHeadersUtil.CLOSE_STRING; import static com.linecorp.armeria.internal.common.RequestContextUtil.NOOP_CONTEXT_HOOK; +import static com.linecorp.armeria.server.AccessLogWriterUtil.maybeWriteAccessLog; import static io.netty.handler.codec.http2.Http2CodecUtil.DEFAULT_WINDOW_SIZE; import static java.util.Objects.requireNonNull; @@ -670,11 +672,7 @@ private ChannelFuture respond(ServiceRequestContext reqCtx, ResponseHeadersBuild logBuilder.endResponse(firstNonNull(cause, f.cause())); } } - reqCtx.log().whenComplete().thenAccept(log -> { - try (SafeCloseable ignored = reqCtx.push()) { - reqCtx.config().accessLogWriter().log(log); - } - }); + maybeWriteAccessLog(reqCtx); }); return future; } diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java b/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java index 2c23dc5f357..284eff8ca1f 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java @@ -230,13 +230,27 @@ private Timer newKeepAliveTimer(SessionProtocol protocol) { } private void configureHttps(ChannelPipeline p, @Nullable ProxiedAddresses proxiedAddresses) { - final Mapping sslContexts = - requireNonNull(config.sslContextMapping(), "config.sslContextMapping() returned null"); - p.addLast(new SniHandler(sslContexts, Flags.defaultMaxClientHelloLength(), config.idleTimeoutMillis())); + p.addLast(newSniHandler(p)); p.addLast(TrafficLoggingHandler.SERVER); p.addLast(new Http2OrHttpHandler(proxiedAddresses)); } + private SniHandler newSniHandler(ChannelPipeline p) { + final Mapping sslContexts = + requireNonNull(config.sslContextMapping(), "config.sslContextMapping() returned null"); + final SniHandler sniHandler = new SniHandler(sslContexts, Flags.defaultMaxClientHelloLength(), + config.idleTimeoutMillis()); + if (sslContexts instanceof TlsProviderMapping) { + p.channel().closeFuture().addListener(future -> { + final SslContext sslContext = sniHandler.sslContext(); + if (sslContext != null) { + ((TlsProviderMapping) sslContexts).release(sslContext); + } + }); + } + return sniHandler; + } + private Http2ConnectionHandler newHttp2ConnectionHandler(ChannelPipeline pipeline, AsciiString scheme) { final Timer keepAliveTimer = newKeepAliveTimer(scheme == SCHEME_HTTP ? H2C : H2); diff --git a/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java b/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java index af242602af0..df43b8803e2 100644 --- a/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java +++ b/core/src/main/java/com/linecorp/armeria/server/RoutingContext.java @@ -169,6 +169,7 @@ default RoutingContext withPath(String path) { oldReqTarget.port(), pathWithoutMatrixVariables, path, + path, oldReqTarget.query(), oldReqTarget.fragment()); diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java index aacb6b1a7a0..6366bb584f6 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java @@ -82,6 +82,8 @@ import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.TlsSetters; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -235,6 +237,10 @@ public final class ServerBuilder implements TlsSetters, ServiceConfigsBuilder shutdownSupports = new ArrayList<>(); private int http2MaxResetFramesPerWindow = Flags.defaultServerHttp2MaxResetFramesPerMinute(); private int http2MaxResetFramesWindowSeconds = 60; + @Nullable + private TlsProvider tlsProvider; + @Nullable + private ServerTlsConfig tlsConfig; ServerBuilder() { // Set the default host-level properties. @@ -1081,49 +1087,65 @@ public ServerBuilder proxyProtocolMaxTlvSize(int proxyProtocolMaxTlvSize) { return this; } + @Deprecated @Override public ServerBuilder tls(File keyCertChainFile, File keyFile) { return (ServerBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile); } + @Deprecated @Override public ServerBuilder tls( File keyCertChainFile, File keyFile, @Nullable String keyPassword) { - virtualHostTemplate.tls(keyCertChainFile, keyFile, keyPassword); - return this; + return (ServerBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile, keyPassword); } + @Deprecated @Override public ServerBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { return (ServerBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream); } + @Deprecated @Override public ServerBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword) { - virtualHostTemplate.tls(keyCertChainInputStream, keyInputStream, keyPassword); - return this; + return (ServerBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream, keyPassword); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, X509Certificate... keyCertChain) { return (ServerBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, Iterable keyCertChain) { return (ServerBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain) { return (ServerBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); } + @Deprecated @Override public ServerBuilder tls(PrivateKey key, @Nullable String keyPassword, Iterable keyCertChain) { - virtualHostTemplate.tls(key, keyPassword, keyCertChain); + return (ServerBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); + } + + /** + * Configures SSL or TLS with the specified {@link TlsKeyPair}. + * + *

    Note that this method mutually exclusive with {@link #tlsProvider(TlsProvider)}. + */ + @Override + public ServerBuilder tls(TlsKeyPair tlsKeyPair) { + virtualHostTemplate.tls(tlsKeyPair); return this; } @@ -1133,9 +1155,69 @@ public ServerBuilder tls(KeyManagerFactory keyManagerFactory) { return this; } + /** + * Sets the specified {@link TlsProvider} which will be used for building an {@link SslContext} of + * a hostname. + * + *

    {@code
    +     * Server
    +     *   .builder()
    +     *   .tlsProvider(
    +     *     TlsProvider.builder()
    +     *                // Set the default key pair.
    +     *                .keyPair(TlsKeyPair.of(...))
    +     *                // Set the key pair for "example.com".
    +     *                .keyPair("example.com", TlsKeyPair.of(...))
    +     *                .build())
    +     * }
    + * + *

    Note that this method mutually exclusive with {@link #tls(TlsKeyPair)} and other static TLS settings. + */ + @UnstableApi + public ServerBuilder tlsProvider(TlsProvider tlsProvider) { + requireNonNull(tlsProvider, "tlsProvider"); + this.tlsProvider = tlsProvider; + tlsConfig = null; + return this; + } + + /** + * Sets the specified {@link TlsProvider} and {@link ServerTlsConfig} which will be used for building an + * {@link SslContext} of a hostname. + * + *

    {@code
    +     * TlsProvider tlsProvider =
    +     *   TlsProvider
    +     *     .builder()
    +     *     // Set the default key pair.
    +     *     .keyPair(TlsKeyPair.of(...))
    +     *     // Set the key pair for "example.com".
    +     *     .keyPair("example.com", TlsKeyPair.of(...))
    +     *     .build();
    +     *
    +     * ServerTlsConfig tlsConfig =
    +     *   ServerTlsConfig
    +     *     .builder()
    +     *     .clientAuth(ClientAuth.REQUIRED)
    +     *     .meterIdPrefix(...)
    +     *     .build();
    +     *
    +     * Server
    +     *   .builder()
    +     *   .tlsProvider(tlsProvider, tlsConfig)
    +     * }
    + */ + @UnstableApi + public ServerBuilder tlsProvider(TlsProvider tlsProvider, ServerTlsConfig tlsConfig) { + tlsProvider(tlsProvider); + this.tlsConfig = requireNonNull(tlsConfig, "tlsConfig"); + return this; + } + /** * Configures SSL or TLS of the {@link Server} with an auto-generated self-signed certificate. - * Note: You should never use this in production but only for a testing purpose. + * + *

    Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) */ @@ -1146,7 +1228,8 @@ public ServerBuilder tlsSelfSigned() { /** * Configures SSL or TLS of the {@link Server} with an auto-generated self-signed certificate. - * Note: You should never use this in production but only for a testing purpose. + * + *

    Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) */ @@ -2229,11 +2312,11 @@ private DefaultServerConfig buildServerConfig(List serverPorts) { : this.errorHandler.orElse(ServerErrorHandler.ofDefault())); final VirtualHost defaultVirtualHost = defaultVirtualHostBuilder.build(virtualHostTemplate, dependencyInjector, - unloggedExceptionsReporter, errorHandler); + unloggedExceptionsReporter, errorHandler, tlsProvider); final List virtualHosts = virtualHostBuilders.stream() .map(vhb -> vhb.build(virtualHostTemplate, dependencyInjector, - unloggedExceptionsReporter, errorHandler)) + unloggedExceptionsReporter, errorHandler, tlsProvider)) .collect(toImmutableList()); // Pre-populate the domain name mapping for later matching. final Mapping sslContexts; @@ -2261,7 +2344,9 @@ private DefaultServerConfig buildServerConfig(List serverPorts) { virtualHostPort, portNumbers); } - if (defaultSslContext == null) { + checkState(defaultSslContext == null || tlsProvider == null, + "Can't set %s with a static TLS setting", TlsProvider.class.getSimpleName()); + if (defaultSslContext == null && tlsProvider == null) { sslContexts = null; if (!serverPorts.isEmpty()) { ports = resolveDistinctPorts(serverPorts); @@ -2289,21 +2374,28 @@ private DefaultServerConfig buildServerConfig(List serverPorts) { ports = ImmutableList.of(new ServerPort(0, HTTPS)); } - final DomainMappingBuilder - mappingBuilder = new DomainMappingBuilder<>(defaultSslContext); - for (VirtualHost h : virtualHosts) { - final SslContext sslCtx = h.sslContext(); - if (sslCtx != null) { - final String originalHostnamePattern = h.originalHostnamePattern(); - // The SslContext for the default virtual host was added when creating DomainMappingBuilder. - if (!"*".equals(originalHostnamePattern)) { - mappingBuilder.add(originalHostnamePattern, sslCtx); + if (defaultSslContext != null) { + final DomainMappingBuilder + mappingBuilder = new DomainMappingBuilder<>(defaultSslContext); + for (VirtualHost h : virtualHosts) { + final SslContext sslCtx = h.sslContext(); + if (sslCtx != null) { + final String originalHostnamePattern = h.originalHostnamePattern(); + // The SslContext for the default virtual host was added when creating + // DomainMappingBuilder. + if (!"*".equals(originalHostnamePattern)) { + mappingBuilder.add(originalHostnamePattern, sslCtx); + } } } + sslContexts = mappingBuilder.build(); + } else { + final TlsEngineType tlsEngineType = defaultVirtualHost.tlsEngineType(); + assert tlsEngineType != null; + assert tlsProvider != null; + sslContexts = new TlsProviderMapping(tlsProvider, tlsEngineType, tlsConfig, meterRegistry); } - sslContexts = mappingBuilder.build(); } - if (pingIntervalMillis > 0) { pingIntervalMillis = Math.max(pingIntervalMillis, MIN_PING_INTERVAL_MILLIS); if (idleTimeoutMillis > 0 && pingIntervalMillis >= idleTimeoutMillis) { diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java b/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java index 0d3c080b5dc..d73dc0e62fc 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServerSslContextUtil.java @@ -26,8 +26,7 @@ import javax.net.ssl.SSLException; import javax.net.ssl.SSLSession; -import com.google.common.collect.ImmutableList; - +import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.TlsEngineType; import com.linecorp.armeria.internal.common.util.SslContextUtil; @@ -65,7 +64,7 @@ static SSLSession validateSslContext(SslContext sslContext, TlsEngineType tlsEng final SslContext sslContextClient = buildSslContext(() -> SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE), - tlsEngineType, true, ImmutableList.of()); + tlsEngineType, true, null); clientEngine = sslContextClient.newEngine(ByteBufAllocator.DEFAULT); clientEngine.setUseClientMode(true); clientEngine.setEnabledProtocols(clientEngine.getSupportedProtocols()); @@ -99,10 +98,10 @@ static SslContext buildSslContext( Supplier sslContextBuilderSupplier, TlsEngineType tlsEngineType, boolean tlsAllowUnsafeCiphers, - Iterable> tlsCustomizers) { + @Nullable Consumer tlsCustomizer) { return SslContextUtil .createSslContext(sslContextBuilderSupplier,/* forceHttp1 */ false, tlsEngineType, - tlsAllowUnsafeCiphers, tlsCustomizers, null); + tlsAllowUnsafeCiphers, tlsCustomizer, null); } private static void unwrap(SSLEngine engine, ByteBuffer packetBuf) throws SSLException { diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java new file mode 100644 index 00000000000..d1c63db70e8 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import java.util.function.Consumer; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.AbstractTlsConfig; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; + +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContextBuilder; + +/** + * Provides server-side TLS configuration for {@link TlsProvider}. + */ +@UnstableApi +public final class ServerTlsConfig extends AbstractTlsConfig { + + /** + * Returns a new {@link ServerTlsConfigBuilder}. + */ + public static ServerTlsConfigBuilder builder() { + return new ServerTlsConfigBuilder(); + } + + private final ClientAuth clientAuth; + + ServerTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix, + ClientAuth clientAuth, Consumer tlsCustomizer) { + super(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer); + this.clientAuth = clientAuth; + } + + /** + * Returns the client authentication mode. + */ + public ClientAuth clientAuth() { + return clientAuth; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("allowsUnsafeCiphers", allowsUnsafeCiphers()) + .add("meterIdPrefix", meterIdPrefix()) + .add("clientAuth", clientAuth) + .add("tlsCustomizer", tlsCustomizer()) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java new file mode 100644 index 00000000000..97c53e828aa --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.common.AbstractTlsConfigBuilder; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.UnstableApi; + +import io.netty.handler.ssl.ClientAuth; + +/** + * A builder class for creating a {@link TlsProvider} that provides server-side TLS. + */ +@UnstableApi +public final class ServerTlsConfigBuilder extends AbstractTlsConfigBuilder { + + private ClientAuth clientAuth = ClientAuth.NONE; + + ServerTlsConfigBuilder() {} + + /** + * Sets the client authentication mode. + */ + public ServerTlsConfigBuilder clientAuth(ClientAuth clientAuth) { + this.clientAuth = requireNonNull(clientAuth, "clientAuth"); + return this; + } + + /** + * Returns a newly-created {@link ServerTlsConfig} based on the properties of this builder. + */ + public ServerTlsConfig build() { + return new ServerTlsConfig(allowsUnsafeCiphers(), meterIdPrefix(), clientAuth, tlsCustomizer()); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java index 246aa8934be..d8a2086ffe0 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java @@ -335,6 +335,11 @@ default String queryParam(String name) { */ String decodedMappedPath(); + /** + * Returns the original path without normalization from the ":path" header. + */ + String rawPath(); + /** * Returns the {@link URI} associated with the current {@link Request}. * Note that this method is a shortcut of calling {@link HttpRequest#uri()} on {@link #request()}. diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java index 7e0555c253d..43715639c59 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java @@ -121,6 +121,11 @@ public String decodedMappedPath() { return unwrap().decodedMappedPath(); } + @Override + public String rawPath() { + return unwrap().rawPath(); + } + @Nullable @Override public MediaType negotiatedResponseMediaType() { diff --git a/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java b/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java new file mode 100644 index 00000000000..a36b9065be2 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.TlsEngineType; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.common.TlsProviderUtil; + +import io.micrometer.core.instrument.MeterRegistry; +import io.netty.handler.ssl.SslContext; +import io.netty.util.Mapping; + +final class TlsProviderMapping implements Mapping { + + private final SslContextFactory sslContextFactory; + + TlsProviderMapping(TlsProvider tlsProvider, TlsEngineType tlsEngineType, + @Nullable ServerTlsConfig tlsConfig, MeterRegistry meterRegistry) { + sslContextFactory = new SslContextFactory(tlsProvider, tlsEngineType, tlsConfig, meterRegistry); + } + + @Override + public SslContext map(@Nullable String hostname) { + if (hostname == null) { + hostname = "*"; + } else { + hostname = TlsProviderUtil.normalizeHostname(hostname); + } + return sslContextFactory.getOrCreate(SslContextFactory.SslContextMode.SERVER, hostname); + } + + void release(SslContext sslContext) { + sslContextFactory.release(sslContext); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java index 89ddb678a29..297f1a55507 100644 --- a/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java +++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java @@ -39,6 +39,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.RequestId; import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.common.logging.RequestLog; @@ -52,6 +53,7 @@ import io.netty.channel.EventLoopGroup; import io.netty.handler.ssl.SslContext; import io.netty.util.Mapping; +import io.netty.util.ReferenceCountUtil; /** * A name-based virtual host. @@ -84,6 +86,8 @@ public final class VirtualHost { @Nullable private final SslContext sslContext; @Nullable + private final TlsProvider tlsProvider; + @Nullable private final TlsEngineType tlsEngineType; private final Router router; private final List serviceConfigs; @@ -109,6 +113,7 @@ public final class VirtualHost { VirtualHost(String defaultHostname, String hostnamePattern, int port, @Nullable SslContext sslContext, + @Nullable TlsProvider tlsProvider, @Nullable TlsEngineType tlsEngineType, Iterable serviceConfigs, ServiceConfig fallbackServiceConfig, @@ -138,6 +143,7 @@ public final class VirtualHost { } this.port = port; this.sslContext = sslContext; + this.tlsProvider = tlsProvider; this.tlsEngineType = tlsEngineType; this.defaultServiceNaming = defaultServiceNaming; this.defaultLogName = defaultLogName; @@ -172,7 +178,11 @@ public final class VirtualHost { } VirtualHost withNewSslContext(SslContext sslContext) { - return new VirtualHost(originalDefaultHostname, originalHostnamePattern, port, sslContext, + if (tlsProvider != null) { + ReferenceCountUtil.release(sslContext); + throw new IllegalStateException("Cannot set a new SslContext when TlsProvider is set."); + } + return new VirtualHost(originalDefaultHostname, originalHostnamePattern, port, sslContext, null, tlsEngineType, serviceConfigs, fallbackServiceConfig, RejectedRouteHandler.DISABLED, host -> accessLogger, defaultServiceNaming, defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses, @@ -590,7 +600,7 @@ VirtualHost decorate(@Nullable Function accessLogger, defaultServiceNaming, defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses, diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java index 323f7e04300..0868d04e9df 100644 --- a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java @@ -32,10 +32,7 @@ import static io.netty.handler.codec.http2.Http2Headers.PseudoHeaderName.isPseudoHeader; import static java.util.Objects.requireNonNull; -import java.io.ByteArrayInputStream; import java.io.File; -import java.io.IOError; -import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; import java.security.PrivateKey; @@ -60,7 +57,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import com.google.common.io.ByteStreams; import com.google.common.net.HostAndPort; import com.linecorp.armeria.common.CommonPools; @@ -76,6 +72,8 @@ import com.linecorp.armeria.common.RequestId; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.common.SuccessFunction; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.TlsSetters; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -134,7 +132,8 @@ public final class VirtualHostBuilder implements TlsSetters, ServiceConfigsBuild private Boolean tlsSelfSigned; @Nullable private SelfSignedCertificate selfSignedCertificate; - private final List> tlsCustomizers = new ArrayList<>(); + @Nullable + private Consumer tlsCustomizer; @Nullable private Boolean tlsAllowUnsafeCiphers; @Nullable @@ -276,70 +275,61 @@ VirtualHostBuilder hostnamePattern(String hostnamePattern, int port) { return this; } + @Deprecated @Override public VirtualHostBuilder tls(File keyCertChainFile, File keyFile) { return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile); } + @Deprecated @Override public VirtualHostBuilder tls(File keyCertChainFile, File keyFile, @Nullable String keyPassword) { - requireNonNull(keyCertChainFile, "keyCertChainFile"); - requireNonNull(keyFile, "keyFile"); - return tls(() -> SslContextBuilder.forServer(keyCertChainFile, keyFile, keyPassword)); + return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainFile, keyFile, keyPassword); } + @Deprecated @Override public VirtualHostBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream) { return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream); } + @Deprecated @Override public VirtualHostBuilder tls(InputStream keyCertChainInputStream, InputStream keyInputStream, @Nullable String keyPassword) { - requireNonNull(keyCertChainInputStream, "keyCertChainInputStream"); - requireNonNull(keyInputStream, "keyInputStream"); - - // Retrieve the content of the given streams so that they can be consumed more than once. - final byte[] keyCertChain; - final byte[] key; - try { - keyCertChain = ByteStreams.toByteArray(keyCertChainInputStream); - key = ByteStreams.toByteArray(keyInputStream); - } catch (IOException e) { - throw new IOError(e); - } - - return tls(() -> SslContextBuilder.forServer(new ByteArrayInputStream(keyCertChain), - new ByteArrayInputStream(key), - keyPassword)); + return (VirtualHostBuilder) TlsSetters.super.tls(keyCertChainInputStream, keyInputStream, keyPassword); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, X509Certificate... keyCertChain) { return (VirtualHostBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, Iterable keyCertChain) { return (VirtualHostBuilder) TlsSetters.super.tls(key, keyCertChain); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, @Nullable String keyPassword, X509Certificate... keyCertChain) { return (VirtualHostBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); } + @Deprecated @Override public VirtualHostBuilder tls(PrivateKey key, @Nullable String keyPassword, Iterable keyCertChain) { - requireNonNull(key, "key"); - requireNonNull(keyCertChain, "keyCertChain"); - for (X509Certificate keyCert : keyCertChain) { - requireNonNull(keyCert, "keyCertChain contains null."); - } + return (VirtualHostBuilder) TlsSetters.super.tls(key, keyPassword, keyCertChain); + } - return tls(() -> SslContextBuilder.forServer(key, keyPassword, keyCertChain)); + @Override + public VirtualHostBuilder tls(TlsKeyPair tlsKeyPair) { + requireNonNull(tlsKeyPair, "tlsKeyPair"); + return tls(() -> SslContextBuilder.forServer(tlsKeyPair.privateKey(), tlsKeyPair.certificateChain())); } @Override @@ -363,7 +353,9 @@ private VirtualHostBuilder tls(Supplier sslContextBuilderSupp * Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) + * @deprecated Use {@link #tls(TlsKeyPair)} with {@link TlsKeyPair#ofSelfSigned()}. */ + @Deprecated public VirtualHostBuilder tlsSelfSigned() { return tlsSelfSigned(true); } @@ -373,7 +365,9 @@ public VirtualHostBuilder tlsSelfSigned() { * Note: You should never use this in production but only for a testing purpose. * * @see #tlsCustomizer(Consumer) + * @deprecated Use {@link #tls(TlsKeyPair)} with {@link TlsKeyPair#ofSelfSigned()}. */ + @Deprecated public VirtualHostBuilder tlsSelfSigned(boolean tlsSelfSigned) { checkState(!portBased, "Cannot configure self-signed to a port-based virtual host." + " Please configure to %s.tlsSelfSigned()", ServerBuilder.class.getSimpleName()); @@ -387,7 +381,12 @@ public VirtualHostBuilder tlsCustomizer(Consumer tlsC checkState(!portBased, "Cannot configure TLS to a port-based virtual host. Please configure to %s.tlsCustomizer()", ServerBuilder.class.getSimpleName()); - tlsCustomizers.add(tlsCustomizer); + if (this.tlsCustomizer == null) { + //noinspection unchecked + this.tlsCustomizer = (Consumer) tlsCustomizer; + } else { + this.tlsCustomizer = this.tlsCustomizer.andThen(tlsCustomizer); + } return this; } @@ -1306,7 +1305,7 @@ public VirtualHostBuilder contextHook(Supplier contextH */ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInjector, @Nullable UnloggedExceptionsReporter unloggedExceptionsReporter, - ServerErrorHandler serverErrorHandler) { + ServerErrorHandler serverErrorHandler, @Nullable TlsProvider tlsProvider) { requireNonNull(template, "template"); if (defaultHostname == null) { @@ -1464,9 +1463,17 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje final TlsEngineType tlsEngineType = this.tlsEngineType != null ? this.tlsEngineType : template.tlsEngineType; assert tlsEngineType != null; + + final SslContext sslContext = sslContext(template, tlsEngineType); + if (sslContext != null && tlsProvider != null) { + ReferenceCountUtil.release(sslContext); + throw new IllegalStateException("Cannot configure TLS settings with a TlsProvider"); + } + final VirtualHost virtualHost = - new VirtualHost(defaultHostname, hostnamePattern, port, sslContext(template, tlsEngineType), - tlsEngineType, serviceConfigs, fallbackServiceConfig, rejectedRouteHandler, + new VirtualHost(defaultHostname, hostnamePattern, port, + sslContext, tlsProvider, tlsEngineType, + serviceConfigs, fallbackServiceConfig, rejectedRouteHandler, accessLoggerMapper, defaultServiceNaming, defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses, accessLogWriter, blockingTaskExecutor, requestAutoAbortDelayMillis, successFunction, multipartUploadsLocation, @@ -1516,27 +1523,27 @@ private SslContext sslContext(VirtualHostBuilder template, TlsEngineType tlsEngi // Build a new SslContext or use a user-specified one for backward compatibility. if (sslContextBuilderSupplier != null) { sslContext = buildSslContext(sslContextBuilderSupplier, tlsEngineType, tlsAllowUnsafeCiphers, - tlsCustomizers); + tlsCustomizer); sslContextFromThis = true; releaseSslContextOnFailure = true; } else if (template.sslContextBuilderSupplier != null) { sslContext = buildSslContext(template.sslContextBuilderSupplier, tlsEngineType, - tlsAllowUnsafeCiphers, template.tlsCustomizers); + tlsAllowUnsafeCiphers, template.tlsCustomizer); releaseSslContextOnFailure = true; } // Generate a self-signed certificate if necessary. if (sslContext == null) { final boolean tlsSelfSigned; - final List> tlsCustomizers; + final Consumer tlsCustomizer; if (this.tlsSelfSigned != null) { tlsSelfSigned = this.tlsSelfSigned; - tlsCustomizers = this.tlsCustomizers; + tlsCustomizer = this.tlsCustomizer; sslContextFromThis = true; } else { assert template.tlsSelfSigned != null; tlsSelfSigned = template.tlsSelfSigned; - tlsCustomizers = template.tlsCustomizers; + tlsCustomizer = template.tlsCustomizer; } if (tlsSelfSigned) { @@ -1551,13 +1558,13 @@ private SslContext sslContext(VirtualHostBuilder template, TlsEngineType tlsEngi ssc.privateKey()), tlsEngineType, tlsAllowUnsafeCiphers, - tlsCustomizers); + tlsCustomizer); releaseSslContextOnFailure = true; } } // Reject if a user called `tlsCustomizer()` without `tls()` or `tlsSelfSigned()`. - checkState(sslContextFromThis || tlsCustomizers.isEmpty(), + checkState(sslContextFromThis || tlsCustomizer == null, "Cannot call tlsCustomizer() without tls() or tlsSelfSigned()"); // Validate the built `SslContext`. diff --git a/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java b/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java index ef0c7cdcd31..4c3f58863ec 100644 --- a/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java +++ b/core/src/main/java/com/linecorp/armeria/server/cors/CorsService.java @@ -17,6 +17,7 @@ package com.linecorp.armeria.server.cors; import static com.linecorp.armeria.internal.common.ArmeriaHttpUtil.isCorsPreflightRequest; +import static com.linecorp.armeria.internal.server.CorsHeaderUtil.isForbiddenOrigin; import static java.util.Objects.requireNonNull; import java.util.List; @@ -29,7 +30,6 @@ import com.google.common.collect.ImmutableList; -import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; @@ -127,21 +127,20 @@ public CorsConfig config() { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { + CorsHeaderUtil.corsHeadersSet(ctx); // check if CORS preflight must be returned, or if // we need to forbid access because origin could not be validated if (isCorsPreflightRequest(req.headers())) { return handleCorsPreflight(ctx, req); } - if (config.isShortCircuit() && - config.getPolicy(req.headers().get(HttpHeaderNames.ORIGIN), ctx.routingContext()) == null) { + if (isForbiddenOrigin(config, ctx, req.headers())) { return forbidden(); } - return unwrap().serve(ctx, req).mapHeaders(headers -> { - final ResponseHeadersBuilder builder = headers.toBuilder(); + ctx.mutateAdditionalResponseHeaders(builder -> { CorsHeaderUtil.setCorsResponseHeaders(ctx, req, builder, config); - return builder.build(); }); + return unwrap().serve(ctx, req); } /** diff --git a/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckServiceBuilder.java b/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckServiceBuilder.java index 72ac5c8e2f4..853847adbf8 100644 --- a/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckServiceBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckServiceBuilder.java @@ -234,10 +234,11 @@ public HealthCheckServiceBuilder longPolling(long maxLongPollingTimeoutMillis, * * @return {@code this} * @see #updatable(HealthCheckUpdateHandler) + * @see HealthCheckUpdateHandler#of() */ public HealthCheckServiceBuilder updatable(boolean updatable) { if (updatable) { - return updatable(DefaultHealthCheckUpdateHandler.INSTANCE); + return updatable(HealthCheckUpdateHandler.of()); } updateHandler = null; diff --git a/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckUpdateHandler.java b/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckUpdateHandler.java index 28c7ac54114..1625e42dff3 100644 --- a/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckUpdateHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/healthcheck/HealthCheckUpdateHandler.java @@ -18,6 +18,7 @@ import java.util.concurrent.CompletionStage; import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.server.HttpResponseException; import com.linecorp.armeria.server.HttpStatusException; import com.linecorp.armeria.server.Server; @@ -28,6 +29,30 @@ */ @FunctionalInterface public interface HealthCheckUpdateHandler { + + /** + * Returns the default {@link HealthCheckUpdateHandler} that accepts a JSON object which has a boolean + * property named {@code "healthy"} for a {@code PUT} or {@code POST} request. A JSON patch in a + * {@code PATCH} request is also accepted. + * + *

    For example: + *

    {@code
    +     * // Update healthiness of the server to unhealthy
    +     * POST /internal/health HTTP/2.0
    +     *
    +     * { "healthy": false }
    +     *
    +     * // Patch healthiness of the server to unhealthy
    +     * PATCH /internal/health HTTP/2.0
    +     *
    +     * [ { "op": "replace", "path": "/healthy", "value": false } ]
    +     * }
    + */ + @UnstableApi + static HealthCheckUpdateHandler of() { + return DefaultHealthCheckUpdateHandler.INSTANCE; + } + /** * Determines if the healthiness of the {@link Server} needs to be changed or not from the given * {@link HttpRequest}. diff --git a/core/src/main/java/com/linecorp/armeria/server/logging/AccessLogWriter.java b/core/src/main/java/com/linecorp/armeria/server/logging/AccessLogWriter.java index ff2b94d1cc6..c4f34ce77a3 100644 --- a/core/src/main/java/com/linecorp/armeria/server/logging/AccessLogWriter.java +++ b/core/src/main/java/com/linecorp/armeria/server/logging/AccessLogWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 LINE Corporation + * Copyright 2024 LINE Corporation * * LINE Corporation licenses this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance @@ -13,6 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ + package com.linecorp.armeria.server.logging; import static com.google.common.base.Preconditions.checkArgument; diff --git a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json index 3d15324de66..e2716fa747a 100644 --- a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json +++ b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/jni-config.json @@ -145,6 +145,8 @@ "name" : "", "parameterTypes" : [ "long", "byte[][]", "java.lang.String", "io.netty.internal.tcnative.CertificateVerifier" ] } ] +}, { + "name" : "io.netty.internal.tcnative.Library" }, { "name" : "io.netty.internal.tcnative.NativeStaticallyReferencedJniMethods" }, { diff --git a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json index 7efa9b1a948..948f2671632 100644 --- a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json +++ b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/reflect-config.json @@ -10,6 +10,8 @@ "name" : "[I" }, { "name" : "[J" +}, { + "name" : "[Lcom.fasterxml.jackson.databind.deser.BeanDeserializerModifier;" }, { "name" : "[Lcom.fasterxml.jackson.databind.deser.Deserializers;" }, { @@ -79,6 +81,12 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "ch.qos.logback.classic.joran.SerializedModelConfigurator", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "ch.qos.logback.classic.jul.LevelChangePropagator", "queryAllPublicMethods" : true, @@ -129,6 +137,12 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "ch.qos.logback.classic.util.DefaultJoranConfigurator", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "ch.qos.logback.core.Appender", "queryAllDeclaredMethods" : true, @@ -185,7 +199,11 @@ }, { "name" : "ch.qos.logback.core.spi.ContextAware", "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true + "queryAllDeclaredConstructors" : true, + "queriedMethods" : [ { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.String" ] + } ] }, { "name" : "ch.qos.logback.core.spi.ContextAwareBase", "queryAllDeclaredMethods" : true, @@ -254,6 +272,293 @@ "name" : "com.fasterxml.jackson.jaxrs.json.JacksonJsonProvider", "allDeclaredFields" : true, "queryAllDeclaredMethods" : true +}, { + "name" : "com.google.api.CustomHttpPattern", + "queriedMethods" : [ { + "name" : "newBuilder", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.google.api.HttpBody", + "methods" : [ { + "name" : "newBuilder", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "getContentType", + "parameterTypes" : [ ] + }, { + "name" : "getContentTypeBytes", + "parameterTypes" : [ ] + }, { + "name" : "getData", + "parameterTypes" : [ ] + }, { + "name" : "getDefaultInstance", + "parameterTypes" : [ ] + }, { + "name" : "getExtensions", + "parameterTypes" : [ "int" ] + }, { + "name" : "getExtensionsCount", + "parameterTypes" : [ ] + }, { + "name" : "getExtensionsList", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.google.api.HttpBody$Builder", + "queriedMethods" : [ { + "name" : "addExtensions", + "parameterTypes" : [ "com.google.protobuf.Any" ] + }, { + "name" : "clearContentType", + "parameterTypes" : [ ] + }, { + "name" : "clearData", + "parameterTypes" : [ ] + }, { + "name" : "clearExtensions", + "parameterTypes" : [ ] + }, { + "name" : "getContentType", + "parameterTypes" : [ ] + }, { + "name" : "getData", + "parameterTypes" : [ ] + }, { + "name" : "getExtensions", + "parameterTypes" : [ "int" ] + }, { + "name" : "getExtensionsBuilder", + "parameterTypes" : [ "int" ] + }, { + "name" : "getExtensionsCount", + "parameterTypes" : [ ] + }, { + "name" : "getExtensionsList", + "parameterTypes" : [ ] + }, { + "name" : "setContentType", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setContentTypeBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setData", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setExtensions", + "parameterTypes" : [ "int", "com.google.protobuf.Any" ] + } ] +}, { + "name" : "com.google.api.HttpRule", + "methods" : [ { + "name" : "getAdditionalBindingsList", + "parameterTypes" : [ ] + }, { + "name" : "getBody", + "parameterTypes" : [ ] + }, { + "name" : "getPatternCase", + "parameterTypes" : [ ] + }, { + "name" : "getPost", + "parameterTypes" : [ ] + }, { + "name" : "getResponseBody", + "parameterTypes" : [ ] + }, { + "name" : "getSelector", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "getAdditionalBindings", + "parameterTypes" : [ "int" ] + }, { + "name" : "getAdditionalBindingsCount", + "parameterTypes" : [ ] + }, { + "name" : "getBodyBytes", + "parameterTypes" : [ ] + }, { + "name" : "getCustom", + "parameterTypes" : [ ] + }, { + "name" : "getDelete", + "parameterTypes" : [ ] + }, { + "name" : "getDeleteBytes", + "parameterTypes" : [ ] + }, { + "name" : "getGet", + "parameterTypes" : [ ] + }, { + "name" : "getGetBytes", + "parameterTypes" : [ ] + }, { + "name" : "getPatch", + "parameterTypes" : [ ] + }, { + "name" : "getPatchBytes", + "parameterTypes" : [ ] + }, { + "name" : "getPostBytes", + "parameterTypes" : [ ] + }, { + "name" : "getPut", + "parameterTypes" : [ ] + }, { + "name" : "getPutBytes", + "parameterTypes" : [ ] + }, { + "name" : "getResponseBodyBytes", + "parameterTypes" : [ ] + }, { + "name" : "getSelectorBytes", + "parameterTypes" : [ ] + }, { + "name" : "newBuilder", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.google.api.HttpRule$Builder", + "queriedMethods" : [ { + "name" : "addAdditionalBindings", + "parameterTypes" : [ "com.google.api.HttpRule" ] + }, { + "name" : "clearAdditionalBindings", + "parameterTypes" : [ ] + }, { + "name" : "clearBody", + "parameterTypes" : [ ] + }, { + "name" : "clearCustom", + "parameterTypes" : [ ] + }, { + "name" : "clearDelete", + "parameterTypes" : [ ] + }, { + "name" : "clearGet", + "parameterTypes" : [ ] + }, { + "name" : "clearPatch", + "parameterTypes" : [ ] + }, { + "name" : "clearPattern", + "parameterTypes" : [ ] + }, { + "name" : "clearPost", + "parameterTypes" : [ ] + }, { + "name" : "clearPut", + "parameterTypes" : [ ] + }, { + "name" : "clearResponseBody", + "parameterTypes" : [ ] + }, { + "name" : "clearSelector", + "parameterTypes" : [ ] + }, { + "name" : "getAdditionalBindings", + "parameterTypes" : [ "int" ] + }, { + "name" : "getAdditionalBindingsBuilder", + "parameterTypes" : [ "int" ] + }, { + "name" : "getAdditionalBindingsCount", + "parameterTypes" : [ ] + }, { + "name" : "getAdditionalBindingsList", + "parameterTypes" : [ ] + }, { + "name" : "getBody", + "parameterTypes" : [ ] + }, { + "name" : "getCustom", + "parameterTypes" : [ ] + }, { + "name" : "getCustomBuilder", + "parameterTypes" : [ ] + }, { + "name" : "getDelete", + "parameterTypes" : [ ] + }, { + "name" : "getGet", + "parameterTypes" : [ ] + }, { + "name" : "getPatch", + "parameterTypes" : [ ] + }, { + "name" : "getPatternCase", + "parameterTypes" : [ ] + }, { + "name" : "getPost", + "parameterTypes" : [ ] + }, { + "name" : "getPut", + "parameterTypes" : [ ] + }, { + "name" : "getResponseBody", + "parameterTypes" : [ ] + }, { + "name" : "getSelector", + "parameterTypes" : [ ] + }, { + "name" : "setAdditionalBindings", + "parameterTypes" : [ "int", "com.google.api.HttpRule" ] + }, { + "name" : "setBody", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setBodyBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setCustom", + "parameterTypes" : [ "com.google.api.CustomHttpPattern" ] + }, { + "name" : "setDelete", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setDeleteBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setGet", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setGetBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setPatch", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setPatchBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setPost", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setPostBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setPut", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setPutBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setResponseBody", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setResponseBodyBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + }, { + "name" : "setSelector", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "setSelectorBytes", + "parameterTypes" : [ "com.google.protobuf.ByteString" ] + } ] }, { "name" : "com.google.common.util.concurrent.AbstractFuture", "fields" : [ { @@ -2132,6 +2437,13 @@ } ] }, { "name" : "com.linecorp.armeria.client.Bootstraps$1" +}, { + "name" : "com.linecorp.armeria.client.DefaultEventLoopScheduler", + "fields" : [ { + "name" : "acquisitionStartIndex" + }, { + "name" : "lastCleanupTimeNanos" + } ] }, { "name" : "com.linecorp.armeria.client.Http1ResponseDecoder", "queriedMethods" : [ { @@ -2197,6 +2509,12 @@ "name" : "channelActive", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext" ] } ] +}, { + "name" : "com.linecorp.armeria.client.HttpClientPipelineConfigurator$ClientSslHandler", + "queriedMethods" : [ { + "name" : "channelActive", + "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext" ] + } ] }, { "name" : "com.linecorp.armeria.client.HttpClientPipelineConfigurator$DowngradeHandler" }, { @@ -2223,6 +2541,21 @@ "name" : "userEventTriggered", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Object" ] } ] +}, { + "name" : "com.linecorp.armeria.client.retrofit2.ArmeriaCallFactory$ArmeriaCall", + "fields" : [ { + "name" : "executionState" + } ] +}, { + "name" : "com.linecorp.armeria.common.CompletableRpcResponse", + "fields" : [ { + "name" : "cause" + } ] +}, { + "name" : "com.linecorp.armeria.common.DefaultConcurrentAttributes", + "fields" : [ { + "name" : "attributes" + } ] }, { "name" : "com.linecorp.armeria.common.HttpHeaderNames", "allDeclaredFields" : true @@ -2238,6 +2571,8 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.common.ResponseEntity" }, { "name" : "com.linecorp.armeria.common.annotation.Nullable", "queryAllPublicMethods" : true @@ -2283,38 +2618,107 @@ "parameterTypes" : [ "java.lang.String" ] } ] }, { - "name" : "com.linecorp.armeria.common.sse.ServerSentEvent" + "name" : "com.linecorp.armeria.common.logging.DefaultRequestLog", + "fields" : [ { + "name" : "deferredFlags" + }, { + "name" : "flags" + } ] }, { - "name" : "com.linecorp.armeria.common.stream.AbortedStreamException", + "name" : "com.linecorp.armeria.common.multipart.MultipartDecoder", "fields" : [ { - "name" : "INSTANCE" + "name" : "delegatedSubscriber" } ] }, { - "name" : "com.linecorp.armeria.common.util.Version", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "artifactId", - "parameterTypes" : [ ] - }, { - "name" : "artifactVersion", - "parameterTypes" : [ ] - }, { - "name" : "commitTimeMillis", - "parameterTypes" : [ ] - }, { - "name" : "longCommitHash", - "parameterTypes" : [ ] - }, { - "name" : "repositoryStatus", - "parameterTypes" : [ ] + "name" : "com.linecorp.armeria.common.multipart.MultipartEncoder", + "fields" : [ { + "name" : "completionFuture" }, { - "name" : "shortCommitHash", - "parameterTypes" : [ ] + "name" : "subscribed" } ] }, { - "name" : "com.linecorp.armeria.common.zookeeper.ServerSetsInstanceConverter$FinagleServiceInstanceDeserializer", + "name" : "com.linecorp.armeria.common.sse.ServerSentEvent" +}, { + "name" : "com.linecorp.armeria.common.stream.AbortedStreamException", + "fields" : [ { + "name" : "INSTANCE" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.AggregationSupport", + "fields" : [ { + "name" : "aggregation" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatArrayStreamMessage", + "fields" : [ { + "name" : "subscribed" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatArrayStreamMessage$ConcatArraySubscriber", + "fields" : [ { + "name" : "cancelled" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatPublisherStreamMessage", + "fields" : [ { + "name" : "outerSubscriber" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.ConcatPublisherStreamMessage$InnerSubscriber", + "fields" : [ { + "name" : "cancelled" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.DefaultStreamMessage", + "fields" : [ { + "name" : "state" + }, { + "name" : "subscription" + } ] +}, { + "name" : "com.linecorp.armeria.common.stream.DeferredStreamMessage", + "fields" : [ { + "name" : "abortCause" + }, { + "name" : "collectingFuture" + }, { + "name" : "downstreamSubscription" + }, { + "name" : "subscribedToUpstream" + }, { + "name" : "upstream" + } ] +}, { + "name" : "com.linecorp.armeria.common.util.AsyncCloseableSupport", + "fields" : [ { + "name" : "closing" + } ] +}, { + "name" : "com.linecorp.armeria.common.util.Version", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "artifactId", + "parameterTypes" : [ ] + }, { + "name" : "artifactVersion", + "parameterTypes" : [ ] + }, { + "name" : "commitTimeMillis", + "parameterTypes" : [ ] + }, { + "name" : "longCommitHash", + "parameterTypes" : [ ] + }, { + "name" : "repositoryStatus", + "parameterTypes" : [ ] + }, { + "name" : "shortCommitHash", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.linecorp.armeria.common.zookeeper.ServerSetsInstanceConverter$FinagleServiceInstanceDeserializer", "methods" : [ { "name" : "", "parameterTypes" : [ ] @@ -2325,12 +2729,31 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.internal.client.DefaultClientRequestContext", + "fields" : [ { + "name" : "additionalRequestHeaders" + }, { + "name" : "whenInitialized" + } ] +}, { + "name" : "com.linecorp.armeria.internal.client.grpc.ArmeriaClientCall", + "fields" : [ { + "name" : "pendingMessages" + }, { + "name" : "pendingTask" + } ] }, { "name" : "com.linecorp.armeria.internal.common.AbstractHttp2ConnectionHandler", "queriedMethods" : [ { "name" : "close", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "io.netty.channel.ChannelPromise" ] } ] +}, { + "name" : "com.linecorp.armeria.internal.common.NonWrappingRequestContext", + "fields" : [ { + "name" : "contextHook" + } ] }, { "name" : "com.linecorp.armeria.internal.common.ReadSuppressingHandler", "queriedMethods" : [ { @@ -2538,6 +2961,23 @@ "fields" : [ { "name" : "label" } ] +}, { + "name" : "com.linecorp.armeria.internal.common.stream.AbortedStreamMessage", + "fields" : [ { + "name" : "subscribed" + } ] +}, { + "name" : "com.linecorp.armeria.internal.common.stream.FixedStreamMessage", + "fields" : [ { + "name" : "abortCause" + }, { + "name" : "executor" + } ] +}, { + "name" : "com.linecorp.armeria.internal.common.stream.RecoverableStreamMessage", + "fields" : [ { + "name" : "subscribed" + } ] }, { "name" : "com.linecorp.armeria.internal.consul.AgentServiceClient$Service", "allDeclaredFields" : true, @@ -2654,16 +3094,31 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.internal.server.DefaultServiceRequestContext", + "fields" : [ { + "name" : "additionalResponseHeaders" + }, { + "name" : "additionalResponseTrailers" + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.COMPOSITE$Mappings" }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DH$Mappings" }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DSA$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.DSTU4145$Mappings" }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.EC$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.EC$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.ECGOST$Mappings" }, { @@ -2677,9 +3132,23 @@ }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.IES$Mappings" }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.RSA$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.X509$Mappings", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { - "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.X509$Mappings" + "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.rsa.DigestSignatureSpi$SHA256", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.bouncycastle.jcajce.provider.asymmetric.x509.CertificateFactory", "methods" : [ { @@ -3019,11 +3488,34 @@ }, { "name" : "thread" } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.guava.util.concurrent.AggregateFutureState", + "fields" : [ { + "name" : "remaining" + }, { + "name" : "seenExceptions" + } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.jctools.maps.ConcurrentAutoTable", + "fields" : [ { + "name" : "_cat" + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.jctools.maps.NonBlockingHashMap", "fields" : [ { "name" : "_kvs" } ] +}, { + "name" : "com.linecorp.armeria.internal.shaded.jctools.maps.NonBlockingHashMap$CHM", + "fields" : [ { + "name" : "_copyDone" + }, { + "name" : "_copyIdx" + }, { + "name" : "_newkvs" + }, { + "name" : "_resizers" + } ] }, { "name" : "com.linecorp.armeria.internal.shaded.jctools.queues.BaseMpscLinkedArrayQueueColdProducerFields", "fields" : [ { @@ -3063,6 +3555,11 @@ "name" : "patternString", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.server.DefaultUnloggedExceptionsReporter", + "fields" : [ { + "name" : "scheduled" + } ] }, { "name" : "com.linecorp.armeria.server.Http1RequestDecoder", "queriedMethods" : [ { @@ -3096,6 +3593,8 @@ "name" : "userEventTriggered", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Object" ] } ] +}, { + "name" : "com.linecorp.armeria.server.HttpServerCodec" }, { "name" : "com.linecorp.armeria.server.HttpServerHandler", "queriedMethods" : [ { @@ -3517,6 +4016,11 @@ "name" : "", "parameterTypes" : [ ] } ] +}, { + "name" : "com.linecorp.armeria.server.grpc.StreamingServerCall", + "fields" : [ { + "name" : "pendingMessages" + } ] }, { "name" : "com.linecorp.armeria.server.protobuf.ProtobufRequestConverterFunction", "methods" : [ { @@ -3932,6 +4436,11 @@ } ] }, { "name" : "io.grpc.internal.PickFirstLoadBalancerProvider" +}, { + "name" : "io.grpc.internal.SerializingExecutor", + "fields" : [ { + "name" : "runState" + } ] }, { "name" : "io.grpc.kotlin.AbstractCoroutineServerImpl", "queryAllDeclaredMethods" : true @@ -3973,6 +4482,16 @@ }, { "name" : "io.micrometer.core.instrument.DistributionSummary$Builder", "queryAllPublicMethods" : true +}, { + "name" : "io.micrometer.core.instrument.distribution.AbstractTimeWindowHistogram", + "fields" : [ { + "name" : "rotating" + } ] +}, { + "name" : "io.micrometer.core.instrument.distribution.TimeWindowMax", + "fields" : [ { + "name" : "rotating" + } ] }, { "name" : "io.netty.bootstrap.ServerBootstrap$1" }, { @@ -3992,6 +4511,11 @@ "fields" : [ { "name" : "refCnt" } ] +}, { + "name" : "io.netty.channel.AbstractChannelHandlerContext", + "fields" : [ { + "name" : "handlerState" + } ] }, { "name" : "io.netty.channel.ChannelDuplexHandler", "queriedMethods" : [ { @@ -4064,6 +4588,13 @@ "name" : "exceptionCaught", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Throwable" ] } ] +}, { + "name" : "io.netty.channel.ChannelOutboundBuffer", + "fields" : [ { + "name" : "totalPendingSize" + }, { + "name" : "unwritable" + } ] }, { "name" : "io.netty.channel.ChannelOutboundHandlerAdapter", "queriedMethods" : [ { @@ -4145,6 +4676,18 @@ "name" : "write", "parameterTypes" : [ "io.netty.channel.ChannelHandlerContext", "java.lang.Object", "io.netty.channel.ChannelPromise" ] } ] +}, { + "name" : "io.netty.channel.DefaultChannelConfig", + "fields" : [ { + "name" : "autoRead" + }, { + "name" : "writeBufferWaterMark" + } ] +}, { + "name" : "io.netty.channel.DefaultChannelPipeline", + "fields" : [ { + "name" : "estimatorHandle" + } ] }, { "name" : "io.netty.channel.DefaultChannelPipeline$HeadContext", "queriedMethods" : [ { @@ -4346,6 +4889,11 @@ "name" : "io.netty.channel.unix.DatagramSocketAddress" }, { "name" : "io.netty.channel.unix.DomainDatagramSocketAddress" +}, { + "name" : "io.netty.channel.unix.FileDescriptor", + "fields" : [ { + "name" : "state" + } ] }, { "name" : "io.netty.channel.unix.PeerCredentials" }, { @@ -4591,6 +5139,11 @@ "name" : "io.netty.internal.tcnative.SSLPrivateKeyMethodTask" }, { "name" : "io.netty.internal.tcnative.SSLTask" +}, { + "name" : "io.netty.resolver.dns.Cache$Entries", + "fields" : [ { + "name" : "expirationFuture" + } ] }, { "name" : "io.netty.resolver.dns.DnsNameResolver", "methods" : [ { @@ -4631,9 +5184,43 @@ "fields" : [ { "name" : "refCnt" } ] +}, { + "name" : "io.netty.util.DefaultAttributeMap", + "fields" : [ { + "name" : "attributes" + } ] +}, { + "name" : "io.netty.util.DefaultAttributeMap$DefaultAttribute", + "fields" : [ { + "name" : "attributeMap" + } ] +}, { + "name" : "io.netty.util.Recycler$DefaultHandle", + "fields" : [ { + "name" : "state" + } ] }, { "name" : "io.netty.util.ReferenceCountUtil", "queryAllDeclaredMethods" : true +}, { + "name" : "io.netty.util.ResourceLeakDetector$DefaultResourceLeak", + "fields" : [ { + "name" : "droppedRecords" + }, { + "name" : "head" + } ] +}, { + "name" : "io.netty.util.concurrent.DefaultPromise", + "fields" : [ { + "name" : "result" + } ] +}, { + "name" : "io.netty.util.concurrent.SingleThreadEventExecutor", + "fields" : [ { + "name" : "state" + }, { + "name" : "threadProperties" + } ] }, { "name" : "io.netty.util.internal.NativeLibraryUtil", "methods" : [ { @@ -4709,287 +5296,1903 @@ }, { "name" : "java.lang.CharSequence", "allDeclaredFields" : true, - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.Character", - "fields" : [ { - "name" : "TYPE" - } ] -}, { - "name" : "java.lang.Class", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "methods" : [ { - "name" : "getAnnotatedSuperclass", - "parameterTypes" : [ ] + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] }, { - "name" : "getModule", - "parameterTypes" : [ ] + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] }, { - "name" : "getRecordComponents", - "parameterTypes" : [ ] + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] }, { - "name" : "isRecord", - "parameterTypes" : [ ] + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] }, { - "name" : "isSealed", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.ClassLoader", - "methods" : [ { - "name" : "registerAsParallelCapable", - "parameterTypes" : [ ] - } ], - "queriedMethods" : [ { - "name" : "getDefinedPackage", + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compare", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", "parameterTypes" : [ "java.lang.String" ] }, { - "name" : "getUnnamedModule", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.ClassValue" -}, { - "name" : "java.lang.Comparable", - "allDeclaredFields" : true, - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.Deprecated", - "queryAllDeclaredMethods" : true, - "queryAllPublicMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "forRemoval", - "parameterTypes" : [ ] + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] }, { - "name" : "since", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.Double", - "fields" : [ { - "name" : "TYPE" - } ] -}, { - "name" : "java.lang.Exception", - "allDeclaredFields" : true -}, { - "name" : "java.lang.Float", - "fields" : [ { - "name" : "TYPE" - } ], - "queriedMethods" : [ { - "name" : "", + "name" : "concat", "parameterTypes" : [ "java.lang.String" ] - } ] -}, { - "name" : "java.lang.IllegalArgumentException" -}, { - "name" : "java.lang.Integer", - "fields" : [ { - "name" : "TYPE" - } ], - "methods" : [ { - "name" : "", + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", "parameterTypes" : [ "java.lang.String" ] - } ], - "queriedMethods" : [ { - "name" : "toString", + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", "parameterTypes" : [ "int" ] } ] }, { - "name" : "java.lang.Iterable", - "queryAllDeclaredMethods" : true -}, { - "name" : "java.lang.Long", + "name" : "java.lang.Character", "fields" : [ { "name" : "TYPE" - } ], - "queriedMethods" : [ { - "name" : "toString", - "parameterTypes" : [ "long" ] } ] }, { - "name" : "java.lang.Module", + "name" : "java.lang.Class", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, "methods" : [ { - "name" : "getDescriptor", + "name" : "getAnnotatedSuperclass", "parameterTypes" : [ ] }, { - "name" : "isExported", - "parameterTypes" : [ "java.lang.String" ] - } ], - "queriedMethods" : [ { - "name" : "addExports", - "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] - }, { - "name" : "canRead", - "parameterTypes" : [ "java.lang.Module" ] + "name" : "getModule", + "parameterTypes" : [ ] }, { - "name" : "getClassLoader", + "name" : "getRecordComponents", "parameterTypes" : [ ] }, { - "name" : "getName", + "name" : "isRecord", "parameterTypes" : [ ] }, { - "name" : "getPackages", + "name" : "isSealed", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "getAnnotatedInterfaces", "parameterTypes" : [ ] }, { - "name" : "getResourceAsStream", - "parameterTypes" : [ "java.lang.String" ] + "name" : "getDeclaredMethod", + "parameterTypes" : [ "java.lang.String", "java.lang.Class[]" ] }, { - "name" : "isExported", - "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + "name" : "getMethod", + "parameterTypes" : [ "java.lang.String", "java.lang.Class[]" ] }, { - "name" : "isNamed", + "name" : "getNestHost", "parameterTypes" : [ ] }, { - "name" : "isOpen", - "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] - } ] -}, { - "name" : "java.lang.NullPointerException" -}, { - "name" : "java.lang.Object", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "", + "name" : "getNestMembers", "parameterTypes" : [ ] }, { - "name" : "toString", + "name" : "getPermittedSubclasses", "parameterTypes" : [ ] + }, { + "name" : "isNestmateOf", + "parameterTypes" : [ "java.lang.Class" ] } ] }, { - "name" : "java.lang.OutOfMemoryError" -}, { - "name" : "java.lang.ProcessHandle", + "name" : "java.lang.ClassLoader", "methods" : [ { - "name" : "current", - "parameterTypes" : [ ] + "name" : "getDefinedPackage", + "parameterTypes" : [ "java.lang.String" ] }, { - "name" : "pid", + "name" : "registerAsParallelCapable", "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.Runtime", - "methods" : [ { - "name" : "version", + } ], + "queriedMethods" : [ { + "name" : "getUnnamedModule", "parameterTypes" : [ ] } ] }, { - "name" : "java.lang.Runtime$Version", - "methods" : [ { - "name" : "feature", - "parameterTypes" : [ ] - } ] + "name" : "java.lang.ClassValue" }, { - "name" : "java.lang.RuntimeException" + "name" : "java.lang.Comparable", + "allDeclaredFields" : true, + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "concat", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", + "parameterTypes" : [ "int" ] + } ] }, { - "name" : "java.lang.RuntimePermission" + "name" : "java.lang.Deprecated", + "queryAllDeclaredMethods" : true, + "queryAllPublicMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "forRemoval", + "parameterTypes" : [ ] + }, { + "name" : "since", + "parameterTypes" : [ ] + } ] }, { - "name" : "java.lang.SecurityManager", + "name" : "java.lang.Double", + "fields" : [ { + "name" : "TYPE" + } ] +}, { + "name" : "java.lang.Exception", + "allDeclaredFields" : true +}, { + "name" : "java.lang.Float", + "fields" : [ { + "name" : "TYPE" + } ], "queriedMethods" : [ { - "name" : "checkPermission", - "parameterTypes" : [ "java.security.Permission" ] + "name" : "", + "parameterTypes" : [ "java.lang.String" ] } ] }, { - "name" : "java.lang.Short", + "name" : "java.lang.IllegalArgumentException" +}, { + "name" : "java.lang.Integer", "fields" : [ { "name" : "TYPE" + } ], + "methods" : [ { + "name" : "", + "parameterTypes" : [ "java.lang.String" ] + } ], + "queriedMethods" : [ { + "name" : "toString", + "parameterTypes" : [ "int" ] } ] }, { - "name" : "java.lang.StackTraceElement", - "queryAllPublicMethods" : true + "name" : "java.lang.Iterable", + "queryAllDeclaredMethods" : true }, { - "name" : "java.lang.StackWalker", + "name" : "java.lang.Long", + "fields" : [ { + "name" : "TYPE" + } ], + "queriedMethods" : [ { + "name" : "toString", + "parameterTypes" : [ "long" ] + } ] +}, { + "name" : "java.lang.Module", "methods" : [ { - "name" : "getInstance", - "parameterTypes" : [ "java.lang.StackWalker$Option" ] + "name" : "getDescriptor", + "parameterTypes" : [ ] + }, { + "name" : "isExported", + "parameterTypes" : [ "java.lang.String" ] + } ], + "queriedMethods" : [ { + "name" : "addExports", + "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + }, { + "name" : "canRead", + "parameterTypes" : [ "java.lang.Module" ] + }, { + "name" : "getClassLoader", + "parameterTypes" : [ ] + }, { + "name" : "getName", + "parameterTypes" : [ ] + }, { + "name" : "getPackages", + "parameterTypes" : [ ] + }, { + "name" : "getResourceAsStream", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "isExported", + "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + }, { + "name" : "isNamed", + "parameterTypes" : [ ] + }, { + "name" : "isOpen", + "parameterTypes" : [ "java.lang.String", "java.lang.Module" ] + } ] +}, { + "name" : "java.lang.NullPointerException" +}, { + "name" : "java.lang.Object", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + }, { + "name" : "toString", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.OutOfMemoryError" +}, { + "name" : "java.lang.ProcessHandle", + "methods" : [ { + "name" : "current", + "parameterTypes" : [ ] + }, { + "name" : "pid", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.Runtime", + "methods" : [ { + "name" : "version", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.Runtime$Version", + "methods" : [ { + "name" : "feature", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.RuntimeException" +}, { + "name" : "java.lang.RuntimePermission" +}, { + "name" : "java.lang.SecurityManager", + "queriedMethods" : [ { + "name" : "checkPermission", + "parameterTypes" : [ "java.security.Permission" ] + } ] +}, { + "name" : "java.lang.Short", + "fields" : [ { + "name" : "TYPE" + } ] +}, { + "name" : "java.lang.StackTraceElement", + "queryAllPublicMethods" : true +}, { + "name" : "java.lang.StackWalker", + "methods" : [ { + "name" : "getInstance", + "parameterTypes" : [ "java.lang.StackWalker$Option" ] + }, { + "name" : "walk", + "parameterTypes" : [ "java.util.function.Function" ] + } ] +}, { + "name" : "java.lang.StackWalker$Option" +}, { + "name" : "java.lang.StackWalker$StackFrame", + "methods" : [ { + "name" : "getDeclaringClass", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.String", + "allDeclaredFields" : true, + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "fields" : [ { + "name" : "TYPE" + } ], + "methods" : [ { + "name" : "", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + } ] +}, { + "name" : "java.lang.System", + "methods" : [ { + "name" : "getSecurityManager", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.Thread", + "fields" : [ { + "name" : "threadLocalRandomProbe" + } ] +}, { + "name" : "java.lang.Throwable", + "allDeclaredFields" : true, + "methods" : [ { + "name" : "getSuppressed", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "addSuppressed", + "parameterTypes" : [ "java.lang.Throwable" ] + } ] +}, { + "name" : "java.lang.Void", + "fields" : [ { + "name" : "TYPE" + } ] +}, { + "name" : "java.lang.annotation.Inherited", + "queryAllPublicMethods" : true +}, { + "name" : "java.lang.annotation.Repeatable", + "queryAllPublicMethods" : true, + "methods" : [ { + "name" : "value", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.annotation.Retention", + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true, + "methods" : [ { + "name" : "value", + "parameterTypes" : [ ] + } ] +}, { + "name" : "java.lang.annotation.RetentionPolicy" +}, { + "name" : "java.lang.annotation.Target", + "queryAllDeclaredMethods" : true, + "queryAllDeclaredConstructors" : true +}, { + "name" : "java.lang.constant.Constable", + "allDeclaredFields" : true, + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "concat", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", + "parameterTypes" : [ "int" ] + } ] +}, { + "name" : "java.lang.constant.ConstantDesc", + "allDeclaredFields" : true, + "queryAllPublicMethods" : true, + "queriedMethods" : [ { + "name" : "charAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "checkBoundsBeginEnd", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkBoundsOffCount", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "checkIndex", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "checkOffset", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "codePointAt", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointBefore", + "parameterTypes" : [ "int" ] + }, { + "name" : "codePointCount", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "compareTo", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "compareToIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "concat", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "contains", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.CharSequence" ] + }, { + "name" : "contentEquals", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "copyValueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "decode2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "decode3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "decode4", + "parameterTypes" : [ "int", "int", "int", "int" ] + }, { + "name" : "decodeASCII", + "parameterTypes" : [ "byte[]", "int", "char[]", "int", "int" ] + }, { + "name" : "decodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "int", "int", "byte[]", "int", "boolean" ] + }, { + "name" : "decodeWithDecoder", + "parameterTypes" : [ "java.nio.charset.CharsetDecoder", "char[]", "byte[]", "int", "int" ] + }, { + "name" : "encode", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encode8859_1", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeASCII", + "parameterTypes" : [ "byte", "byte[]" ] + }, { + "name" : "encodeUTF8", + "parameterTypes" : [ "byte", "byte[]", "boolean" ] + }, { + "name" : "encodeUTF8_UTF16", + "parameterTypes" : [ "byte[]", "boolean" ] + }, { + "name" : "encodeWithEncoder", + "parameterTypes" : [ "java.nio.charset.Charset", "byte", "byte[]", "boolean" ] + }, { + "name" : "endsWith", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "equals", + "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "equalsIgnoreCase", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "format", + "parameterTypes" : [ "java.util.Locale", "java.lang.String", "java.lang.Object[]" ] + }, { + "name" : "formatted", + "parameterTypes" : [ "java.lang.Object[]" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "int", "int", "byte[]", "int" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "java.nio.charset.Charset" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "byte" ] + }, { + "name" : "getBytes", + "parameterTypes" : [ "byte[]", "int", "int", "byte", "int" ] + }, { + "name" : "getBytesNoRepl", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesNoRepl1", + "parameterTypes" : [ "java.lang.String", "java.nio.charset.Charset" ] + }, { + "name" : "getBytesUTF8NoRepl", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "getChars", + "parameterTypes" : [ "int", "int", "char[]", "int" ] + }, { + "name" : "indent", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "indexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "isASCII", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "isMalformed3", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed3_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4", + "parameterTypes" : [ "int", "int", "int" ] + }, { + "name" : "isMalformed4_2", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "isMalformed4_3", + "parameterTypes" : [ "int" ] + }, { + "name" : "isNotContinuation", + "parameterTypes" : [ "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.AbstractStringBuilder", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuffer" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "java.lang.StringBuilder" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "byte" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "int", "int", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.lang.String" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "char[]", "int", "int", "java.lang.Void" ] + }, { + "name" : "java.lang.String", + "parameterTypes" : [ "int[]", "int", "int" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.Iterable" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence[]" ] + }, { + "name" : "join", + "parameterTypes" : [ "java.lang.String", "java.lang.String", "java.lang.String", "java.lang.String[]", "int" ] + }, { + "name" : "lambda$indent$0", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "lambda$indent$1", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lambda$indent$2", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lambda$stripIndent$3", + "parameterTypes" : [ "int", "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "lastIndexOf", + "parameterTypes" : [ "byte[]", "byte", "int", "java.lang.String", "int" ] + }, { + "name" : "lookupCharset", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "malformed3", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "malformed4", + "parameterTypes" : [ "byte[]", "int" ] + }, { + "name" : "matches", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "newStringNoRepl", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringNoRepl1", + "parameterTypes" : [ "byte[]", "java.nio.charset.Charset" ] + }, { + "name" : "newStringUTF8NoRepl", + "parameterTypes" : [ "byte[]", "int", "int" ] + }, { + "name" : "nonSyncContentEquals", + "parameterTypes" : [ "java.lang.AbstractStringBuilder" ] + }, { + "name" : "offsetByCodePoints", + "parameterTypes" : [ "int", "int" ] }, { - "name" : "walk", - "parameterTypes" : [ "java.util.function.Function" ] - } ] -}, { - "name" : "java.lang.StackWalker$Option" -}, { - "name" : "java.lang.StackWalker$StackFrame", - "methods" : [ { - "name" : "getDeclaringClass", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.String", - "allDeclaredFields" : true, - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "", + "name" : "outdent", + "parameterTypes" : [ "java.util.List" ] + }, { + "name" : "rangeCheck", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "int", "java.lang.String", "int", "int" ] + }, { + "name" : "regionMatches", + "parameterTypes" : [ "boolean", "int", "java.lang.String", "int", "int" ] + }, { + "name" : "repeat", + "parameterTypes" : [ "int" ] + }, { + "name" : "replace", + "parameterTypes" : [ "char", "char" ] + }, { + "name" : "replace", + "parameterTypes" : [ "java.lang.CharSequence", "java.lang.CharSequence" ] + }, { + "name" : "replaceAll", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "replaceFirst", + "parameterTypes" : [ "java.lang.String", "java.lang.String" ] + }, { + "name" : "resolveConstantDesc", + "parameterTypes" : [ "java.lang.invoke.MethodHandles$Lookup" ] + }, { + "name" : "safeTrim", + "parameterTypes" : [ "byte[]", "int", "boolean" ] + }, { + "name" : "scale", + "parameterTypes" : [ "int", "float" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "split", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "startsWith", "parameterTypes" : [ "java.lang.String" ] + }, { + "name" : "startsWith", + "parameterTypes" : [ "java.lang.String", "int" ] + }, { + "name" : "subSequence", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int" ] + }, { + "name" : "substring", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "int", "int" ] + }, { + "name" : "throwMalformed", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "int" ] + }, { + "name" : "throwUnmappable", + "parameterTypes" : [ "byte[]" ] + }, { + "name" : "toLowerCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "toUpperCase", + "parameterTypes" : [ "java.util.Locale" ] + }, { + "name" : "transform", + "parameterTypes" : [ "java.util.function.Function" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "double" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "float" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "int" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "long" ] }, { "name" : "valueOf", "parameterTypes" : [ "java.lang.Object" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "boolean" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]" ] + }, { + "name" : "valueOf", + "parameterTypes" : [ "char[]", "int", "int" ] + }, { + "name" : "valueOfCodePoint", + "parameterTypes" : [ "int" ] } ] -}, { - "name" : "java.lang.System", - "methods" : [ { - "name" : "getSecurityManager", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.Thread", - "fields" : [ { - "name" : "threadLocalRandomProbe" - } ] -}, { - "name" : "java.lang.Throwable", - "allDeclaredFields" : true, - "methods" : [ { - "name" : "getSuppressed", - "parameterTypes" : [ ] - } ], - "queriedMethods" : [ { - "name" : "addSuppressed", - "parameterTypes" : [ "java.lang.Throwable" ] - } ] -}, { - "name" : "java.lang.Void", - "fields" : [ { - "name" : "TYPE" - } ] -}, { - "name" : "java.lang.annotation.Inherited", - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.annotation.Repeatable", - "queryAllPublicMethods" : true, - "methods" : [ { - "name" : "value", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.annotation.Retention", - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true, - "methods" : [ { - "name" : "value", - "parameterTypes" : [ ] - } ] -}, { - "name" : "java.lang.annotation.RetentionPolicy" -}, { - "name" : "java.lang.annotation.Target", - "queryAllDeclaredMethods" : true, - "queryAllDeclaredConstructors" : true -}, { - "name" : "java.lang.constant.Constable", - "allDeclaredFields" : true, - "queryAllPublicMethods" : true -}, { - "name" : "java.lang.constant.ConstantDesc", - "allDeclaredFields" : true, - "queryAllPublicMethods" : true }, { "name" : "java.lang.invoke.MethodHandle", "queriedMethods" : [ { @@ -5211,6 +7414,12 @@ "name" : "", "parameterTypes" : [ "java.lang.String", "java.lang.String" ] } ] +}, { + "name" : "java.net.UnixDomainSocketAddress", + "methods" : [ { + "name" : "of", + "parameterTypes" : [ "java.lang.String" ] + } ] }, { "name" : "java.nio.Bits", "fields" : [ { @@ -5382,12 +7591,34 @@ "fields" : [ { "name" : "e" } ] +}, { + "name" : "java.util.concurrent.ForkJoinTask", + "fields" : [ { + "name" : "aux" + }, { + "name" : "status" + } ] }, { "name" : "java.util.concurrent.ScheduledThreadPoolExecutor", "queriedMethods" : [ { "name" : "setRemoveOnCancelPolicy", "parameterTypes" : [ "boolean" ] } ] +}, { + "name" : "java.util.concurrent.atomic.AtomicBoolean", + "fields" : [ { + "name" : "value" + } ] +}, { + "name" : "java.util.concurrent.atomic.AtomicMarkableReference", + "fields" : [ { + "name" : "pair" + } ] +}, { + "name" : "java.util.concurrent.atomic.AtomicReference", + "fields" : [ { + "name" : "value" + } ] }, { "name" : "java.util.concurrent.atomic.LongAdder", "queryAllPublicConstructors" : true, @@ -5402,6 +7633,13 @@ "name" : "sum", "parameterTypes" : [ ] } ] +}, { + "name" : "java.util.concurrent.atomic.Striped64", + "fields" : [ { + "name" : "base" + }, { + "name" : "cellsBusy" + } ] }, { "name" : "java.util.logging.LogManager", "methods" : [ { @@ -5551,6 +7789,11 @@ } ] }, { "name" : "kotlin.Nothing" +}, { + "name" : "kotlin.SafePublicationLazyImpl", + "fields" : [ { + "name" : "_value" + } ] }, { "name" : "kotlin.String" }, { @@ -5587,6 +7830,11 @@ "fields" : [ { "name" : "label" } ] +}, { + "name" : "kotlin.reflect.full.KCallables$callSuspendBy$1", + "fields" : [ { + "name" : "label" + } ] }, { "name" : "kotlin.reflect.full.KClasses" }, { @@ -5598,6 +7846,81 @@ }, { "name" : "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter", "allPublicFields" : true +}, { + "name" : "kotlinx.coroutines.CancellableContinuationImpl", + "fields" : [ { + "name" : "_decisionAndIndex$volatile" + }, { + "name" : "_parentHandle$volatile" + }, { + "name" : "_state$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.CancelledContinuation", + "fields" : [ { + "name" : "_resumed$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.CompletedExceptionally", + "fields" : [ { + "name" : "_handled$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.DispatchedCoroutine", + "fields" : [ { + "name" : "_decision$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.EventLoopImplBase", + "fields" : [ { + "name" : "_delayed$volatile" + }, { + "name" : "_isCompleted$volatile" + }, { + "name" : "_queue$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.InvokeOnCancelling", + "fields" : [ { + "name" : "_invoked$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.JobSupport", + "fields" : [ { + "name" : "_parentHandle$volatile" + }, { + "name" : "_state$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.JobSupport$Finishing", + "fields" : [ { + "name" : "_exceptionsHolder$volatile" + }, { + "name" : "_isCompleting$volatile" + }, { + "name" : "_rootCause$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.channels.BufferedChannel", + "fields" : [ { + "name" : "_closeCause$volatile" + }, { + "name" : "bufferEnd$volatile" + }, { + "name" : "bufferEndSegment$volatile" + }, { + "name" : "closeHandler$volatile" + }, { + "name" : "completedExpandBuffersAndPauseFlag$volatile" + }, { + "name" : "receiveSegment$volatile" + }, { + "name" : "receivers$volatile" + }, { + "name" : "sendSegment$volatile" + }, { + "name" : "sendersAndCloseStatus$volatile" + } ] }, { "name" : "kotlinx.coroutines.flow.AbstractFlow$collect$1", "fields" : [ { @@ -5606,7 +7929,103 @@ }, { "name" : "kotlinx.coroutines.flow.Flow" }, { - "name" : "kotlinx.coroutines.internal.StackTraceRecoveryKt" + "name" : "kotlinx.coroutines.internal.AtomicOp", + "fields" : [ { + "name" : "_consensus$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.ConcurrentLinkedListNode", + "fields" : [ { + "name" : "_next$volatile" + }, { + "name" : "_prev$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.DispatchedContinuation", + "fields" : [ { + "name" : "_reusableCancellableContinuation$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LimitedDispatcher", + "fields" : [ { + "name" : "runningWorkers$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LockFreeLinkedListNode", + "fields" : [ { + "name" : "_next$volatile" + }, { + "name" : "_prev$volatile" + }, { + "name" : "_removedRef$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LockFreeTaskQueue", + "fields" : [ { + "name" : "_cur$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.LockFreeTaskQueueCore", + "fields" : [ { + "name" : "_next$volatile" + }, { + "name" : "_state$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.Segment", + "fields" : [ { + "name" : "cleanedAndPointers$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.internal.StackTraceRecoveryKt" +}, { + "name" : "kotlinx.coroutines.internal.ThreadSafeHeap", + "fields" : [ { + "name" : "_size$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.scheduling.CoroutineScheduler", + "fields" : [ { + "name" : "_isTerminated$volatile" + }, { + "name" : "controlState$volatile" + }, { + "name" : "parkedWorkersStack$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker", + "fields" : [ { + "name" : "workerCtl$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.scheduling.WorkQueue", + "fields" : [ { + "name" : "blockingTasksInBuffer$volatile" + }, { + "name" : "consumerIndex$volatile" + }, { + "name" : "lastScheduledTask$volatile" + }, { + "name" : "producerIndex$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.sync.MutexImpl", + "fields" : [ { + "name" : "owner$volatile" + } ] +}, { + "name" : "kotlinx.coroutines.sync.SemaphoreImpl", + "fields" : [ { + "name" : "_availablePermits$volatile" + }, { + "name" : "deqIdx$volatile" + }, { + "name" : "enqIdx$volatile" + }, { + "name" : "head$volatile" + }, { + "name" : "tail$volatile" + } ] }, { "name" : "net.bytebuddy.description.method.MethodDescription$InDefinedShape$AbstractBase$Executable", "queryAllPublicMethods" : true @@ -5803,8 +8222,18 @@ }, { "name" : "net.bytebuddy.utility.JavaModule$Resolver", "queryAllPublicMethods" : true +}, { + "name" : "org.HdrHistogram.AbstractHistogram", + "fields" : [ { + "name" : "maxValue" + }, { + "name" : "minNonZeroValue" + } ] }, { "name" : "org.HdrHistogram.ConcurrentHistogram", + "fields" : [ { + "name" : "totalCount" + } ], "methods" : [ { "name" : "", "parameterTypes" : [ "long", "long", "int" ] @@ -5818,6 +8247,15 @@ "name" : "", "parameterTypes" : [ "long", "long", "int" ] } ] +}, { + "name" : "org.HdrHistogram.WriterReaderPhaser", + "fields" : [ { + "name" : "evenEndEpoch" + }, { + "name" : "oddEndEpoch" + }, { + "name" : "startEpoch" + } ] }, { "name" : "org.apache.curator.shaded.com.google.common.collect.ImmutableCollection", "allDeclaredFields" : true, @@ -6014,6 +8452,9 @@ }, { "name" : "checkObjectEnd", "parameterTypes" : [ "com.fasterxml.jackson.core.JsonToken" ] + }, { + "name" : "fieldNamesEqual", + "parameterTypes" : [ "com.fasterxml.jackson.core.JsonParser", "java.lang.String", "java.lang.String" ] }, { "name" : "mapUnknownEnumValue", "parameterTypes" : [ "int" ] @@ -6164,6 +8605,9 @@ "name" : "org.curioswitch.common.protobuf.json.TypeSpecificMarshaller$org$curioswitch$common$protobuf$json$TypeSpecificMarshaller$buildOrFindMarshaller$ByteBuddy", "allDeclaredFields" : true, "methods" : [ { + "name" : "", + "parameterTypes" : [ "com.google.api.HttpBody" ] + }, { "name" : "", "parameterTypes" : [ "com.google.rpc.BadRequest$FieldViolation" ] }, { @@ -6259,6 +8703,12 @@ }, { "name" : "", "parameterTypes" : [ "testing.grpc.Messages$TestMessage" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$ArbitraryHttpWrappedRequest" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$ArbitraryHttpWrappedResponse" ] }, { "name" : "", "parameterTypes" : [ "testing.grpc.Transcoding$EchoAnyRequest" ] @@ -6307,6 +8757,12 @@ }, { "name" : "", "parameterTypes" : [ "testing.grpc.Transcoding$EchoTimestampAndDurationResponse" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$EchoTimestampRequest" ] + }, { + "name" : "", + "parameterTypes" : [ "testing.grpc.Transcoding$EchoTimestampResponse" ] }, { "name" : "", "parameterTypes" : [ "testing.grpc.Transcoding$EchoValueRequest" ] @@ -12681,8 +15137,317 @@ "name" : "log", "parameterTypes" : [ "org.slf4j.Marker", "java.lang.String", "int", "java.lang.String", "java.lang.Object[]", "java.lang.Throwable" ] } ] +}, { + "name" : "reactor.core.publisher.FluxArray$ArraySubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxAutoConnect", + "fields" : [ { + "name" : "remaining" + } ] +}, { + "name" : "reactor.core.publisher.FluxCombineLatest$CombineLatestCoordinator", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxCombineLatest$CombineLatestInner", + "fields" : [ { + "name" : "s" + } ] +}, { + "name" : "reactor.core.publisher.FluxConcatArray$ConcatArrayDelayErrorSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxConcatArray$ConcatArraySubscriber", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxConcatMapNoPrefetch$FluxConcatMapNoPrefetchSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.FluxCreate$BaseSink", + "fields" : [ { + "name" : "disposable" + }, { + "name" : "requestConsumer" + }, { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxCreate$BufferAsyncSink", + "fields" : [ { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxCreate$SerializedFluxSink", + "fields" : [ { + "name" : "error" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxFirstWithSignal$RaceCoordinator", + "fields" : [ { + "name" : "winner" + } ] +}, { + "name" : "reactor.core.publisher.FluxGenerate$GenerateSubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxInterval$IntervalRunnable", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxIterable$IterableSubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxLimitRequest$FluxLimitRequestSubscriber", + "fields" : [ { + "name" : "requestRemaining" + } ] +}, { + "name" : "reactor.core.publisher.FluxMergeSequential$MergeSequentialInner", + "fields" : [ { + "name" : "subscription" + } ] +}, { + "name" : "reactor.core.publisher.FluxMergeSequential$MergeSequentialMain", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublish", + "fields" : [ { + "name" : "connection" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublish$PubSubInner", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublish$PublishSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "state" + }, { + "name" : "subscribers" + } ] +}, { + "name" : "reactor.core.publisher.FluxPublishOn$PublishOnSubscriber", + "fields" : [ { + "name" : "discardGuard" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.FluxSwitchMapNoPrefetch$SwitchMapMain", + "fields" : [ { + "name" : "requested" + }, { + "name" : "state" + }, { + "name" : "throwable" + } ] +}, { + "name" : "reactor.core.publisher.FluxZip$ZipScalarCoordinator", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.LambdaSubscriber", + "fields" : [ { + "name" : "subscription" + } ] }, { "name" : "reactor.core.publisher.Mono" +}, { + "name" : "reactor.core.publisher.MonoCallable$MonoCallableSubscription", + "fields" : [ { + "name" : "requestedOnce" + } ] +}, { + "name" : "reactor.core.publisher.MonoCompletionStage$MonoCompletionStageSubscription", + "fields" : [ { + "name" : "requestedOnce" + } ] +}, { + "name" : "reactor.core.publisher.MonoCreate$DefaultMonoSink", + "fields" : [ { + "name" : "disposable" + }, { + "name" : "requestConsumer" + }, { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoDelay$MonoDelayRunnable", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoNext$NextSubscriber", + "fields" : [ { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.MonoPublishOn$PublishOnSubscriber", + "fields" : [ { + "name" : "future" + }, { + "name" : "value" + } ] +}, { + "name" : "reactor.core.publisher.MonoSupplier$MonoSupplierSubscription", + "fields" : [ { + "name" : "requestedOnce" + } ] +}, { + "name" : "reactor.core.publisher.MonoZip$ZipCoordinator", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.publisher.MonoZip$ZipInner", + "fields" : [ { + "name" : "s" + } ] +}, { + "name" : "reactor.core.publisher.Operators$DeferredSubscription", + "fields" : [ { + "name" : "requested" + } ] +}, { + "name" : "reactor.core.publisher.Operators$MultiSubscriptionSubscriber", + "fields" : [ { + "name" : "missedProduced" + }, { + "name" : "missedRequested" + }, { + "name" : "missedSubscription" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.publisher.Operators$ScalarSubscription", + "fields" : [ { + "name" : "once" + } ] +}, { + "name" : "reactor.core.publisher.StrictSubscriber", + "fields" : [ { + "name" : "error" + }, { + "name" : "requested" + }, { + "name" : "s" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.core.scheduler.ParallelScheduler", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.scheduler.PeriodicWorkerTask", + "fields" : [ { + "name" : "future" + }, { + "name" : "parent" + } ] +}, { + "name" : "reactor.core.scheduler.SchedulerTask", + "fields" : [ { + "name" : "future" + }, { + "name" : "parent" + } ] +}, { + "name" : "reactor.core.scheduler.SingleScheduler", + "fields" : [ { + "name" : "state" + } ] +}, { + "name" : "reactor.core.scheduler.WorkerTask", + "fields" : [ { + "name" : "future" + }, { + "name" : "parent" + }, { + "name" : "thread" + } ] +}, { + "name" : "reactor.test.DefaultStepVerifierBuilder$DefaultVerifySubscriber", + "fields" : [ { + "name" : "errors" + }, { + "name" : "requested" + }, { + "name" : "wip" + } ] +}, { + "name" : "reactor.util.concurrent.MpscLinkedQueue", + "fields" : [ { + "name" : "consumerNode" + }, { + "name" : "producerNode" + } ] +}, { + "name" : "reactor.util.concurrent.MpscLinkedQueue$LinkedQueueNode", + "fields" : [ { + "name" : "next" + } ] +}, { + "name" : "reactor.util.concurrent.SpscArrayQueueConsumer", + "fields" : [ { + "name" : "consumerIndex" + } ] +}, { + "name" : "reactor.util.concurrent.SpscArrayQueueProducer", + "fields" : [ { + "name" : "producerIndex" + } ] +}, { + "name" : "reactor.util.concurrent.SpscLinkedArrayQueue", + "fields" : [ { + "name" : "consumerIndex" + }, { + "name" : "producerIndex" + } ] }, { "name" : "retrofit2.http.DELETE", "methods" : [ { @@ -12725,10 +15490,54 @@ "name" : "value", "parameterTypes" : [ ] } ] +}, { + "name" : "scala.Equals", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] +}, { + "name" : "scala.Product", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] +}, { + "name" : "scala.Serializable", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] +}, { + "name" : "scala.collection.concurrent.CNodeBase", + "fields" : [ { + "name" : "csize" + } ] +}, { + "name" : "scala.collection.concurrent.INodeBase", + "fields" : [ { + "name" : "mainnode" + } ] +}, { + "name" : "scala.collection.concurrent.MainNode", + "fields" : [ { + "name" : "prev" + } ] +}, { + "name" : "scala.collection.concurrent.TrieMap", + "fields" : [ { + "name" : "root" + } ] }, { "name" : "scala.concurrent.ExecutionContext" }, { "name" : "scala.concurrent.Future" +}, { + "name" : "scalapb.GeneratedMessage", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] }, { "name" : "scalapb.descriptors.Descriptor", "fields" : [ { @@ -12740,6 +15549,12 @@ }, { "name" : "sortedFields$lzy1" } ] +}, { + "name" : "scalapb.lenses.Updatable", + "queriedMethods" : [ { + "name" : "testing.scalapb.messages.SimpleRequest", + "parameterTypes" : [ "java.lang.String", "int", "scalapb.UnknownFieldSet" ] + } ] }, { "name" : "sun.management.ClassLoadingImpl", "queryAllPublicConstructors" : true @@ -12932,6 +15747,16 @@ "name" : "", "parameterTypes" : [ "java.security.SecureRandomParameters" ] } ] +}, { + "name" : "sun.security.provider.NativePRNG$NonBlocking", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ], + "queriedMethods" : [ { + "name" : "", + "parameterTypes" : [ "java.security.SecureRandomParameters" ] + } ] }, { "name" : "sun.security.provider.SHA", "methods" : [ { @@ -13043,6 +15868,12 @@ "fields" : [ { "name" : "trustManager" } ] +}, { + "name" : "sun.security.ssl.SSLContextImpl$DefaultSSLContext", + "methods" : [ { + "name" : "", + "parameterTypes" : [ ] + } ] }, { "name" : "sun.security.ssl.SSLContextImpl$TLSContext", "fields" : [ { diff --git a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json index 8d374f928bd..406d2ff5dd9 100644 --- a/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json +++ b/core/src/main/resources/META-INF/native-image/com.linecorp.armeria/armeria/resource-config.json @@ -1,6 +1,16 @@ { "resources" : { "includes" : [ { + "pattern" : "\\QMETA-INF/services/ch.qos.logback.classic.spi.Configurator\\E" + }, { + "pattern" : "\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E" + }, { + "pattern" : "\\Qcom/linecorp/armeria/internal/common/thrift/thrift-options.properties\\E" + }, { + "pattern" : "\\Qlogback.scmo\\E" + }, { + "pattern" : "\\Qprometheus.properties\\E" + }, { "pattern" : "^META-INF/[^/]+\\.versions\\.properties$" }, { "pattern" : "^META-INF/armeria/grpc/.*$" diff --git a/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt b/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt index c8c72ba2edc..d9451e3585d 100644 --- a/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt +++ b/core/src/main/resources/com/linecorp/armeria/public_suffixes.txt @@ -7,6 +7,7 @@ !city.yokohama.jp !www.ck *.001.test.code-builder-stg.platform.salesforce.com +*.0e.vc *.0emm.com *.advisor.ws *.af-south-1.airflow.amazonaws.com @@ -169,7 +170,6 @@ 0.bg 001www.com 0am.jp -0e.vc 0g0.jp 0j0.jp 0t0.jp @@ -267,6 +267,7 @@ ac.ci ac.cn ac.cr ac.cy +ac.eg ac.fj ac.gn ac.gov.br @@ -346,7 +347,6 @@ aem.live aem.page aero aero.mv -aero.tt aerobatic.aero aeroclub.aero aerodrome.aero @@ -371,6 +371,7 @@ agency agents.aero agr.br agrar.hu +agri.jo agric.za agrigento.it agro.bj @@ -381,6 +382,7 @@ ah.cn ah.no ai ai.in +ai.jo ai.vn aibetsu.hokkaido.jp aichi.jp @@ -612,7 +614,6 @@ art.pl art.sn arte arte.bo -arts.co arts.nf arts.ro arts.ve @@ -883,7 +884,6 @@ bbt bbva bc.ca bcg -bci.dnstrace.pro bcn bd.se be @@ -916,7 +916,6 @@ bestbuy bet bet.ar bet.br -betainabox.com better-than.tv bf bg @@ -1538,6 +1537,7 @@ co.bj co.bn co.business co.bw +co.bz co.ca co.ci co.cl @@ -1546,6 +1546,7 @@ co.com co.cr co.cz co.dk +co.dm co.education co.events co.financial @@ -1557,6 +1558,7 @@ co.id co.il co.im co.in +co.io co.ir co.it co.je @@ -1681,7 +1683,6 @@ com.im com.in com.io com.iq -com.is com.jo com.kg com.ki @@ -1800,10 +1801,8 @@ coop.mv coop.mw coop.py coop.rw -coop.tt cooperativa.bo copro.uk -corpnet.work corsica cosenza.it couchpotatofries.org @@ -1918,6 +1917,7 @@ dazaifu.fukuoka.jp dc.us dclk dd-dns.de +ddns-ip.net ddns.me ddns.net ddnsfree.com @@ -2231,8 +2231,8 @@ edu.hk edu.hn edu.ht edu.in +edu.io edu.iq -edu.is edu.it edu.jo edu.kg @@ -2459,6 +2459,7 @@ enebakk.no energy enf.br eng.br +eng.jo eng.pro engerdal.no engine.aero @@ -2662,7 +2663,6 @@ firewall-gateway.com firewall-gateway.de firewall-gateway.net firewalledreplit.co -firm.co firm.dk firm.ht firm.in @@ -2708,6 +2708,7 @@ fly.dev fm fm.br fm.it +fm.jo fm.no fnc.fr-par.scw.cloud fnd.br @@ -3149,7 +3150,6 @@ gov.cl gov.cm gov.cn gov.co -gov.cu gov.cx gov.cy gov.dm @@ -3172,9 +3172,9 @@ gov.hk gov.ie gov.il gov.in +gov.io gov.iq gov.ir -gov.is gov.it gov.jo gov.kg @@ -3264,6 +3264,7 @@ gr.com gr.eu.org gr.it gr.jp +grafana-dev.net grainger grajewo.pl gran.no @@ -3471,6 +3472,8 @@ herokussl.com heroy.more-og-romsdal.no heroy.nordland.no heteml.net +heyflow.page +heyflow.site hf.space hi.cn hi.us @@ -3586,6 +3589,7 @@ holmestrand.no holtalen.no holy.jp home-webserver.de +home.arpa home.dyndns.org homebuilt.aero homedepot @@ -3696,6 +3700,7 @@ icu icurus.jp id id.au +id.cv id.firewalledreplit.co id.forgerock.io id.ir @@ -3817,14 +3822,13 @@ inf.ua infiniti info info.at -info.au info.az info.bb info.bj info.bo -info.co info.cx info.ec +info.eg info.et info.fj info.gu @@ -3866,11 +3870,9 @@ int.ar int.az int.bo int.ci -int.co int.cv int.eu.org int.in -int.is int.la int.lk int.mv @@ -3879,7 +3881,6 @@ int.ni int.pt int.ru int.tj -int.tt int.ve int.vn international @@ -3892,9 +3893,11 @@ investments inzai.chiba.jp io io.in +io.noc.ruhr-uni-bochum.de io.vn iobb.net iopsys.se +ip-ddns.com ip-dynamic.org ip.linodeusercontent.com ip6.arpa @@ -4126,7 +4129,6 @@ jnj jo joboji.iwate.jp jobs -jobs.tt joburg joetsu.niigata.jp jogasz.hu @@ -4853,6 +4855,7 @@ locker locus lodi.it lodingen.no +lodz.pl log.br loginline.app loginline.dev @@ -5043,13 +5046,12 @@ mc.it mcdir.me mcdir.ru mckinsey -mcpe.me mcpre.ru md -md.ci md.us me me-south-1.elasticbeanstalk.com +me.eg me.eu.org me.in me.it @@ -5163,12 +5165,12 @@ mil.do mil.ec mil.eg mil.fj -mil.ge mil.gh mil.gt mil.hn mil.id mil.in +mil.io mil.iq mil.jo mil.kg @@ -5198,6 +5200,7 @@ mil.tj mil.tm mil.to mil.tr +mil.tt mil.tw mil.tz mil.uy @@ -5325,7 +5328,6 @@ mobi mobi.gp mobi.ke mobi.ng -mobi.tt mobi.tz mobile mochizuki.nagano.jp @@ -5437,7 +5439,6 @@ museum museum.mv museum.no museum.om -museum.tt music musica.ar musica.bo @@ -5599,7 +5600,6 @@ name.eg name.et name.fj name.hr -name.jo name.mk name.mv name.my @@ -5711,6 +5711,7 @@ net.cm net.cn net.co net.cu +net.cv net.cw net.cy net.dm @@ -5738,9 +5739,9 @@ net.id net.il net.im net.in +net.io net.iq net.ir -net.is net.je net.jo net.kg @@ -5954,11 +5955,11 @@ nohost.me noip.me noip.us nokia -nom.ad nom.ag nom.co nom.es nom.fr +nom.io nom.km nom.mg nom.nc @@ -5966,7 +5967,6 @@ nom.ni nom.pa nom.pe nom.pl -nom.re nom.ro nom.tm nom.ve @@ -6096,6 +6096,7 @@ nz.eu.org o.bg o.se o0o0.jp +o365.cloud.nospamproxy.com oamishirasato.chiba.jp oarai.ibaraki.jp obama.fukui.jp @@ -6246,7 +6247,6 @@ ono.hyogo.jp onojo.fukuoka.jp onomichi.hiroshima.jp onporter.run -onred.one onrender.com onthewifi.com onza.mythic-beasts.com @@ -6344,9 +6344,9 @@ org.hu org.il org.im org.in +org.io org.iq org.ir -org.is org.je org.jo org.kg @@ -6538,9 +6538,11 @@ paas.beebyte.io paas.datacenter.fi paas.hosted-by-previder.com paas.massivegrid.com +pabianice.pl padova.it padua.it page +pages-research.it.hs-heilbronn.de pages.dev pages.gay pages.it.hs-heilbronn.de @@ -6592,9 +6594,9 @@ peewee.jp penne.jp penza.su pepper.jp +per.jo per.la per.nf -per.sg perma.jp perso.ht perso.sn @@ -6617,6 +6619,7 @@ pharmacien.fr pharmaciens.km pharmacy phd +phd.jo philips phone photo @@ -6675,6 +6678,7 @@ plesk.page pleskns.com pley.games plo.ps +plock.pl plumbing plurinacional.bo plus @@ -6746,7 +6750,6 @@ press.aero press.cy press.ma press.se -presse.ci presse.km presse.ml preview.csb.app @@ -6807,6 +6810,7 @@ pu.it pub pub.instances.scw.cloud pub.sa +publ.cv publ.pt public-inquiry.uk pubtls.org @@ -6909,7 +6913,6 @@ realtor realty rebun.hokkaido.jp rec.br -rec.co rec.nf rec.ro rec.ve @@ -7060,8 +7063,10 @@ ru ru.com ru.eu.org ru.net +rub.de rugby ruhr +ruhr-uni-bochum.de rulez.jp run runcontainers.dev @@ -7546,6 +7551,7 @@ schmidt schokokeks.net scholarships school +school.ge school.nz school.za schoolbus.jp @@ -7794,6 +7800,7 @@ sicilia.it sicily.it siellak.no siena.it +sieradz.pl sigdal.no siiites.com siljan.no @@ -7827,6 +7834,7 @@ skedsmokorset.no ski ski.no skien.no +skierniewice.pl skierva.no skin skiptvet.no @@ -7934,6 +7942,7 @@ sphinx.mythic-beasts.com spjelkavik.no spock.replit.dev sport +sport.eg sport.hu spot spydeberg.no @@ -7962,7 +7971,6 @@ stackit.zone stada stage.nodeart.io staging.expo.app -staging.onred.one staging.replit.dev stalowa-wola.pl stange.no @@ -8236,6 +8244,7 @@ tateyama.toyama.jp tatsuno.hyogo.jp tatsuno.nagano.jp tattoo +taveusercontent.com tawaramoto.nara.jp tax taxi @@ -8332,7 +8341,6 @@ tm.fr tm.hu tm.km tm.mc -tm.mg tm.no tm.pl tm.ro @@ -8481,7 +8489,6 @@ trapani.it travel travel.in travel.pl -travel.tt travelers travelersinsurance travinh.vn @@ -8583,9 +8590,11 @@ tv tv.bb tv.bo tv.br +tv.eg tv.im tv.in tv.it +tv.jo tv.sd tv.tr tv.tz @@ -8679,7 +8688,6 @@ uol uonuma.niigata.jp uozu.toyama.jp up.in -upli.io upow.gov.pl upper.jp uppo.gov.pl @@ -8971,7 +8979,6 @@ weather weatherchannel web.app web.bo -web.co web.do web.gu web.id @@ -9255,7 +9262,6 @@ xn--clchc0ea0b2g2a9gcd xn--czr694b xn--czrs0t xn--czru2d -xn--czrw28b.tw xn--d1acj3b xn--d1alf xn--d1at.xn--90a3ac @@ -9556,7 +9562,6 @@ xn--trna-woa.no xn--troms-zua.no xn--tysvr-vra.no xn--uc0atv.hk -xn--uc0atv.tw xn--uc0atv.xn--j6w193g xn--uc0ay4a.hk xn--uist22h.jp @@ -9601,7 +9606,6 @@ xn--ygarden-p1a.no xn--ygbi2ammx xn--ystre-slidre-ujb.no xn--zbx025d.jp -xn--zf0ao64a.tw xn--zf0avx.hk xn--zfr164b xnbay.com @@ -9752,6 +9756,7 @@ zarow.pl zeabur.app zentsuji.kagawa.jp zero +zgierz.pl zgora.pl zgorzelec.pl zhitomir.ua diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java new file mode 100644 index 00000000000..3763819d982 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderBuilderTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; + +class ClientTlsProviderBuilderTest { + + @Test + void testBuild() { + assertThatThrownBy(() -> { + TlsProvider.builder() + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No TLS key pair is set."); + } + + @Test + void testMapping() { + final TlsKeyPair exactKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair wildcardKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair defaultKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair barKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsKeyPair barWildKeyPair = TlsKeyPair.ofSelfSigned(); + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(defaultKeyPair) + .keyPair("example.com", exactKeyPair) + .keyPair("*.foo.com", wildcardKeyPair) + .keyPair("*.bar.com", barWildKeyPair) + .keyPair("bar.com", barKeyPair) + .build(); + assertThat(tlsProvider.keyPair("any.com")).isEqualTo(defaultKeyPair); + // Exact match + assertThat(tlsProvider.keyPair("example.com")).isEqualTo(exactKeyPair); + // Wildcard match + assertThat(tlsProvider.keyPair("bar.foo.com")).isEqualTo(wildcardKeyPair); + + // Not a wildcard match + assertThat(tlsProvider.keyPair("foo.com")).isEqualTo(defaultKeyPair); + // No nested wildcard support + assertThat(tlsProvider.keyPair("baz.bar.foo.com")).isEqualTo(defaultKeyPair); + + assertThat(tlsProvider.keyPair("bar.com")).isEqualTo(barKeyPair); + assertThat(tlsProvider.keyPair("foo.bar.com")).isEqualTo(barWildKeyPair); + assertThat(tlsProvider.keyPair("foo.foo.bar.com")).isEqualTo(defaultKeyPair); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java new file mode 100644 index 00000000000..922e764488b --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/ClientTlsProviderTest.java @@ -0,0 +1,311 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.metric.MoreMeters; +import com.linecorp.armeria.internal.common.util.CertificateUtil; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.netty.handler.ssl.ClientAuth; + +class ClientTlsProviderTest { + + @RegisterExtension + static final SelfSignedCertificateExtension server0DefaultCert = new SelfSignedCertificateExtension(); + @RegisterExtension + static final SelfSignedCertificateExtension server0FooCert = new SelfSignedCertificateExtension( + "foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension server0SubFooCert = new SelfSignedCertificateExtension( + "sub.foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension server1DefaultCert = new SelfSignedCertificateExtension(); + @RegisterExtension + static final SelfSignedCertificateExtension server1BarCert = new SelfSignedCertificateExtension("bar.com"); + @RegisterExtension + static final SelfSignedCertificateExtension clientFooCert = new SelfSignedCertificateExtension("foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension clientSubFooCert = + new SelfSignedCertificateExtension("sub.foo.com"); + @RegisterExtension + static final SelfSignedCertificateExtension clientBarCert = new SelfSignedCertificateExtension("bar.com"); + + @RegisterExtension + static final ServerExtension server0 = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(server0DefaultCert.tlsKeyPair()) + .keyPair("foo.com", server0FooCert.tlsKeyPair()) + .keyPair("*.foo.com", server0SubFooCert.tlsKeyPair()) + .trustedCertificates(clientFooCert.certificate(), clientSubFooCert.certificate()) + .build(); + + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0) + .tlsProvider(tlsProvider, tlsConfig) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("foo.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("foo:" + commonName); + }) + .and() + .virtualHost("sub.foo.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("sub.foo:" + commonName); + }); + } + }; + + @RegisterExtension + static final ServerExtension server1 = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(server1DefaultCert.tlsKeyPair()) + .keyPair("bar.com", server1BarCert.tlsKeyPair()) + .trustedCertificates(clientFooCert.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + + sb.https(0) + .tlsProvider(tlsProvider, tlsConfig) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("bar.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("virtual:" + commonName); + }); + } + }; + + @RegisterExtension + static final ServerExtension serverNoMtls = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(server0DefaultCert.tlsKeyPair()) + .keyPair("bar.com", server1BarCert.tlsKeyPair()) + .build(); + + sb.https(0) + .tlsProvider(tlsProvider) + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("bar.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("virtual:" + commonName); + }); + } + }; + + @Test + void testExactMatch() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("*.foo.com", clientFooCert.tlsKeyPair()) + .keyPair("bar.com", clientBarCert.tlsKeyPair()) + .keyPair(TlsKeyPair.of(clientFooCert.privateKey(), + clientFooCert.certificate())) + .trustedCertificates("foo.com", server0FooCert.certificate()) + .trustedCertificates("bar.com", server1BarCert.certificate()) + .trustedCertificates("sub.foo.com", server0SubFooCert.certificate()) + .trustedCertificates(server0DefaultCert.certificate()) + .build(); + + final MeterRegistry meterRegistry = new SimpleMeterRegistry(); + try (ClientFactory factory = ClientFactory.builder() + .tlsProvider(tlsProvider) + .meterRegistry(meterRegistry) + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + // clientFooCert should be chosen by TlsProvider. + BlockingWebClient client = WebClient.builder("https://foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("foo:foo.com"); + client = WebClient.builder("https://sub.foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("sub.foo:sub.foo.com"); + client = WebClient.builder("https://127.0.0.1:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("default:localhost"); + + await().untilAsserted(() -> { + final Map metrics = MoreMeters.measureAll(meterRegistry); + // Make sure that the metrics for the certificates generated from TlsProvider are exported. + assertThat(metrics.get("armeria.client.tls.certificate.validity#value{common.name=foo.com}")) + .isEqualTo(1.0); + assertThat( + metrics.get("armeria.client.tls.certificate.validity#value{common.name=sub.foo.com}")) + .isEqualTo(1.0); + }); + } + + await().untilAsserted(() -> { + final Map metrics = MoreMeters.measureAll(meterRegistry); + // The metrics for the certificates should be closed when the associated connections are closed. + assertThat(metrics.get("armeria.client.tls.certificate.validity#value{common.name=foo.com}")) + .isNull(); + assertThat(metrics.get("armeria.client.tls.certificate.validity#value{common.name=sub.foo.com}")) + .isNull(); + }); + } + + @Test + void testWildcardMatch() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientFooCert.tlsKeyPair()) + .keyPair("*.foo.com", clientFooCert.tlsKeyPair()) + .trustedCertificates(server0FooCert.certificate(), + server0SubFooCert.certificate()) + .build(); + + try ( + ClientFactory factory = ClientFactory.builder() + .tlsProvider(tlsProvider) + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + // clientFooCert should be chosen by TlsProvider. + BlockingWebClient client = WebClient.builder("https://foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("foo:foo.com"); + client = WebClient.builder("https://sub.foo.com:" + server0.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("sub.foo:sub.foo.com"); + } + } + + @Test + void testNoMtls() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientFooCert.tlsKeyPair()) + .trustedCertificates(server0DefaultCert.certificate(), + server1BarCert.certificate()) + .build(); + + try (ClientFactory factory = ClientFactory.builder() + .tlsProvider(tlsProvider) + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + // clientFooCert should be chosen by TlsProvider. + BlockingWebClient client = WebClient.builder("https://bar.com:" + serverNoMtls.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("virtual:bar.com"); + + client = WebClient.builder("https://127.0.0.1:" + serverNoMtls.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("default:localhost"); + } + } + + @Test + void disallowTlsProviderWhenTlsSettingsIsSet() { + final TlsProvider tlsProvider = + TlsProvider.of(TlsKeyPair.ofSelfSigned()); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tls(TlsKeyPair.ofSelfSigned()); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tlsCustomizer(b -> {}); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tlsNoVerify(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tlsProvider(tlsProvider) + .tlsNoVerifyHosts("example.com"); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings because a TlsProvider has been set."); + + assertThatThrownBy(() -> { + ClientFactory.builder() + .tls(TlsKeyPair.ofSelfSigned()) + .tlsProvider(tlsProvider); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "Cannot configure the TlsProvider because static TLS settings have been set already."); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/HttpClientExpect100HeaderTest.java b/core/src/test/java/com/linecorp/armeria/client/HttpClientExpect100HeaderTest.java index c2c7115f2fd..eef69a9a330 100644 --- a/core/src/test/java/com/linecorp/armeria/client/HttpClientExpect100HeaderTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/HttpClientExpect100HeaderTest.java @@ -552,7 +552,7 @@ private static String get(Http2Headers headers, CharSequence name) { private static void sendFrameHeaders(BufferedOutputStream bos, HttpStatus status, boolean endOfStream, int streamId) throws Exception { - final HPackEncoder encoder = new HPackEncoder(StandardCharsets.UTF_8); + final HPackEncoder encoder = new HPackEncoder(4096, StandardCharsets.UTF_8); final ByteArrayBuffer buffer = new ByteArrayBuffer(1024); encoder.encodeHeader(buffer, ":status", status.codeAsText(), false); final byte[] headersPayload = buffer.toByteArray(); diff --git a/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java b/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java index 6966d8a580a..15f97dba438 100644 --- a/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/IgnoreHostsTrustManagerTest.java @@ -37,6 +37,7 @@ import com.google.common.collect.ImmutableSet; import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.internal.common.IgnoreHostsTrustManager; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.testing.junit5.server.ServerExtension; diff --git a/core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java b/core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java new file mode 100644 index 00000000000..5ee0e319ce5 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/TlsProviderCacheTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; +import com.spotify.futures.CompletableFutures; + +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.logging.RequestLogProperty; +import com.linecorp.armeria.internal.common.SslContextFactory; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.netty.channel.Channel; +import io.netty.handler.ssl.ClientAuth; + +class TlsProviderCacheTest { + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension clientFooCert = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension clientBarCert = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension serverFooCert = new SelfSignedCertificateExtension("foo.com"); + + @Order(0) + @RegisterExtension + static final SelfSignedCertificateExtension serverBarCert = new SelfSignedCertificateExtension("bar.com"); + + static CompletableFuture startFuture; + + @Order(1) + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(serverFooCert.tlsKeyPair()) + .keyPair("bar.com", serverBarCert.tlsKeyPair()) + .trustedCertificates(clientFooCert.certificate(), + clientBarCert.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.tlsProvider(tlsProvider, tlsConfig); + + sb.virtualHost("bar.com") + .service("/", (ctx, req) -> { + final CompletableFuture future = + startFuture.thenApply(unused -> HttpResponse.of("Hello, Bar!")); + return HttpResponse.of(future); + }); + + sb.service("/", (ctx, req) -> { + final CompletableFuture future = + startFuture.thenApply(unused -> HttpResponse.of("Hello!")); + return HttpResponse.of(future); + }); + } + }; + + @BeforeEach + void setUp() { + startFuture = new CompletableFuture<>(); + } + + @Test + void shouldCacheSslContext() { + // This test could be broken if multiple tests are running in parallel. + final CountingConnectionPoolListener poolListener = new CountingConnectionPoolListener(); + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientFooCert.tlsKeyPair()) + .keyPair("bar.com", clientBarCert.tlsKeyPair()) + .trustedCertificates(serverFooCert.certificate(), serverBarCert.certificate()) + .build(); + + final List channels = new ArrayList<>(); + final List> responses = new ArrayList<>(); + try ( + ClientFactory factory = ClientFactory + .builder() + .addressResolverGroupFactory(eventLoopGroup -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .connectionPoolListener(poolListener) + .build()) { + for (String host : ImmutableList.of("foo.com", "bar.com")) { + final WebClient client = + // Use HTTP/1 to create multiple connections. + WebClient.builder("h1://" + host + ':' + server.httpsPort()) + .factory(factory) + .build(); + + for (int i = 0; i < 3; i++) { + try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) { + final CompletableFuture future = + client.prepare() + .get("/") + .header(HttpHeaderNames.CONNECTION, "close") + .execute() + .aggregate(); + responses.add(future); + channels.add(captor.get().log() + .whenAvailable(RequestLogProperty.REQUEST_HEADERS).join() + .channel()); + } + } + } + + await().untilAsserted(() -> { + assertThat(poolListener.opened()).isEqualTo(6); + }); + + final HttpClientFactory clientFactory = (HttpClientFactory) factory.unwrap(); + final SslContextFactory sslContextFactory = clientFactory.sslContextFactory(); + assertThat(sslContextFactory).isNotNull(); + // Make sure the SslContext is reused + assertThat(sslContextFactory.numCachedContexts()).isEqualTo(2); + + startFuture.complete(null); + final List responses0 = CompletableFutures.allAsList(responses).join(); + for (int i = 0; i < responses0.size(); i++) { + final AggregatedHttpResponse response = responses0.get(i); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + if (i < 3) { + assertThat(response.contentUtf8()).isEqualTo("Hello!"); + } else { + assertThat(response.contentUtf8()).isEqualTo("Hello, Bar!"); + } + } + + await().untilAsserted(() -> { + assertThat(poolListener.closed()).isEqualTo(6); + }); + // Make sure a cached SslContext is released when all referenced channels are closed. + assertThat(sslContextFactory.numCachedContexts()).isEqualTo(0); + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java b/core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java new file mode 100644 index 00000000000..b3fda22fc8d --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/TlsProviderMTlsTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.netty.handler.ssl.ClientAuth; + +class TlsProviderMTlsTest { + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension sscServer = new SelfSignedCertificateExtension(); + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension sscClient = new SelfSignedCertificateExtension(); + + @Order(1) + @RegisterExtension + static ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair(sscServer.tlsKeyPair()) + .trustedCertificates(sscClient.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.tlsProvider(tlsProvider, tlsConfig); + + sb.service("/", (ctx, req) -> { + return HttpResponse.of(HttpStatus.OK); + }); + } + }; + + @Test + void testMTls() { + final TlsProvider tlsProvider = TlsProvider + .builder() + .keyPair(sscClient.tlsKeyPair()) + .trustedCertificates(sscServer.certificate()) + .build(); + try (ClientFactory factory = ClientFactory + .builder() + .tlsProvider(tlsProvider) + .connectTimeoutMillis(Long.MAX_VALUE) + .build()) { + final BlockingWebClient client = WebClient.builder(server.httpsUri()) + .factory(factory) + .build() + .blocking(); + final AggregatedHttpResponse res = client.get("/"); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java b/core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java new file mode 100644 index 00000000000..50e999cd1d0 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/client/TlsProviderTrustedCertificatesTest.java @@ -0,0 +1,233 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.cert.X509Certificate; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerTlsConfig; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +import io.netty.handler.ssl.ClientAuth; + +class TlsProviderTrustedCertificatesTest { + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension serverCertFoo = new SelfSignedCertificateExtension("foo.com"); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension serverCertBar = new SelfSignedCertificateExtension("bar.com"); + + @Order(0) + + @RegisterExtension + static SelfSignedCertificateExtension serverCertDefault = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension clientCertFoo = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension clientCertBar = new SelfSignedCertificateExtension(); + + @Order(0) + @RegisterExtension + static SelfSignedCertificateExtension clientCertDefault = new SelfSignedCertificateExtension(); + + @Order(1) + @RegisterExtension + static ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", serverCertFoo.tlsKeyPair()) + .keyPair("bar.com", serverCertBar.tlsKeyPair()) + .keyPair(serverCertDefault.tlsKeyPair()) + .trustedCertificates("foo.com", clientCertFoo.certificate()) + .trustedCertificates("bar.com", clientCertBar.certificate()) + .trustedCertificates(clientCertDefault.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0); + sb.tlsProvider(tlsProvider, tlsConfig); + sb.service("/", (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; + + @RegisterExtension + static ServerExtension fooServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair("foo.com", serverCertFoo.tlsKeyPair()) + .trustedCertificates("foo.com", + clientCertFoo.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0); + sb.tlsProvider(tlsProvider, tlsConfig); + sb.service("/", (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; + + @RegisterExtension + static ServerExtension barServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("bar.com", serverCertBar.tlsKeyPair()) + .trustedCertificates("bar.com", clientCertBar.certificate()) + .build(); + final ServerTlsConfig tlsConfig = ServerTlsConfig.builder() + .clientAuth(ClientAuth.REQUIRE) + .build(); + sb.https(0); + sb.tlsProvider(tlsProvider, tlsConfig); + sb.service("/", (ctx, req) -> HttpResponse.of(HttpStatus.OK)); + } + }; + + @Test + void complexUsage() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientCertFoo.tlsKeyPair()) + .keyPair("bar.com", clientCertBar.tlsKeyPair()) + .keyPair(clientCertDefault.tlsKeyPair()) + .trustedCertificates(serverCertDefault.certificate()) + .trustedCertificates("foo.com", serverCertFoo.certificate()) + .trustedCertificates("bar.com", serverCertBar.certificate()) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + for (String hostname : ImmutableList.of("foo.com", "bar.com", "127.0.0.1")) { + final BlockingWebClient client = + WebClient.builder("https://" + hostname + ':' + server.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } + } + + @MethodSource("simpleParameters") + @ParameterizedTest + void simpleUsage(String hostname, int port, TlsKeyPair keyPair, X509Certificate trustedCertificate) { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(hostname, keyPair) + .trustedCertificates(hostname, trustedCertificate) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + final BlockingWebClient client = + WebClient.builder("https://" + hostname + ':' + port) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } + + @Test + void defaultTrustedCertificates() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("foo.com", clientCertFoo.tlsKeyPair()) + .trustedCertificates(serverCertFoo.certificate()) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + final BlockingWebClient client = + WebClient.builder("https://foo.com:" + fooServer.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } + + static Stream simpleParameters() { + return Stream.of( + Arguments.of("foo.com", fooServer.httpsPort(), + clientCertFoo.tlsKeyPair(), serverCertFoo.certificate()), + Arguments.of("bar.com", barServer.httpsPort(), + clientCertBar.tlsKeyPair(), serverCertBar.certificate())); + } + + @Test + void simpleUsage_bar() { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair("bar.com", clientCertBar.tlsKeyPair()) + .trustedCertificates("bar.com", serverCertBar.certificate()) + .build(); + try (ClientFactory factory = + ClientFactory.builder() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .tlsProvider(tlsProvider) + .build()) { + final BlockingWebClient client = + WebClient.builder("https://bar.com:" + barServer.httpsPort()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").status()).isEqualTo(HttpStatus.OK); + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java b/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java index 68fb9c5d951..504f94cbf53 100644 --- a/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java @@ -26,6 +26,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableMap; @@ -43,10 +45,13 @@ import io.netty.handler.codec.dns.DefaultDnsResponse; import io.netty.handler.codec.dns.DnsRecord; import io.netty.handler.codec.dns.DnsSection; +import io.netty.resolver.ResolvedAddressTypes; import io.netty.util.ReferenceCountUtil; class TrailingDotAddressResolverTest { + private static final Logger logger = LoggerFactory.getLogger(TrailingDotAddressResolverTest.class); + @RegisterExtension static ServerExtension server = new ServerExtension() { @Override @@ -77,13 +82,15 @@ void resolve() throws Exception { new DefaultDnsQuestion("foo.com.", A), new DefaultDnsResponse(0).addRecord(ANSWER, newAddressRecord("foo.com.", "127.0.0.1"))), dnsRecordCaptor)) { - try (ClientFactory factory = ClientFactory.builder() - .domainNameResolverCustomizer(b -> { - b.serverAddresses(dnsServer.addr()); - b.searchDomains("search.domain1", "search.domain2"); - b.ndots(3); - }) - .build()) { + try (ClientFactory factory = + ClientFactory.builder() + .domainNameResolverCustomizer(b -> { + b.resolvedAddressTypes(ResolvedAddressTypes.IPV4_ONLY); + b.serverAddresses(dnsServer.addr()); + b.searchDomains("search.domain1", "search.domain2"); + b.ndots(3); + }) + .build()) { final BlockingWebClient client = WebClient.builder() .factory(factory) @@ -93,6 +100,7 @@ void resolve() throws Exception { "http://foo.com.:" + server.httpPort() + '/'); assertThat(response.contentUtf8()).isEqualTo("Hello, world!"); assertThat(dnsRecordCaptor.records).isNotEmpty(); + logger.debug("Captured DNS records: {}", dnsRecordCaptor.records); dnsRecordCaptor.records.forEach(record -> { assertThat(record.name()).isEqualTo("foo.com."); }); diff --git a/core/src/test/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyTest.java b/core/src/test/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyTest.java index 54ede19bbe3..6542f67bb96 100644 --- a/core/src/test/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/endpoint/WeightRampingUpStrategyTest.java @@ -110,7 +110,6 @@ void rampingUpIsDoneAfterNumberOfSteps() { scheduledJobs.poll().run(); // Ramping up is done because the step reached the numberOfSteps. - assertThat(selector.endpointsRampingUp).isEmpty(); endpointsFromEntry = endpointsFromSelectorEntry(selector); assertThat(endpointsFromEntry).usingElementComparator(EndpointComparator.INSTANCE) .containsExactlyInAnyOrder( diff --git a/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java b/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java index 932c15197a1..f1fee902870 100644 --- a/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/proxy/ProxyClientIntegrationTest.java @@ -63,6 +63,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsProvider; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.testing.BlockingUtils; import com.linecorp.armeria.internal.testing.NettyServerExtension; @@ -92,6 +93,7 @@ import io.netty.handler.codec.socksx.v4.Socks4CommandStatus; import io.netty.handler.logging.LoggingHandler; import io.netty.handler.proxy.ProxyConnectException; +import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.traffic.ChannelTrafficShapingHandler; @@ -111,7 +113,15 @@ class ProxyClientIntegrationTest { @RegisterExtension @Order(0) - static final SelfSignedCertificateExtension ssc = new SelfSignedCertificateExtension(); + static final SelfSignedCertificateExtension proxySsc = new SelfSignedCertificateExtension(); + + @RegisterExtension + @Order(0) + static final SelfSignedCertificateExtension backendSsc = new SelfSignedCertificateExtension(); + + @RegisterExtension + @Order(0) + static final SelfSignedCertificateExtension clientSsc = new SelfSignedCertificateExtension(); @RegisterExtension @Order(1) @@ -120,7 +130,7 @@ class ProxyClientIntegrationTest { protected void configure(ServerBuilder sb) throws Exception { sb.port(0, SessionProtocol.HTTP); sb.port(0, SessionProtocol.HTTPS); - sb.tlsSelfSigned(); + sb.tls(backendSsc.tlsKeyPair()); sb.service(PROXY_PATH, (ctx, req) -> HttpResponse.of(SUCCESS_RESPONSE)); } }; @@ -172,7 +182,27 @@ protected void configure(Channel ch) throws Exception { protected void configure(Channel ch) throws Exception { assertThat(sslContext).isNotNull(); final SslContext sslContext = SslContextBuilder - .forServer(ssc.privateKey(), ssc.certificate()).build(); + .forServer(proxySsc.privateKey(), proxySsc.certificate()).build(); + ch.pipeline().addLast(sslContext.newHandler(ch.alloc())); + ch.pipeline().addLast(new HttpServerCodec()); + ch.pipeline().addLast(new HttpObjectAggregator(1024)); + ch.pipeline().addLast(new HttpProxyServerHandler()); + ch.pipeline().addLast(new SleepHandler()); + ch.pipeline().addLast(new IntermediaryProxyServerHandler("http", PROXY_CALLBACK)); + } + }; + + @RegisterExtension + @Order(4) + static NettyServerExtension mTlsHttpsProxyServer = new NettyServerExtension() { + @Override + protected void configure(Channel ch) throws Exception { + assertThat(sslContext).isNotNull(); + final SslContext sslContext = SslContextBuilder + .forServer(proxySsc.privateKey(), proxySsc.certificate()) + .clientAuth(ClientAuth.REQUIRE) + .trustManager(clientSsc.certificate()) + .build(); ch.pipeline().addLast(sslContext.newHandler(ch.alloc())); ch.pipeline().addLast(new HttpServerCodec()); ch.pipeline().addLast(new HttpObjectAggregator(1024)); @@ -205,7 +235,7 @@ protected void configure(Channel ch) throws Exception { @BeforeAll static void beforeAll() throws Exception { sslContext = SslContextBuilder - .forServer(ssc.privateKey(), ssc.certificate()).build(); + .forServer(proxySsc.privateKey(), proxySsc.certificate()).build(); } @BeforeEach @@ -507,6 +537,33 @@ void testHttpsProxy(SessionProtocol protocol, Endpoint endpoint) throws Exceptio clientFactory.closeAsync(); } + @ParameterizedTest + @MethodSource("sessionAndEndpointProvider") + void testMTlsHttpsProxyWithTlsProvider(SessionProtocol protocol, Endpoint endpoint) throws Exception { + final TlsProvider tlsProvider = + TlsProvider.builder() + .keyPair(clientSsc.tlsKeyPair()) + .trustedCertificates(proxySsc.certificate(), backendSsc.certificate()) + .build(); + + final ClientFactory clientFactory = + ClientFactory.builder() + .tlsProvider(tlsProvider) + .proxyConfig( + ProxyConfig.connect(mTlsHttpsProxyServer.address(), true)).build(); + final WebClient webClient = WebClient.builder(protocol, endpoint) + .factory(clientFactory) + .decorator(LoggingClient.newDecorator()) + .build(); + final CompletableFuture responseFuture = + webClient.get(PROXY_PATH).aggregate(); + final AggregatedHttpResponse response = responseFuture.join(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo(SUCCESS_RESPONSE); + assertThat(numSuccessfulProxyRequests).isEqualTo(1); + clientFactory.closeAsync(); + } + @Test void testProxyWithH2C() throws Exception { final int numRequests = 5; diff --git a/core/src/test/java/com/linecorp/armeria/common/logging/DefaultRequestLogTest.java b/core/src/test/java/com/linecorp/armeria/common/logging/DefaultRequestLogTest.java index fb95c5bff37..dfc8838b61d 100644 --- a/core/src/test/java/com/linecorp/armeria/common/logging/DefaultRequestLogTest.java +++ b/core/src/test/java/com/linecorp/armeria/common/logging/DefaultRequestLogTest.java @@ -28,6 +28,9 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiFunction; import org.junit.jupiter.api.BeforeEach; @@ -49,6 +52,7 @@ import com.linecorp.armeria.common.RpcResponse; import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.util.ThreadFactories; import com.linecorp.armeria.internal.testing.AnticipatedException; import com.linecorp.armeria.internal.testing.ImmediateEventLoop; import com.linecorp.armeria.server.HttpService; @@ -544,4 +548,22 @@ void testPendingLogsAlwaysInEventLoop() { .satisfiesAnyOf(t0 -> assertThat(t0).isEqualTo(testThread), t0 -> assertThat(ctx.eventLoop().inEventLoop(t0)).isTrue())); } + + @Test + void nameIsAlwaysSet() { + final AtomicInteger atomicInteger = new AtomicInteger(); + final ExecutorService executorService = + Executors.newFixedThreadPool(2, ThreadFactories.newThreadFactory("test", true)); + // a heurestic number of iterations to reproduce #5981 + final int numIterations = 1000; + for (int i = 0; i < numIterations; i++) { + final ServiceRequestContext sctx = ServiceRequestContext.of(HttpRequest.of(HttpMethod.GET, "/")); + final DefaultRequestLog log = new DefaultRequestLog(sctx); + log.defer(RequestLogProperty.REQUEST_CONTENT); + executorService.execute(log::endRequest); + executorService.execute(() -> log.requestContent(null, null)); + log.whenRequestComplete().thenRun(atomicInteger::incrementAndGet); + } + await().untilAsserted(() -> assertThat(atomicInteger).hasValue(numIterations)); + } } diff --git a/core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java b/core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java new file mode 100644 index 00000000000..085372b27f6 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/stream/TimeoutStreamMessageTest.java @@ -0,0 +1,238 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.common.stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.StreamTimeoutException; +import com.linecorp.armeria.testing.junit5.common.EventLoopExtension; + +class TimeoutStreamMessageTest { + + @RegisterExtension + static final EventLoopExtension executor = new EventLoopExtension(); + + @Test + public void timeoutNextMode() { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_NEXT); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + subscription.request(1); + } + + @Override + public void onNext(String s) { + executor.get().schedule(() -> subscription.request(1), 2, TimeUnit.SECONDS); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(StreamTimeoutException.class); + } + + @Test + void noTimeoutNextMode() throws Exception { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_NEXT); + + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(2); + } + + @Override + public void onNext(String s) { + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThat(future.get()).isNull(); + } + + @Test + void timeoutFirstMode() { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_FIRST); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + executor.get().schedule(() -> subscription.request(1), 2, TimeUnit.SECONDS); + } + + @Override + public void onNext(String s) { + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(StreamTimeoutException.class); + } + + @Test + void noTimeoutModeFirst() throws Exception { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(1), StreamTimeoutMode.UNTIL_FIRST); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(2); + } + + @Override + public void onNext(String s) { + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThat(future.get()).isNull(); + } + + @Test + void timeoutEOSMode() { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(2), StreamTimeoutMode.UNTIL_EOS); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + private Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + executor.get().schedule(() -> subscription.request(1), 1, TimeUnit.SECONDS); + } + + @Override + public void onNext(String s) { + executor.get().schedule(() -> subscription.request(1), 2, TimeUnit.SECONDS); + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(StreamTimeoutException.class); + } + + @Test + void noTimeoutEOSMode() throws Exception { + final StreamMessage timeoutStreamMessage = StreamMessage.of("message1", "message2").timeout( + Duration.ofSeconds(2), StreamTimeoutMode.UNTIL_EOS); + final CompletableFuture future = new CompletableFuture<>(); + + timeoutStreamMessage.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(2); + } + + @Override + public void onNext(String s) { + } + + @Override + public void onError(Throwable throwable) { + future.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + future.complete(null); + } + }, executor.get()); + + assertThat(future.get()).isNull(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java index 4328afba130..27139316b1d 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java @@ -199,13 +199,16 @@ void shouldWaitForPreferredRecords() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List fooDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "1.2.3.4")); final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, null); assertThat(future).isNotCompleted(); - maybeCompletePreferredRecords(future, questions, results, 0, fooDnsRecord, null); + assertThat(ctx.isCompleted()).isFalse(); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, fooDnsRecord, null); assertThat(future).isCompletedWithValue(fooDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -216,12 +219,15 @@ void shouldWaitForPreferredRecords_ignoreErrorsOnPrecedence() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, null); assertThat(future).isNotCompleted(); - maybeCompletePreferredRecords(future, questions, results, 0, null, new AnticipatedException()); + assertThat(ctx.isCompleted()).isFalse(); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, null, new AnticipatedException()); assertThat(future).isCompletedWithValue(barDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -232,10 +238,12 @@ void resolvePreferredRecordsFirst() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List fooDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "1.2.3.4")); - maybeCompletePreferredRecords(future, questions, results, 0, fooDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, fooDnsRecord, null); // The preferred question is resolved. Don't need to wait for the questions. assertThat(future).isCompletedWithValue(fooDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -249,11 +257,14 @@ void shouldWaitForPreferredRecords_allQuestionsAreFailed() { final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. final AnticipatedException barCause = new AnticipatedException(); - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, barCause); + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, barCause); assertThat(future).isNotCompleted(); + assertThat(ctx.isCompleted()).isFalse(); final AnticipatedException fooCause = new AnticipatedException(); - maybeCompletePreferredRecords(future, questions, results, 0, null, fooCause); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, null, fooCause); assertThat(future).isCompletedExceptionally(); + assertThat(ctx.isCompleted()).isTrue(); assertThatThrownBy(future::join) .isInstanceOf(CompletionException.class) .cause() diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java index c17c86c1978..d32bdec4615 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.net.UnknownHostException; import java.util.List; import java.util.Queue; import java.util.concurrent.CompletableFuture; @@ -76,6 +77,46 @@ public void close() {} DnsQuestionWithoutTrailingDot.of("example.com", "example.com.armeria.io.", DnsRecordType.A), DnsQuestionWithoutTrailingDot.of("example.com", "example.com.armeria.dev.", DnsRecordType.A), DnsQuestionWithoutTrailingDot.of("example.com", "example.com.", DnsRecordType.A)); - context.cancel(); + context.cancelScheduler(); + } + + @Test + void unknownHostnameEndingWithDot() { + final ByteArrayDnsRecord record = new ByteArrayDnsRecord("example.com", DnsRecordType.A, + 1, new byte[] { 10, 0, 1, 1 }); + final Queue questions = new LinkedBlockingQueue<>(); + final DnsResolver mockResolver = new DnsResolver() { + + @Override + public CompletableFuture> resolve(DnsQuestionContext ctx, DnsQuestion question) { + questions.add(question); + if ("trailing-dot.com.armeria.dev.".equals(question.name())) { + return UnmodifiableFuture.completedFuture(ImmutableList.of(record)); + } else { + return UnmodifiableFuture.exceptionallyCompletedFuture(new UnknownHostException()); + } + } + + @Override + public void close() {} + }; + + final List searchDomains = ImmutableList.of("armeria.io", "armeria.dev"); + final DnsQuestionContext context = new DnsQuestionContext(eventLoop.get(), 10000); + final DnsQuestionWithoutTrailingDot question = + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", DnsRecordType.A); + final SearchDomainDnsResolver resolver = new SearchDomainDnsResolver(mockResolver, searchDomains, 2); + final CompletableFuture> result = resolver.resolve(context, question); + + assertThat(result.join()).contains(record); + assertThat(questions).hasSize(3); + assertThat(questions).containsExactly( + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.", + DnsRecordType.A), + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.armeria.io.", + DnsRecordType.A), + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.armeria.dev.", + DnsRecordType.A)); + context.cancelScheduler(); } } diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java index d966239ae7c..1ea65cb7452 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java @@ -39,15 +39,15 @@ void startsWithHostname(String hostname, int ndots) { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of(hostname, DnsRecordType.A); // Since `SearchDomainDnsResolver` normalizes search domains while being initialized, // `SearchDomainQuestionContext` should use a normalized search domain that - // starts and ends with a dot for testing . - final List searchDomains = ImmutableList.of(".armeria.io.", ".armeria.com.", - ".armeria.org.", ".armeria.dev."); + // ends with a dot for testing . + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, ndots); final DnsQuestion firstQuestion = ctx.nextQuestion(); assertThat(firstQuestion.name()).isEqualTo(hostname + '.'); for (String searchDomain : searchDomains) { final DnsQuestion expected = - DnsQuestionWithoutTrailingDot.of(hostname, hostname + searchDomain, + DnsQuestionWithoutTrailingDot.of(hostname, hostname + '.' + searchDomain, DnsRecordType.A); assertThat(ctx.nextQuestion()).isEqualTo(expected); } @@ -58,12 +58,12 @@ void startsWithHostname(String hostname, int ndots) { @ParameterizedTest void endsWithHostname(String hostname, int ndots) { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of(hostname, DnsRecordType.A); - final List searchDomains = ImmutableList.of(".armeria.io.", ".armeria.com.", - ".armeria.org.", ".armeria.dev."); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, ndots); for (String searchDomain : searchDomains) { final DnsQuestion expected = - DnsQuestionWithoutTrailingDot.of(hostname, hostname + searchDomain, + DnsQuestionWithoutTrailingDot.of(hostname, hostname + '.' + searchDomain, DnsRecordType.A); assertThat(ctx.nextQuestion()).isEqualTo(expected); } @@ -73,12 +73,64 @@ void endsWithHostname(String hostname, int ndots) { assertThat(ctx.nextQuestion()).isNull(); } + @Test + void trailingDot() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 3); + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("foo.com."); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("foo.com.", "foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + assertThat(ctx.nextQuestion()).isNull(); + } + + @Test + void nonTrailingDot_shouldStartWithHostnameByNdots() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("bar.foo.com", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 2); + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("bar.foo.com."); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("bar.foo.com", "bar.foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + assertThat(ctx.nextQuestion()).isNull(); + } + + @Test + void nonTrailingDot_shouldNotStartWithHostnameByNdots() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("bar.foo.com", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 3); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("bar.foo.com", "bar.foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("bar.foo.com."); + assertThat(ctx.nextQuestion()).isNull(); + } + @Test void noSearchDomain() { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("foo.com", DnsRecordType.A); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, ImmutableList.of(), 2); - assertThat(ctx.nextQuestion()).isEqualTo(original); + assertThat(ctx.nextQuestion()).isEqualTo( + DnsQuestionWithoutTrailingDot.of("foo.com", "foo.com.", DnsRecordType.A)); assertThat(ctx.nextQuestion()).isNull(); } } diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java index f959f039552..62251179c7e 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/common/DefaultRequestTargetTest.java @@ -143,9 +143,9 @@ void serverShouldAcceptGoodDoubleDotPatterns(String pattern) { void dotsAndEqualsInNameValueQuery() { QUERY_SEPARATORS.forEach(qs -> { assertThat(forServer("/?a=..=" + qs + "b=..=")).satisfies(res -> { - assertThat(res).isNotNull(); - assertThat(res.query()).isEqualTo("a=..=" + qs + "b=..="); - assertThat(QueryParams.fromQueryString(res.query(), true)).containsExactly( + assertThat(res.requestTarget).isNotNull(); + assertThat(res.requestTarget.query()).isEqualTo("a=..=" + qs + "b=..="); + assertThat(QueryParams.fromQueryString(res.requestTarget.query(), true)).containsExactly( Maps.immutableEntry("a", "..="), Maps.immutableEntry("b", "..=") ); @@ -153,8 +153,8 @@ void dotsAndEqualsInNameValueQuery() { assertThat(forServer("/?a==.." + qs + "b==..")).satisfies(res -> { assertThat(res).isNotNull(); - assertThat(res.query()).isEqualTo("a==.." + qs + "b==.."); - assertThat(QueryParams.fromQueryString(res.query(), true)).containsExactly( + assertThat(res.requestTarget.query()).isEqualTo("a==.." + qs + "b==.."); + assertThat(QueryParams.fromQueryString(res.requestTarget.query(), true)).containsExactly( Maps.immutableEntry("a", "=.."), Maps.immutableEntry("b", "=..") ); @@ -162,8 +162,8 @@ void dotsAndEqualsInNameValueQuery() { assertThat(forServer("/?a==..=" + qs + "b==..=")).satisfies(res -> { assertThat(res).isNotNull(); - assertThat(res.query()).isEqualTo("a==..=" + qs + "b==..="); - assertThat(QueryParams.fromQueryString(res.query(), true)).containsExactly( + assertThat(res.requestTarget.query()).isEqualTo("a==..=" + qs + "b==..="); + assertThat(QueryParams.fromQueryString(res.requestTarget.query(), true)).containsExactly( Maps.immutableEntry("a", "=..="), Maps.immutableEntry("b", "=..=") ); @@ -500,9 +500,9 @@ void clientShouldAcceptAbsoluteUri(String uri, String expectedScheme, String expectedAuthority, String expectedPath, @Nullable String expectedQuery, @Nullable String expectedFragment) { - final RequestTarget res = forClient(uri); - assertThat(res.scheme()).isEqualTo(expectedScheme); - assertThat(res.authority()).isEqualTo(expectedAuthority); + final RequestTargetWithRawPath res = forClient(uri); + assertThat(res.requestTarget.scheme()).isEqualTo(expectedScheme); + assertThat(res.requestTarget.authority()).isEqualTo(expectedAuthority); assertAccepted(res, expectedPath, emptyToNull(expectedQuery), emptyToNull(expectedFragment)); } @@ -531,15 +531,15 @@ void shouldYieldEmptyStringForEmptyQueryAndFragment(Mode mode) { @ParameterizedTest @EnumSource(Mode.class) void testToString(Mode mode) { - assertThat(parse(mode, "/")).asString().isEqualTo("/"); - assertThat(parse(mode, "/?")).asString().isEqualTo("/?"); - assertThat(parse(mode, "/?a=b")).asString().isEqualTo("/?a=b"); + assertThat(parse(mode, "/").requestTarget).asString().isEqualTo("/"); + assertThat(parse(mode, "/?").requestTarget).asString().isEqualTo("/?"); + assertThat(parse(mode, "/?a=b").requestTarget).asString().isEqualTo("/?a=b"); if (mode == Mode.CLIENT) { - assertThat(forClient("/#")).asString().isEqualTo("/#"); - assertThat(forClient("/?#")).asString().isEqualTo("/?#"); - assertThat(forClient("/?a=b#c=d")).asString().isEqualTo("/?a=b#c=d"); - assertThat(forClient("http://foo/bar?a=b#c=d")).asString().isEqualTo("http://foo/bar?a=b#c=d"); + assertThat(forClient("/#").requestTarget).asString().isEqualTo("/#"); + assertThat(forClient("/?#").requestTarget).asString().isEqualTo("/?#"); + assertThat(forClient("/?a=b#c=d").requestTarget).asString().isEqualTo("/?a=b#c=d"); + assertThat(forClient("http://foo/bar?a=b#c=d").requestTarget).asString().isEqualTo("http://foo/bar?a=b#c=d"); } } @@ -572,32 +572,32 @@ void testRemoveMatrixVariables() { assertThat(removeMatrixVariables("/prefix/;a=b")).isNull(); } - private static void assertAccepted(@Nullable RequestTarget res, String expectedPath) { + private static void assertAccepted(RequestTargetWithRawPath res, String expectedPath) { assertAccepted(res, expectedPath, null, null); } - private static void assertAccepted(@Nullable RequestTarget res, + private static void assertAccepted(RequestTargetWithRawPath res, String expectedPath, @Nullable String expectedQuery) { assertAccepted(res, expectedPath, expectedQuery, null); } - private static void assertAccepted(@Nullable RequestTarget res, + private static void assertAccepted(RequestTargetWithRawPath res, String expectedPath, @Nullable String expectedQuery, @Nullable String expectedFragment) { - assertThat(res).isNotNull(); - assertThat(res.path()).isEqualTo(expectedPath); - assertThat(res.query()).isEqualTo(expectedQuery); - assertThat(res.fragment()).isEqualTo(expectedFragment); + assertThat(res.requestTarget).isNotNull(); + assertThat(res.requestTarget.path()).isEqualTo(expectedPath); + assertThat(res.requestTarget.query()).isEqualTo(expectedQuery); + assertThat(res.requestTarget.fragment()).isEqualTo(expectedFragment); + assertThat(res.requestTarget.rawPath()).isEqualTo(res.rawPath); } - private static void assertRejected(@Nullable RequestTarget res) { - assertThat(res).isNull(); + private static void assertRejected(RequestTargetWithRawPath res) { + assertThat(res.requestTarget).isNull(); } - @Nullable - private static RequestTarget parse(Mode mode, String rawPath) { + private static RequestTargetWithRawPath parse(Mode mode, String rawPath) { switch (mode) { case SERVER: return forServer(rawPath); @@ -608,37 +608,57 @@ private static RequestTarget parse(Mode mode, String rawPath) { } } - @Nullable - private static RequestTarget forServer(String rawPath) { + private static class RequestTargetWithRawPath { + @Nullable + final String rawPath; + @Nullable + final RequestTarget requestTarget; + + RequestTargetWithRawPath(@Nullable String rawPath, @Nullable RequestTarget requestTarget) { + this.rawPath = rawPath; + this.requestTarget = requestTarget; + } + + @Override + public String toString() { + return "RequestTargetWithRawPath{" + + "rawPath='" + rawPath + '\'' + + ", requestTarget=" + requestTarget + + '}'; + } + } + + private static RequestTargetWithRawPath forServer(String rawPath) { return forServer(rawPath, false); } - @Nullable - private static RequestTarget forServer(String rawPath, boolean allowSemicolonInPathComponent) { - final RequestTarget res = DefaultRequestTarget.forServer(rawPath, allowSemicolonInPathComponent, false); - if (res != null) { - logger.info("forServer({}) => path: {}, query: {}", rawPath, res.path(), res.query()); + private static RequestTargetWithRawPath forServer(String rawPath, boolean allowSemicolonInPathComponent) { + final RequestTarget target = DefaultRequestTarget.forServer( + rawPath, + allowSemicolonInPathComponent, + false); + if (target != null) { + logger.info("forServer({}) => path: {}, query: {}", rawPath, target.path(), target.query()); } else { logger.info("forServer({}) => null", rawPath); } - return res; + return new RequestTargetWithRawPath(rawPath, target); } - @Nullable - private static RequestTarget forClient(String rawPath) { + private static RequestTargetWithRawPath forClient(String rawPath) { return forClient(rawPath, null); } - @Nullable - private static RequestTarget forClient(String rawPath, @Nullable String prefix) { - final RequestTarget res = DefaultRequestTarget.forClient(rawPath, prefix); - if (res != null) { - logger.info("forClient({}, {}) => path: {}, query: {}, fragment: {}", rawPath, prefix, res.path(), - res.query(), res.fragment()); + private static RequestTargetWithRawPath forClient(String rawPath, @Nullable String prefix) { + final RequestTarget target = DefaultRequestTarget.forClient(rawPath, prefix); + if (target != null) { + logger.info("forClient({}, {}) => path: {}, query: {}, fragment: {}", + rawPath, prefix, target.path(), + target.query(), target.fragment()); } else { logger.info("forClient({}, {}) => null", rawPath, prefix); } - return res; + return new RequestTargetWithRawPath(null, target); } private static String toAbsolutePath(String pattern) { diff --git a/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java b/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java index 3202a4f649a..0f4d44f9d81 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/common/util/KeyStoreUtilTest.java @@ -25,8 +25,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.internal.common.util.KeyStoreUtil.KeyPair; class KeyStoreUtilTest { // The key store files used in this test case were generated with the following commands: @@ -73,10 +73,10 @@ class KeyStoreUtilTest { void shouldLoadKeyStoreWithOneKeyPair(String filename, @Nullable String keyStorePassword, @Nullable String keyPassword) throws Exception { - final KeyPair keyPair = KeyStoreUtil.load(getFile(filename), - underscoreToNull(keyStorePassword), - underscoreToNull(keyPassword), - null /* no alias */); + final TlsKeyPair keyPair = KeyStoreUtil.load(getFile(filename), + underscoreToNull(keyStorePassword), + underscoreToNull(keyPassword), + null /* no alias */); assertThat(keyPair.certificateChain()).hasSize(1).allSatisfy(cert -> { assertThat(cert.getSubjectX500Principal().getName()).isEqualTo("CN=foo.com"); }); @@ -85,7 +85,7 @@ void shouldLoadKeyStoreWithOneKeyPair(String filename, @ParameterizedTest @CsvSource({"first, foo.com", "second, bar.com"}) void shouldLoadKeyStoreWithTwoKeyPairsIfAliasIsGiven(String alias, String expectedCN) throws Exception { - final KeyPair keyPair = KeyStoreUtil.load(getFile("keystore-two-keys.p12"), + final TlsKeyPair keyPair = KeyStoreUtil.load(getFile("keystore-two-keys.p12"), "my-second-password", null, alias); diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceResponseConverterTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceResponseConverterTest.java index 5f60ddb375c..723a1cedd33 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceResponseConverterTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceResponseConverterTest.java @@ -416,15 +416,13 @@ public ResponseEntity expectSpecifiedNoContent() { public ResponseEntity expectNotModified() { // Will send '304 Not Modified' because ResponseEntity overrides the @StatusCode // annotation. - return ResponseEntity.of(ResponseHeaders.of(HttpStatus.NOT_MODIFIED)); + return ResponseEntity.of(HttpStatus.NOT_MODIFIED); } @Get("/expect-unauthorized") public ResponseEntity expectUnauthorized() { // Will send '401 Unauthorized' because the content of ResponseEntity is HttpResponse. - return ResponseEntity.of( - ResponseHeaders.of(HttpStatus.OK), - HttpResponse.of(HttpStatus.UNAUTHORIZED)); + return ResponseEntity.of(HttpStatus.OK, HttpResponse.of(HttpStatus.UNAUTHORIZED)); } @Get("/expect-no-content-from-converter") diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceTest.java index e078a605b07..bb5f68a8403 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedServiceTest.java @@ -900,7 +900,7 @@ public static class MyAnnotatedService15 { @Path("/response-entity-void") public ResponseEntity responseEntityVoid(RequestContext ctx) { validateContext(ctx); - return ResponseEntity.of(ResponseHeaders.of(HttpStatus.OK)); + return ResponseEntity.of(HttpStatus.OK); } @Get @@ -914,15 +914,14 @@ public ResponseEntity responseEntityString(RequestContext ctx, @Param("n @Path("/response-entity-status") public ResponseEntity responseEntityResponseData(RequestContext ctx) { validateContext(ctx); - return ResponseEntity.of(ResponseHeaders.of(HttpStatus.MOVED_PERMANENTLY)); + return ResponseEntity.of(HttpStatus.MOVED_PERMANENTLY); } @Get @Path("/response-entity-http-response") public ResponseEntity responseEntityHttpResponse(RequestContext ctx) { validateContext(ctx); - return ResponseEntity.of(ResponseHeaders.of(HttpStatus.OK), - HttpResponse.of(HttpStatus.UNAUTHORIZED)); + return ResponseEntity.of(HttpStatus.OK, HttpResponse.of(HttpStatus.UNAUTHORIZED)); } } diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/ResponseEntityUtilTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/ResponseEntityUtilTest.java index 2417ebd9605..2a6b459464c 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/ResponseEntityUtilTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/ResponseEntityUtilTest.java @@ -72,7 +72,7 @@ void useNegotiatedResponseMediaType() { .routingResult(routingResult) .build(); - final ResponseEntity result = ResponseEntity.of(ResponseHeaders.of(HttpStatus.OK)); + final ResponseEntity result = ResponseEntity.of(HttpStatus.OK); final ResponseHeaders actual = ResponseEntityUtil.buildResponseHeaders(ctx, result); assertThat(actual.contentType()).isEqualTo(MediaType.JSON_UTF_8); } diff --git a/core/src/test/java/com/linecorp/armeria/server/RawPathTest.java b/core/src/test/java/com/linecorp/armeria/server/RawPathTest.java new file mode 100644 index 00000000000..2a70aca2ea2 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/RawPathTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class RawPathTest { + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.serviceUnder("/", (ctx, req) -> { + final String rawPath = ctx.routingContext().requestTarget().rawPath(); + assertThat(rawPath).isNotNull(); + assertThat(ctx.rawPath()).isEqualTo(rawPath); + + final String expectedRawPath = ctx.request().headers().get("X-Raw-Path"); + assertThat(rawPath).isEqualTo(expectedRawPath); + return HttpResponse.of(HttpStatus.OK); + }); + } + }; + + @ParameterizedTest + @ValueSource(strings = { + "/", + "//", + "/service//foo", + "/service/foo..bar", + "/service..hello/foobar", + "/service//test//////a/", + "/service//test//////a/?flag=hello", + "/service/foo:bar", + "/service/foo::::::bar", + "/another/foo/", + "/cache/v1.0/rnd_team/get/krisjey:56578015655:1223", + "/signout/56578015655?crumb=s-1475829101-cec4230588-%E2%98%83", + "/search/num=20&newwindow=1&espv=2&q=url+path+colon&oq=url+path+colon&gs_l=serp.3" + + "..0i30k1.80626.89265.0.89464.18.16.1.1.1.0.154.1387.0j12.12.0....0...1c.1j4.64.serp" + + "..4.14.1387...0j35i39k1j0i131k1j0i19k1j0i30i19k1j0i8i30i19k1j0i5i30i19k1j0i8i10i30i19k1" + + ".Z6SsEq-rZDw", + "/service/foo*bar4", + "/gwturl#user:45/comments", + "/service:name/hello", + "/service::::name/hello", + "/..service/foobar1", + "/service../foobar2", + "/service/foobar3..", + "/service/foo|bar5", + "/service/foo\\bar6", + "/\\\\", + "/\"?\"", + "/service/foo>bar", + "/service/foo { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("default:" + commonName); + }) + .virtualHost("api.example.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("nested:" + commonName); + }) + .and() + .virtualHost("*.example.com") + .service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of("wild:" + commonName); + }); + } + }; + + @RegisterExtension + static final ServerExtension certRenewableServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.tlsProvider(settableTlsProvider); + sb.service("/", (ctx, req) -> { + final String commonName = CertificateUtil.getCommonName(ctx.sslSession()); + return HttpResponse.of(commonName); + }); + } + }; + + private static final SettableTlsProvider settableTlsProvider = new SettableTlsProvider(); + + @BeforeEach + void setUp() { + settableTlsProvider.set(null); + } + + @Test + void testDefault() { + final BlockingWebClient client = WebClient.builder(server.uri(SessionProtocol.HTTPS)) + .factory(ClientFactory.insecure()) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo("default:default"); + } + + @CsvSource({ + "example.com, wild:example.com", + "api.example.com, nested:api.example.com", + "foo.example.com, wild:*.example.com", + "example.org, default:default", + "api.example.org, default:default", + "foo.example.org, default:default", + "bar.example.org, default:default", + "baz.bar.example.org, default:default" + }) + @ParameterizedTest + void wildcardMatch(String host, String expected) { + try (ClientFactory factory = ClientFactory.builder() + .tlsNoVerify() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + assertThat(WebClient.builder("https://" + host + ':' + server.httpsPort()) + .factory(factory) + .build() + .blocking() + .get("/") + .contentUtf8()).isEqualTo(expected); + } + } + + @Test + void shouldUseNewTlsKeyPair() { + for (String host : ImmutableList.of("foo.com", "bar.com")) { + settableTlsProvider.set(TlsKeyPair.ofSelfSigned(host)); + try (ClientFactory factory = ClientFactory.builder() + .tlsNoVerify() + .addressResolverGroupFactory( + unused -> MockAddressResolverGroup.localhost()) + .build()) { + final BlockingWebClient client = WebClient.builder(certRenewableServer.httpsUri()) + .factory(factory) + .build() + .blocking(); + assertThat(client.get("/").contentUtf8()).isEqualTo(host); + } + } + } + + @Test + void disallowTlsProviderWhenTlsSettingsIsSet() { + assertThatThrownBy(() -> { + Server.builder() + .tls(TlsKeyPair.ofSelfSigned()) + .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned())) + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings with a TlsProvider"); + + assertThatThrownBy(() -> { + Server.builder() + .tlsSelfSigned() + .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned())) + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings with a TlsProvider"); + + assertThatThrownBy(() -> { + Server.builder() + .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned())) + .virtualHost("example.com") + .tls(TlsKeyPair.ofSelfSigned()) + .and() + .build(); + }).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot configure TLS settings with a TlsProvider"); + } + + private static class SettableTlsProvider implements TlsProvider { + + @Nullable + private volatile TlsKeyPair keyPair; + + @Override + public TlsKeyPair keyPair(String hostname) { + return keyPair; + } + + public void set(@Nullable TlsKeyPair keyPair) { + this.keyPair = keyPair; + } + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java b/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java new file mode 100644 index 00000000000..5c5ceacaefb --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.common.util.TlsEngineType; + +class TlsProviderMappingTest { + + @Test + void testNoDefault() { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair("example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("api.example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("foo.com", TlsKeyPair.ofSelfSigned()) + .keyPair("*.foo.com", TlsKeyPair.ofSelfSigned()) + .build(); + final TlsProviderMapping mapping = new TlsProviderMapping(tlsProvider, + TlsEngineType.OPENSSL, + ServerTlsConfig.builder().build(), + Flags.meterRegistry()); + assertThat(mapping.map("example.com")).isNotNull(); + assertThat(mapping.map("api.example.com")).isNotNull(); + assertThatThrownBy(() -> mapping.map("web.example.com")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No TLS key pair found for web.example.com"); + assertThat(mapping.map("foo.com")).isNotNull(); + assertThat(mapping.map("bar.foo.com")).isNotNull(); + assertThatThrownBy(() -> mapping.map("baz.bar.foo.com")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No TLS key pair found for baz.bar.foo.com"); + } + + @Test + void testWithDefault() { + final TlsProvider tlsProvider = TlsProvider.builder() + .keyPair(TlsKeyPair.ofSelfSigned()) + .keyPair("example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("api.example.com", TlsKeyPair.ofSelfSigned()) + .keyPair("foo.com", TlsKeyPair.ofSelfSigned()) + .keyPair("*.foo.com", TlsKeyPair.ofSelfSigned()) + .build(); + final TlsProviderMapping mapping = new TlsProviderMapping(tlsProvider, + TlsEngineType.OPENSSL, + ServerTlsConfig.builder().build(), + Flags.meterRegistry()); + assertThat(mapping.map("example.com")).isNotNull(); + assertThat(mapping.map("api.example.com")).isNotNull(); + assertThat(mapping.map("web.example.com")).isNotNull(); + assertThat(mapping.map("foo.com")).isNotNull(); + assertThat(mapping.map("bar.foo.com")).isNotNull(); + assertThat(mapping.map("baz.bar.foo.com")).isNotNull(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java b/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java index 5d8e4a8cfdf..8cc6e3f8b1d 100644 --- a/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java @@ -107,7 +107,7 @@ void testAllConfigsAreSet() { .multipartUploadsLocation(multipartUploadsLocation) .requestIdGenerator(serviceRequestIdGenerator) .build(new TestService()) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(virtualHost.serviceConfigs()).hasSize(2); final ServiceConfig pathBar = virtualHost.serviceConfigs().get(0); diff --git a/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java b/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java index c211ad6a886..055b51b8bbc 100644 --- a/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java @@ -168,7 +168,7 @@ void virtualHostWithoutPattern() { Server.builder() .virtualHost("foo.com") .defaultHostname("foo.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h.hostnamePattern()).isEqualTo("foo.com"); assertThat(h.defaultHostname()).isEqualTo("foo.com"); } @@ -178,7 +178,7 @@ void virtualHostWithPattern() { final VirtualHost h = Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.foo.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h.hostnamePattern()).isEqualTo("*.foo.com"); assertThat(h.defaultHostname()).isEqualTo("bar.foo.com"); } @@ -189,14 +189,14 @@ void accessLoggerCustomization() { Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.foo.com") .accessLogger(host -> LoggerFactory.getLogger("customize.test")) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.accessLogger().getName()).isEqualTo("customize.test"); final VirtualHost h2 = Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.foo.com") .accessLogger(LoggerFactory.getLogger("com.foo.test")) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.accessLogger().getName()).isEqualTo("com.foo.test"); } @@ -258,13 +258,13 @@ void tlsAllowUnsafeCiphersCustomization(String templateTlsAllowUnsafeCiphers, switch (expectedOutcome) { case "success": virtualHostBuilder.build(serverBuilder.virtualHostTemplate, noopDependencyInjector, - null, ServerErrorHandler.ofDefault()); + null, ServerErrorHandler.ofDefault(), null); break; case "failure": assertThatThrownBy(() -> virtualHostBuilder.build(serverBuilder.virtualHostTemplate, noopDependencyInjector, null, - ServerErrorHandler.ofDefault())) + ServerErrorHandler.ofDefault(), null)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("TLS with a bad cipher suite"); break; @@ -304,7 +304,7 @@ void virtualHostWithMismatch() { assertThatThrownBy(() -> { Server.builder().virtualHost("foo.com") .defaultHostname("bar.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); }).isInstanceOf(IllegalArgumentException.class); } @@ -313,7 +313,7 @@ void virtualHostWithMismatch2() { assertThatThrownBy(() -> { Server.builder().virtualHost("*.foo.com") .defaultHostname("bar.com") - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); }).isInstanceOf(IllegalArgumentException.class); } @@ -327,7 +327,7 @@ void precedenceOfDuplicateRoute() throws Exception { final VirtualHost virtualHost = new VirtualHostBuilder(Server.builder(), true) .service(routeA, (ctx, req) -> HttpResponse.of(200)) .service(routeB, (ctx, req) -> HttpResponse.of(201)) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(virtualHost.serviceConfigs().size()).isEqualTo(2); final RoutingContext routingContext = new DefaultRoutingContext(virtualHost(), "example.com", RequestHeaders.of(HttpMethod.GET, "/"), @@ -343,11 +343,11 @@ void multipartUploadsLocationCustomization() { final Path multipartUploadsLocation = FileSystems.getDefault().getPath("logs", "access.log"); final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false) .multipartUploadsLocation(multipartUploadsLocation) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.multipartUploadsLocation()).isEqualTo(multipartUploadsLocation); final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.multipartUploadsLocation()).isEqualTo(template.multipartUploadsLocation()); } @@ -356,11 +356,11 @@ void defaultLogNameCustomization() { final String defaultLogName = "test"; final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false) .defaultLogName(defaultLogName) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.defaultLogName()).isEqualTo(defaultLogName); final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.defaultLogName()).isEqualTo(template.defaultLogName()); } @@ -369,11 +369,11 @@ void successFunctionCustomization() { final SuccessFunction successFunction = (ctx, log) -> false; final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false) .successFunction(successFunction) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h1.successFunction()).isEqualTo(successFunction); final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false) - .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault()); + .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null); assertThat(h2.successFunction()).isEqualTo(template.successFunction()); } } diff --git a/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java b/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java index 947510695ba..58b449533cc 100644 --- a/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/cors/CorsServerErrorHandlerTest.java @@ -20,10 +20,13 @@ import java.util.function.Function; +import javax.annotation.Nonnull; + import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpHeaderNames; @@ -31,6 +34,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.internal.testing.AnticipatedException; import com.linecorp.armeria.server.HttpResponseException; import com.linecorp.armeria.server.HttpService; import com.linecorp.armeria.server.HttpStatusException; @@ -51,23 +55,50 @@ protected void configure(ServerBuilder sb) throws Exception { HttpResponseException.of( HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR)), false); - addCorsServiceWithException(sb, myService, "/cors_status_exception-route", + addCorsServiceWithException(sb, myService, "/cors_status_exception_route", HttpStatusException.of(HttpStatus.INTERNAL_SERVER_ERROR), true); - addCorsServiceWithException(sb, myService, "/cors_response_exception-route", + addCorsServiceWithException(sb, myService, "/cors_response_exception_route", HttpResponseException.of( HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR)), true); + + sb.service("/cors_service_exception_throw", ((HttpService) (ctx, req) -> { + throw new RuntimeException("Service exception"); + }).decorate(corsDecorator())); + + sb.service("/cors_service_exception_return", ((HttpService) (ctx, req) -> { + return HttpResponse.ofFailure(new RuntimeException("Service exception")); + }).decorate(corsDecorator())); + } + }; + + @RegisterExtension + static ServerExtension serverWithErrorHandler = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.decorator(corsDecorator()); + sb.service("/cors_service_exception_throw", (ctx, req) -> { + throw new AnticipatedException("Service exception"); + }); + + sb.service("/cors_service_exception_return", (ctx, req) -> { + return HttpResponse.ofFailure(new AnticipatedException("Service exception")); + }); + sb.decorator("/cors_decorator_exception_throw", (delegate, ctx, req) -> { + throw new AnticipatedException("Service exception"); + }); + + sb.errorHandler((ctx, cause) -> { + if (cause instanceof AnticipatedException) { + return HttpResponse.of(HttpStatus.SERVICE_UNAVAILABLE); + } + return null; + }); } }; private static void addCorsServiceWithException(ServerBuilder sb, HttpService myService, String pathPattern, Exception exception, boolean useRouteDecorator) { - final Function corsService = - CorsService.builder("http://example.com") - .allowRequestMethods(HttpMethod.POST, HttpMethod.GET) - .allowRequestHeaders("allow_request_header") - .exposeHeaders("expose_header_1", "expose_header_2") - .preflightResponseHeader("x-preflight-cors", "Hello CORS") - .newDecorator(); + final Function corsService = corsDecorator(); if (useRouteDecorator) { sb.decorator(pathPattern, corsService); sb.decorator(pathPattern, (delegate, ctx, req) -> { @@ -82,6 +113,18 @@ private static void addCorsServiceWithException(ServerBuilder sb, HttpService my sb.service(pathPattern, myService); } + @Nonnull + private static Function corsDecorator() { + final Function corsService = + CorsService.builder("http://example.com") + .allowRequestMethods(HttpMethod.POST, HttpMethod.GET) + .allowRequestHeaders("allow_request_header") + .exposeHeaders("expose_header_1", "expose_header_2") + .preflightResponseHeader("x-preflight-cors", "Hello CORS") + .newDecorator(); + return corsService; + } + private static AggregatedHttpResponse request(WebClient client, HttpMethod method, String path, String origin, String requestMethod) { return client.execute(RequestHeaders.of( @@ -97,15 +140,54 @@ private static AggregatedHttpResponse preflightRequest(WebClient client, String @ParameterizedTest @CsvSource({ "/cors_status_exception", - "/cors_status_exception-route", + "/cors_status_exception_route", "/cors_response_exception", - "/cors_response_exception-route" + "/cors_response_exception_route", }) - void testCorsHeaderWithException(String path) { + void shouldNotHandlePreflightRequest(String path) { final WebClient client = server.webClient(); final AggregatedHttpResponse response = preflightRequest(client, path, "http://example.com", "GET"); assertThat(response.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)).isNull(); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull(); + } + + /** + * Test a simple request + * that does not trigger a CORS preflight. + */ + @ParameterizedTest + @CsvSource({ + "/cors_service_exception_throw", + "/cors_service_exception_return", + }) + void testSimpleRequest(String path) { + final BlockingWebClient client = server.blockingWebClient(cb -> { + cb.addHeader(HttpHeaderNames.ORIGIN, "http://example.com"); + }); + final AggregatedHttpResponse response = client.get(path); + + assertThat(response.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)).isEqualTo( + "allow_request_header"); + assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo( + "http://example.com"); + } + + @ParameterizedTest + @CsvSource({ + "/cors_service_exception_throw", + "/cors_service_exception_return", + "/cors_decorator_exception_throw" + }) + void testCorsHeaderWithExceptionErrorHandler(String path) { + final BlockingWebClient client = serverWithErrorHandler.blockingWebClient(cb -> { + cb.addHeader(HttpHeaderNames.ORIGIN, "http://example.com"); + }); + final AggregatedHttpResponse response = client.get(path); + + assertThat(response.status()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS)).isEqualTo( "allow_request_header"); assertThat(response.headers().get(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo( diff --git a/core/src/test/java/com/linecorp/armeria/server/logging/AccessLoggerIntegrationTest.java b/core/src/test/java/com/linecorp/armeria/server/logging/AccessLoggerIntegrationTest.java index bfc33e1dfe2..c451974526d 100644 --- a/core/src/test/java/com/linecorp/armeria/server/logging/AccessLoggerIntegrationTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/logging/AccessLoggerIntegrationTest.java @@ -19,48 +19,109 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.RequestContext; -import com.linecorp.armeria.common.logging.RequestLog; +import com.linecorp.armeria.server.HttpService; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.TransientHttpService; +import com.linecorp.armeria.server.TransientServiceOption; import com.linecorp.armeria.testing.junit5.server.ServerExtension; class AccessLoggerIntegrationTest { - private static final AtomicReference CTX_REF = new AtomicReference<>(); + private static final AtomicReference REQUEST_CONTEXT_REFERENCE = new AtomicReference<>(); + private static final AtomicInteger CONTEXT_HOOK_COUNTER = new AtomicInteger(0); + + private static final AccessLogWriter ACCESS_LOG_WRITER = log -> + REQUEST_CONTEXT_REFERENCE.set(RequestContext.currentOrNull()); + + private static final Supplier CONTENT_HOOK = () -> { + CONTEXT_HOOK_COUNTER.incrementAndGet(); + return () -> {}; + }; + + private static final HttpService BASE_SERVICE = ((HttpService) (ctx, req) -> HttpResponse.of(200)) + .decorate((delegate, ctx, req) -> { + ctx.hook(CONTENT_HOOK); + return delegate.serve(ctx, req); + }); @RegisterExtension - static ServerExtension server = new ServerExtension() { + static final ServerExtension server = new ServerExtension() { @Override - protected void configure(ServerBuilder sb) throws Exception { - sb.service("/", (ctx, req) -> HttpResponse.of(200)); - sb.accessLogWriter(new AccessLogWriter() { - @Override - public void log(RequestLog log) { - CTX_REF.set(RequestContext.currentOrNull()); - } - }, false); + protected void configure(ServerBuilder sb) { + sb.route().path("/default-service") + .build(BASE_SERVICE); + sb.route().path("/default-service-with-access-log-writer") + .accessLogWriter(ACCESS_LOG_WRITER, false) + .build(BASE_SERVICE); + sb.route().path("/transit-service") + .build(BASE_SERVICE.decorate(TransientHttpService.newDecorator())); + sb.route().path("/transit-service-with-access-logger") + .accessLogWriter(ACCESS_LOG_WRITER, false) + .build(BASE_SERVICE.decorate(TransientHttpService.newDecorator())); + sb.route().path("/transit-service-with-access-log-option") + .build(BASE_SERVICE.decorate( + TransientHttpService.newDecorator(TransientServiceOption.WITH_ACCESS_LOGGING)) + ); + sb.route().path("/transit-service-with-access-log-option-and-access-logger") + .accessLogWriter(ACCESS_LOG_WRITER, false) + .build(BASE_SERVICE.decorate( + TransientHttpService.newDecorator(TransientServiceOption.WITH_ACCESS_LOGGING)) + ); } }; @BeforeEach - void beforeEach() { - CTX_REF.set(null); + void resetState() { + REQUEST_CONTEXT_REFERENCE.set(null); + CONTEXT_HOOK_COUNTER.set(0); } - @Test - void testAccessLogger() throws Exception { - assertThat(server.blockingWebClient().get("/").status().code()).isEqualTo(200); - assertThat(server.requestContextCaptor().size()).isEqualTo(1); + @CsvSource({ + "/default-service, false", + "/default-service-with-access-log-writer, true", + "/transit-service, false", + "/transit-service-with-access-logger, false", + "/transit-service-with-access-log-option, false", + "/transit-service-with-access-log-option-and-access-logger, true" + }) + @ParameterizedTest + void testAccessLogger(String path, boolean shouldWriteAccessLog) throws Exception { + assertThat(server.blockingWebClient().get(path).status().code()) + .as("Response status for path: %s", path) + .isEqualTo(200); + + assertThat(server.requestContextCaptor().size()) + .as("Expected exactly one captured context for path: %s", path) + .isEqualTo(1); + final ServiceRequestContext ctx = server.requestContextCaptor().poll(); - assertThat(ctx).isNotNull(); - await().untilAsserted(() -> assertThat(CTX_REF).hasValue(ctx)); + assertThat(ctx) + .as("ServiceRequestContext should not be null for path: %s", path) + .isNotNull(); + + if (shouldWriteAccessLog) { + await().untilAsserted(() -> + assertThat(REQUEST_CONTEXT_REFERENCE) + .as("Expected request context to be set for path: %s", path) + .hasValue(ctx) + ); + } + + final int expectedHookCounter = shouldWriteAccessLog ? 1 : 0; + assertThat(CONTEXT_HOOK_COUNTER) + .as("Context hook counter mismatch for path: %s", path) + .hasValue(expectedHookCounter); } } diff --git a/dependencies.toml b/dependencies.toml index fbfc7650d50..a0b8b740fcf 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -2,16 +2,16 @@ akka = "2.6.20" akka-http-cors = "1.0.0" akka-grpc-runtime = "1.0.3" -apache-httpclient5 = "5.3.1" +apache-httpclient5 = "5.4.1" apache-httpclient4 = "4.5.14" -asm = "9.7" +asm = "9.7.1" assertj = "3.26.3" -awaitility = "4.2.1" -blockhound = "1.0.9.RELEASE" +awaitility = "4.2.2" +blockhound = "1.0.10.RELEASE" bouncycastle = "1.70" brave5 = "5.18.1" brave6 = "6.0.3" -brotli4j = "1.16.0" +brotli4j = "1.17.0" # Don"t upgrade bucket4j to 8.x that requires Java 11. The module name also has been changed to "com.bucket4j" bucket4j = "7.6.0" # Don"t upgrade Caffeine to 3.x that requires Java 11. @@ -19,35 +19,36 @@ caffeine = "2.9.3" cglib = "3.3.0" checkerframework = "2.5.6" checkstyle = "10.3.2" -controlplane = "1.0.45" -curator = "5.7.0" -dagger = "2.51.1" +context-propagation = "1.1.1" +controlplane = "1.0.46" +curator = "5.7.1" +dagger = "2.52" # Don't upgrade DGS to 9.x that requires GraphQL-Java 22 dgs = "8.6.1" dropwizard1 = "1.3.29" dropwizard2 = "2.1.12" -dropwizard-metrics = "4.2.26" -errorprone = "2.29.2" -errorprone-gradle-plugin = "4.0.1" -eureka = "2.0.3" -fastutil = "8.5.14" +dropwizard-metrics = "4.2.28" +errorprone = "2.35.1" +errorprone-gradle-plugin = "4.1.0" +eureka = "2.0.4" +fastutil = "8.5.15" finagle = "24.2.0" findbugs = "3.0.2" futures-completable = "0.3.6" futures-extra = "4.3.3" -gax-grpc = "2.51.0" +gax-grpc = "2.57.0" # Don"t upgrade graphql-java to 21.0 that requires Java 11 graphql-java = "20.4" -graphql-kotlin = "7.1.4" -grpc-java = "1.65.1" +graphql-kotlin = "8.2.1" +grpc-java = "1.68.1" grpc-kotlin = "1.4.1" -guava = "33.2.1-jre" -hamcrest = "2.2" +guava = "33.3.1-jre" +hamcrest = "3.0" hbase = "1.2.6" hibernate-validator6 = "6.2.5.Final" hibernate-validator8 = "8.0.1.Final" j2objc = "3.0.0" -jackson = "2.17.2" +jackson = "2.18.1" jakarta-inject = "2.0.1" jakarta-validation = "3.1.0" jakarta-websocket = "2.2.0" @@ -60,14 +61,14 @@ jctools = "4.0.5" # Find the latest version of the major 10 https://central.sonatype.com/artifact/org.eclipse.jetty/jetty-server/ jetty10 = "10.0.20" # Find the latest version of the major 10 https://central.sonatype.com/artifact/org.eclipse.jetty/apache-jstl/ -jetty10-jstl = "10.0.20" -jetty11 = "11.0.22" +jetty10-jstl = "10.0.24" +jetty11 = "11.0.24" jetty11-jstl = "11.0.0" -jetty12 = "12.0.12" +jetty12 = "12.0.14" jetty93 = "9.3.30.v20211001" jetty94 = "9.4.55.v20240627" jetty-alpn-api = "1.1.3.v20160715" -jkube = "1.16.2" +jkube = "1.17.0" jmh-core = "1.37" jmh-gradle-plugin = "0.7.2" joor = "0.9.15" @@ -75,32 +76,33 @@ joor = "0.9.15" json-unit = "2.38.0" jsoup = "1.18.1" junit4 = "4.13.2" -junit5 = "5.10.3" +junit5 = "5.11.3" # Don't upgrade junit-pioneer to 2.x.x that requires Java 11 junit-pioneer = "1.9.1" jwt = "4.4.0" -kafka = "3.8.0" -kotlin = "1.9.23" -kotlin-coroutine = "1.8.1" +kafka = "3.8.1" +kotlin = "1.9.25" +kotlin-coroutine = "1.9.0" krotodc = "1.1.1" ktlint-gradle-plugin = "12.1.1" -kubernetes-client = "6.13.1" +kubernetes-client = "6.13.4" logback12 = "1.2.13" logback13 = "1.3.14" logback14 = "1.4.14" -micrometer = "1.13.2" -micrometer-tracing = "1.3.2" +logback15 = "1.5.12" +micrometer = "1.13.6" +micrometer-tracing = "1.3.5" micrometer-docs-generator = "1.0.2" # Don't uprade mockito to 5.x.x that requires Java 11 mockito = "4.11.0" monix = "3.4.1" -munit = "1.0.0" -netty = "4.1.112.Final" +munit = "1.0.2" +netty = "4.1.115.Final" netty-incubator-transport-native-io_uring = "0.0.25.Final" nexus-publish = "2.0.0" -node-gradle-plugin = "7.0.2" -nullaway = "0.11.1" -nullaway-gradle-plugin = "2.0.0" +node-gradle-plugin = "7.1.0" +nullaway = "0.12.1" +nullaway-gradle-plugin = "2.1.0" okhttp2 = "2.7.5" # For testing okhttp3 = { strictly = "3.14.9" } # Not just for testing. Used in the Retrofit mudule. okhttp4 = "4.12.0" # For testing @@ -110,19 +112,19 @@ osdetector = "1.7.3" # Used for kubernetes-chaos-tests picocli = "4.7.6" proguard = "7.5.0" -prometheus = "1.3.1" +prometheus = "1.3.2" prometheus-legacy = "0.16.0" # Ensure that we use the same Protobuf version as what gRPC depends on. # See: https://github.com/grpc/grpc-java/blob/master/gradle/libs.versions.toml # (Switch to the right tag and look for "protobuf".) # e.g. https://github.com/grpc/grpc-java/blob/v1.48.0/gradle/libs.versions.toml -protobuf = "3.25.1" +protobuf = "3.25.5" protobuf-gradle-plugin = "0.8.19" -protobuf-jackson = "2.5.0" +protobuf-jackson = "2.6.0" reactive-grpc = "1.2.4" reactive-streams = "1.0.4" -reactor = "3.6.8" -reactor-kotlin = "1.2.2" +reactor = "3.6.11" +reactor-kotlin = "1.2.3" # Upgrade once https://github.com/ronmamo/reflections/issues/279 is fixed. reflections = "0.9.11" resilience4j = "2.2.0" @@ -133,27 +135,27 @@ resteasy-jboss-logging = "3.4.3.Final" resteasy-jboss-logging-annotations = "2.2.1.Final" retrofit2 = "2.11.0" rxjava2 = "2.2.21" -rxjava3 = "3.1.8" -sangria = "4.1.1" +rxjava3 = "3.1.9" +sangria = "4.2.2" sangria-slowlog = "3.0.0" scala-collection-compat = "2.12.0" scala-java8-compat = "1.0.2" -scala212 = "2.12.19" -scala213 = "2.13.14" -scala3 = "3.4.2" +scala212 = "2.12.20" +scala213 = "2.13.15" +scala3 = "3.6.1" scalafmt-gradle-plugin = "1.16.2" scalapb = "0.11.17" scalapb-json = "0.12.1" shadow-gradle-plugin = "7.1.2" # Don"t upgrade to 8.x that requires Java 11. shibboleth-utilities = "7.5.2" -snappy = "1.1.10.5" +snappy = "1.1.10.7" slf4j = "1.7.36" -slf4j2 = "2.0.13" -spring6 = "6.1.11" +slf4j2 = "2.0.16" +spring6 = "6.1.14" spring-boot2 = "2.7.18" -spring-boot3 = "3.3.2" -testcontainers = "1.20.1" +spring-boot3 = "3.3.5" +testcontainers = "1.20.3" thrift09 = { strictly = "0.9.3-1" } thrift012 = { strictly = "0.12.0" } thrift013 = { strictly = "0.13.0" } @@ -165,8 +167,8 @@ thrift018 = { strictly = "0.18.1" } thrift019 = { strictly = "0.19.0" } thrift020 = { strictly = "0.20.0" } tomcat8 = "8.5.100" -tomcat9 = "9.0.91" -tomcat10 = "10.1.26" +tomcat9 = "9.0.96" +tomcat10 = "10.1.31" xml-apis = "1.4.01" # Ensure that we use the same ZooKeeper version as what Curator depends on. # See: https://github.com/apache/curator/blob/master/pom.xml @@ -320,6 +322,10 @@ version.ref = "checkerframework" module = "com.puppycrawl.tools:checkstyle" version.ref = "checkstyle" +[libraries.context-propagation] +module = "io.micrometer:context-propagation" +version.ref = "context-propagation" + [libraries.controlplane-api] module = "io.envoyproxy.controlplane:api" version.ref = "controlplane" @@ -859,6 +865,11 @@ module = "ch.qos.logback:logback-classic" version.ref = "logback14" javadocs = "https://www.javadoc.io/doc/ch.qos.logback/logback-classic/1.4.7/" +[libraries.logback15] +module = "ch.qos.logback:logback-classic" +version.ref = "logback15" +javadocs = "https://www.javadoc.io/doc/ch.qos.logback/logback-classic/1.5.12/" + [libraries.micrometer-core] module = "io.micrometer:micrometer-core" version.ref = "micrometer" @@ -981,7 +992,10 @@ version.ref = "protobuf-gradle-plugin" [libraries.protobuf-jackson] module = "org.curioswitch.curiostack:protobuf-jackson" version.ref = "protobuf-jackson" -exclusions = "javax.annotation:javax.annotation-api" +exclusions = [ + 'com.google.protobuf:protobuf-java', + "javax.annotation:javax.annotation-api", +] javadocs = "https://developers.curioswitch.org/apidocs/java/" [libraries.reactor-core] diff --git a/dropwizard2/src/test/java/com/linecorp/armeria/dropwizard/DropwizardArmeriaApplicationTest.java b/dropwizard2/src/test/java/com/linecorp/armeria/dropwizard/DropwizardArmeriaApplicationTest.java index d8227345392..006bc6b41d1 100644 --- a/dropwizard2/src/test/java/com/linecorp/armeria/dropwizard/DropwizardArmeriaApplicationTest.java +++ b/dropwizard2/src/test/java/com/linecorp/armeria/dropwizard/DropwizardArmeriaApplicationTest.java @@ -18,18 +18,21 @@ import static io.dropwizard.testing.ResourceHelpers.resourceFilePath; import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import io.dropwizard.testing.junit5.DropwizardAppExtension; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -@ExtendWith(DropwizardExtensionsSupport.class) class DropwizardArmeriaApplicationTest { static final DropwizardAppExtension appExtension = new DropwizardAppExtension<>(TestApplication.class, resourceFilePath("dropwizard-armeria-app.yaml")); + @BeforeAll + static void beforeAll() throws Exception { + appExtension.before(); + } + @Test void helloWorld() { final String content = appExtension diff --git a/examples/dropwizard/src/test/java/example/dropwizard/DropwizardArmeriaApplicationTest.java b/examples/dropwizard/src/test/java/example/dropwizard/DropwizardArmeriaApplicationTest.java index 514c4167542..8480f703592 100644 --- a/examples/dropwizard/src/test/java/example/dropwizard/DropwizardArmeriaApplicationTest.java +++ b/examples/dropwizard/src/test/java/example/dropwizard/DropwizardArmeriaApplicationTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import com.codahale.metrics.health.HealthCheck; import com.codahale.metrics.health.HealthCheck.Result; @@ -19,10 +18,8 @@ import example.dropwizard.health.PingCheck; import example.dropwizard.resources.JerseyResource; import io.dropwizard.testing.junit5.DropwizardAppExtension; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; -@ExtendWith(DropwizardExtensionsSupport.class) public class DropwizardArmeriaApplicationTest { public static final ResourceExtension RESOURCES = ResourceExtension.builder() .addResource(new JerseyResource()) @@ -35,7 +32,10 @@ public class DropwizardArmeriaApplicationTest { private static String endpoint; @BeforeAll - public static void setUp() { + public static void setUp() throws Throwable { + EXTENSION.before(); + RESOURCES.before(); + endpoint = "http://localhost:" + EXTENSION.getLocalPort(); client = EXTENSION.client().target(endpoint); } diff --git a/gradle.properties b/gradle.properties index e2f176e3e7a..7760cf281fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group=com.linecorp.armeria -version=1.30.2-SNAPSHOT +version=1.31.2-SNAPSHOT projectName=Armeria projectUrl=https://armeria.dev/ projectDescription=Asynchronous HTTP/2 RPC/REST client/server library built on top of Java 8, Netty, Thrift and gRPC diff --git a/gradle/scripts/.gitrepo b/gradle/scripts/.gitrepo index d770e09d55f..fc59a047a16 100644 --- a/gradle/scripts/.gitrepo +++ b/gradle/scripts/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/line/gradle-scripts branch = main - commit = 1f94acd56f170782ad291e4603384ad59cca4e9e - parent = d18437e44118f1367ce3cf2d7e5008552ebd7513 + commit = 597bb9e29378d56051db3ace62d5cbd81f3b1272 + parent = 7da456555cd58b9fe3cd387a7fe1003be7504411 method = merge - cmdver = 0.4.5 + cmdver = 0.4.6 diff --git a/gradle/scripts/lib/common-dependencies.gradle b/gradle/scripts/lib/common-dependencies.gradle index 19eaf73c5b2..12046f0781d 100644 --- a/gradle/scripts/lib/common-dependencies.gradle +++ b/gradle/scripts/lib/common-dependencies.gradle @@ -21,6 +21,7 @@ allprojects { p -> managedVersions = getManagedVersions(p.rootProject) findLibrary = this.&findLibrary.curry(p.rootProject) findPlugin = this.&findPlugin.curry(p.rootProject) + failOnVersionConflict = this.&failOnVersionConflict.curry(p) } } @@ -331,3 +332,36 @@ final class GentlePlainTextReporter implements Reporter { return delegate.getFileExtension() } } + +/** + * A custom version of failOnVersionConflict which can limit which dependencies should be checked for conflict. + * Heavily inspired by https://github.com/gradle/gradle/issues/8813. + */ +static def failOnVersionConflict(Project project, ProviderConvertible providerConvertible) { + return failOnVersionConflict(project, providerConvertible.asProvider()) +} + +static def failOnVersionConflict(Project project, Provider dependencyProvider) { + if (!dependencyProvider.isPresent()) { + return + } + def targetDependency = dependencyProvider.get() + project.configurations.configureEach { config -> + incoming.afterResolve { + resolutionResult.allComponents {ResolvedComponentResult result -> + if (selectionReason.conflictResolution && moduleVersion != null) { + // we don't care if the selected version is the one specified in dependencies.toml + if (targetDependency.module == moduleVersion.module && targetDependency.version != moduleVersion.version) { + def msg = "Project '${project.name}:${config.name}' resolution failed " + + "for '${targetDependency.module}' with '${getSelectionReason()}" + if (project.rootProject.hasProperty('debugDeps')) { + project.logger.lifecycle(msg) + } else { + throw new IllegalStateException(msg) + } + } + } + } + } + } +} diff --git a/gradle/scripts/lib/java-shade.gradle b/gradle/scripts/lib/java-shade.gradle index 33dd4b17a70..fd1ddd3ec55 100644 --- a/gradle/scripts/lib/java-shade.gradle +++ b/gradle/scripts/lib/java-shade.gradle @@ -6,7 +6,7 @@ import java.util.concurrent.atomic.AtomicInteger buildscript { repositories { gradlePluginPortal() - mavenCentral() + google() } dependencies { classpath "gradle.plugin.com.github.johnrengelman:shadow:${managedVersions['gradle.plugin.com.github.johnrengelman:shadow']}" @@ -88,7 +88,6 @@ configure(relocatedProjects) { group: 'Build', description: 'Extracts the shaded test JAR.', dependsOn: tasks.shadedTestJar) { - from(zipTree(tasks.shadedTestJar.archiveFile.get().asFile)) from(sourceSets.test.output.classesDirs) { // Add the JAR resources excluded in the 'shadedTestJar' task. @@ -335,7 +334,8 @@ private void configureShadowTask(Project project, ShadowJar task, boolean isMain private Configuration configureShadedTestImplementConfiguration( Project project, Project recursedProject = project, Set excludeRules = new HashSet<>(), - Set visitedProjects = new HashSet<>()) { + Set visitedProjects = new HashSet<>(), + boolean recursedProjectRelocated = true) { def shadedJarTestImplementation = project.configurations.getByName('shadedJarTestImplementation') @@ -364,14 +364,24 @@ private Configuration configureShadedTestImplementConfiguration( }.each { cfg -> cfg.allDependencies.each { dep -> if (dep instanceof ProjectDependency) { + if (!dep.dependencyProject.hasFlag('java')) { + // Do not add the dependencies of non-Java projects. + return + } // Project dependency - recurse later. // Note that we recurse later to have immediate module dependencies higher precedence. projectDependencies.add(dep) } else { // Module dependency - add. if (shadedDependencyNames.contains("${dep.group}:${dep.name}")) { - // Skip the shaded dependencies. - return + if (recursedProjectRelocated) { + // Skip the shaded dependencies. + return + } + throw new IllegalStateException( + "${recursedProject} has a shaded dependency: ${dep.group}:${dep.name} " + + "but it is not relocated. Please add a 'relocate' flag to " + + "${recursedProject} in settings.gradle.") } if (excludeRules.find { rule -> @@ -382,16 +392,23 @@ private Configuration configureShadedTestImplementConfiguration( } // Do not use `project.dependencies.add(name, dep)` that discards the classifier of // a dependency. See https://github.com/gradle/gradle/issues/23096 - project.configurations.getByName(shadedJarTestImplementation.name).dependencies.add(dep) + shadedJarTestImplementation.dependencies.add(dep) } } } // Recurse into the project dependencies. projectDependencies.each { ProjectDependency dep -> + if (!dep.dependencyProject.hasFlag('relocate')) { + shadedJarTestImplementation.dependencies.add( + project.dependencies.project(path: dep.dependencyProject.path)) + recursedProjectRelocated = false + } else { + recursedProjectRelocated = true + } configureShadedTestImplementConfiguration( project, dep.dependencyProject, - excludeRules + dep.excludeRules, visitedProjects) + excludeRules + dep.excludeRules, visitedProjects, recursedProjectRelocated) } return shadedJarTestImplementation diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f3d..2c3521197d7 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dedd5d1e69e..79eb9d003fe 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a42690..f5feea6d6b1 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30dbdee..9d21a21834d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java index 66965d7c038..0996ad7c8e8 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/ServerCallUtil.java @@ -18,7 +18,7 @@ import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; +import java.lang.reflect.Method; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.server.grpc.AbstractServerCall; @@ -34,8 +34,9 @@ final class ServerCallUtil { static { try { - delegateMH = MethodHandles.lookup().findVirtual(ForwardingServerCall.class, "delegate", - MethodType.methodType(ServerCall.class)); + final Method delegate = ForwardingServerCall.class.getDeclaredMethod("delegate"); + delegate.setAccessible(true); + delegateMH = MethodHandles.lookup().unreflect(delegate); } catch (NoSuchMethodException | IllegalAccessException e) { delegateMH = null; } diff --git a/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java b/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java index b69ff8ef5d1..7fd73938689 100644 --- a/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java +++ b/grpc/src/test/java/com/linecorp/armeria/server/grpc/DeferredListenerTest.java @@ -29,6 +29,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.google.common.util.concurrent.MoreExecutors; @@ -44,6 +46,7 @@ import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; +import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; import io.grpc.ServerCall; import io.netty.channel.EventLoop; import testing.grpc.Messages.SimpleRequest; @@ -60,10 +63,14 @@ void shouldHaveRequestContextInThread() { AsyncServerInterceptor.class.getName()); } - @Test - void shouldLazilyExecuteCallbacks() { + @ValueSource(booleans = { true, false }) + @ParameterizedTest + void shouldLazilyExecuteCallbacks(boolean wrap) { final EventLoop eventLoop = CommonPools.workerGroup().next(); - final UnaryServerCall serverCall = newServerCall(eventLoop, null); + ServerCall serverCall = newServerCall(eventLoop, null); + if (wrap) { + serverCall = new SimpleForwardingServerCall(serverCall) {}; + } assertListenerEvents(serverCall, eventLoop); final Executor blockingExecutor = diff --git a/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java b/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java index 2c2f76a95e4..5307cf68669 100644 --- a/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java +++ b/it/builders/src/test/java/com/linecorp/armeria/OverriddenBuilderMethodsReturnTypeTest.java @@ -45,56 +45,59 @@ class OverriddenBuilderMethodsReturnTypeTest { @Test void methodChaining() { - final Set excludedClasses = ImmutableSet.of("JsonLogFormatterBuilder", - "TextLogFormatterBuilder", - "PathStreamMessageBuilder", - "InputStreamStreamMessageBuilder", - "ContextPathAnnotatedServiceConfigSetters", - "ContextPathDecoratingBindingBuilder", - "ContextPathServiceBindingBuilder", - "ContextPathServicesBuilder", - "DecoratingServiceBindingBuilder", - "ServerBuilder", - "ServiceBindingBuilder", - "AnnotatedServiceBindingBuilder", - "VirtualHostAnnotatedServiceBindingBuilder", - "VirtualHostBuilder", - "VirtualHostContextPathDecoratingBindingBuilder", - "VirtualHostContextPathServiceBindingBuilder", - "VirtualHostContextPathServicesBuilder", - "VirtualHostDecoratingServiceBindingBuilder", - "VirtualHostServiceBindingBuilder", - "ChainedCorsPolicyBuilder", - "CorsPolicyBuilder", - "ConsulEndpointGroupBuilde", - "AbstractDnsResolverBuilder", - "AbstractRuleBuilder", - "AbstractRuleWithContentBuilder", - "DnsResolverGroupBuilder", - "AbstractCircuitBreakerMappingBuilder", - "CircuitBreakerMappingBuilder", - "CircuitBreakerRuleBuilder", - "CircuitBreakerRuleWithContentBuilder", - "AbstractDynamicEndpointGroupBuilder", - "DynamicEndpointGroupBuilder", - "DynamicEndpointGroupSetters", - "DnsAddressEndpointGroupBuilder", - "DnsEndpointGroupBuilder", - "DnsServiceEndpointGroupBuilder", - "DnsTextEndpointGroupBuilder", - "AbstractHealthCheckedEndpointGroupBuilder", - "HealthCheckedEndpointGroupBuilder", - "RetryRuleBuilder", - "RetryRuleWithContentBuilder", - "AbstractHeadersSanitizerBuilder", - "JsonHeadersSanitizerBuilder", - "TextHeadersSanitizerBuilder", - "EurekaEndpointGroupBuilder", - "KubernetesEndpointGroupBuilder", - "Resilience4jCircuitBreakerMappingBuilder", - "ZooKeeperEndpointGroupBuilder", - "AbstractCuratorFrameworkBuilder", - "ZooKeeperUpdatingListenerBuilder"); + final Set excludedClasses = ImmutableSet.of( + "AbstractCircuitBreakerMappingBuilder", + "AbstractCuratorFrameworkBuilder", + "AbstractDnsResolverBuilder", + "AbstractDynamicEndpointGroupBuilder", + "AbstractHeadersSanitizerBuilder", + "AbstractHealthCheckedEndpointGroupBuilder", + "AbstractRuleBuilder", + "AbstractRuleWithContentBuilder", + "AnnotatedServiceBindingBuilder", + "ChainedCorsPolicyBuilder", + "CircuitBreakerMappingBuilder", + "CircuitBreakerRuleBuilder", + "CircuitBreakerRuleWithContentBuilder", + "ClientTlsConfigBuilder", + "ConsulEndpointGroupBuilder", + "ContextPathAnnotatedServiceConfigSetters", + "ContextPathDecoratingBindingBuilder", + "ContextPathServiceBindingBuilder", + "ContextPathServicesBuilder", + "CorsPolicyBuilder", + "DecoratingServiceBindingBuilder", + "DnsAddressEndpointGroupBuilder", + "DnsEndpointGroupBuilder", + "DnsResolverGroupBuilder", + "DnsServiceEndpointGroupBuilder", + "DnsTextEndpointGroupBuilder", + "DynamicEndpointGroupBuilder", + "DynamicEndpointGroupSetters", + "EurekaEndpointGroupBuilder", + "HealthCheckedEndpointGroupBuilder", + "InputStreamStreamMessageBuilder", + "JsonHeadersSanitizerBuilder", + "JsonLogFormatterBuilder", + "KubernetesEndpointGroupBuilder", + "PathStreamMessageBuilder", + "Resilience4jCircuitBreakerMappingBuilder", + "RetryRuleBuilder", + "RetryRuleWithContentBuilder", + "ServerBuilder", + "ServerTlsConfigBuilder", + "ServiceBindingBuilder", + "TextHeadersSanitizerBuilder", + "TextLogFormatterBuilder", + "VirtualHostAnnotatedServiceBindingBuilder", + "VirtualHostBuilder", + "VirtualHostContextPathDecoratingBindingBuilder", + "VirtualHostContextPathServiceBindingBuilder", + "VirtualHostContextPathServicesBuilder", + "VirtualHostDecoratingServiceBindingBuilder", + "VirtualHostServiceBindingBuilder", + "ZooKeeperEndpointGroupBuilder", + "ZooKeeperUpdatingListenerBuilder"); final String packageName = "com.linecorp.armeria"; findAllClasses(packageName).stream() .map(ReflectionUtils::forName) diff --git a/it/logback1.5/build.gradle b/it/logback1.5/build.gradle new file mode 100644 index 00000000000..c90db1c84d9 --- /dev/null +++ b/it/logback1.5/build.gradle @@ -0,0 +1,34 @@ +// Mostly copied over from project(:logback1.4) to ensure compatibility +// with more recent logback versions + +dependencies { + testImplementation project(':thrift0.18') + api libs.logback15 +} + +def logback12Dir = "${rootProject.projectDir}/logback/logback12" +def logback13Dir = "${rootProject.projectDir}/logback/logback13" + +// Copy common files from logback. +task generateSources(type: Copy) { + from("${logback12Dir}/src/main/java") { + exclude "**/LoggingEventWrapper.java" + exclude "**/package-info.java" + } + from "${logback13Dir}/src/main/java" + into "${project.ext.genSrcDir}/main/java" +} + +task generateTestSources(type: Copy) { + from("${logback12Dir}/src/test/java") + into "${project.ext.genSrcDir}/test/java" +} + +ext { + testThriftSrcDirs = ["$logback12Dir/src/test/thrift"] +} + +tasks.compileJava.dependsOn(generateSources) +tasks.generateSources.dependsOn(generateTestSources) +tasks.compileTestJava.dependsOn(generateSources) +tasks.processTestResources.from "${logback12Dir}/src/test/resources" diff --git a/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java b/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java index 56ecf114893..9393d208f18 100644 --- a/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java +++ b/junit5/src/main/java/com/linecorp/armeria/internal/testing/SelfSignedCertificateRuleDelegate.java @@ -28,6 +28,7 @@ import java.time.temporal.TemporalAccessor; import java.util.Date; +import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.common.util.SelfSignedCertificate; @@ -50,6 +51,9 @@ public final class SelfSignedCertificateRuleDelegate { @Nullable private SelfSignedCertificate certificate; + @Nullable + private TlsKeyPair tlsKeyPair; + /** * Creates a new instance. */ @@ -205,6 +209,16 @@ public File privateKeyFile() { return ensureCertificate().privateKey(); } + /** + * Returns the {@link TlsKeyPair} of the self-signed certificate. + */ + public TlsKeyPair tlsKeyPair() { + if (tlsKeyPair == null) { + tlsKeyPair = TlsKeyPair.of(privateKey(), certificate()); + } + return tlsKeyPair; + } + private SelfSignedCertificate ensureCertificate() { checkState(certificate != null, "certificate not created"); return certificate; diff --git a/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java b/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java index e155fd62e82..d64c3e94f70 100644 --- a/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java +++ b/junit5/src/main/java/com/linecorp/armeria/testing/junit5/server/SelfSignedCertificateExtension.java @@ -26,6 +26,8 @@ import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionContext; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.internal.testing.SelfSignedCertificateRuleDelegate; import com.linecorp.armeria.testing.junit5.common.AbstractAllOrEachExtension; @@ -144,4 +146,12 @@ public PrivateKey privateKey() { public File privateKeyFile() { return delegate.privateKeyFile(); } + + /** + * Returns the {@link TlsKeyPair} of the self-signed certificate. + */ + @UnstableApi + public TlsKeyPair tlsKeyPair() { + return delegate.tlsKeyPair(); + } } diff --git a/kubernetes/src/main/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroup.java b/kubernetes/src/main/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroup.java index cdaeac87c8d..3cd08c9c1ed 100644 --- a/kubernetes/src/main/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroup.java +++ b/kubernetes/src/main/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroup.java @@ -269,6 +269,8 @@ public void eventReceived(Action action, Service service0) { if (podWatch0 != null) { podWatch0.close(); } + // Clear the podToNode map before starting a new pod watch. + podToNode.clear(); podWatch0 = watchPod(service0.getSpec().getSelector()); if (closed) { podWatch0.close(); @@ -384,6 +386,8 @@ public void eventReceived(Action action, Node node) { nodeToIp.remove(nodeName); break; } + // TODO(ikhoon): Reschedule the update after a certain delay since multiple websocket events + // are updated in a same task. maybeUpdateEndpoints(); } diff --git a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpClientProxyTest.java b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpClientProxyTest.java index 1b15450fa4e..d97b58e814d 100644 --- a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpClientProxyTest.java +++ b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpClientProxyTest.java @@ -30,6 +30,7 @@ */ package com.linecorp.armeria.client.kubernetes; +import static io.fabric8.kubernetes.client.utils.HttpClientUtils.basicCredentials; import static org.assertj.core.api.Assertions.assertThat; import java.net.InetSocketAddress; @@ -47,7 +48,9 @@ import io.fabric8.kubernetes.client.http.AbstractHttpClientProxyTest; import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.StandardHttpHeaders; import io.fabric8.mockwebserver.DefaultMockServer; +import io.fabric8.mockwebserver.utils.ResponseProvider; import okhttp3.Headers; import okhttp3.mockwebserver.RecordedRequest; @@ -71,19 +74,70 @@ protected HttpClient.Factory getHttpClientFactory() { return new ArmeriaHttpClientFactory(); } + /** + * Forked the upstream test case and wrapped client.sendAsync() with try-catch. + */ + @Override + @Test + @DisplayName("Proxied HttpClient with basic authorization adds required headers to the request") + protected void proxyConfigurationBasicAuthAddsRequiredHeaders() throws Exception { + server.expect().get().withPath("/").andReply(new ResponseProvider() { + @Override + public String getBody(RecordedRequest request) { + return "\n"; + } + + @Override + public void setHeaders(Headers headers) { + } + + @Override + public int getStatusCode(RecordedRequest request) { + return request.getHeader(StandardHttpHeaders.PROXY_AUTHORIZATION) != null ? 200 : 407; + } + + @Override + public Headers getHeaders() { + return new Headers.Builder().add("Proxy-Authenticate", "Basic").build(); + } + }).always(); + // Given + final HttpClient.Builder builder = + getHttpClientFactory().newBuilder() + .proxyAddress(new InetSocketAddress("localhost", server.getPort())) + .proxyAuthorization(basicCredentials("auth", "cred")); + try (HttpClient client = builder.build()) { + // When + try { + client.sendAsync(client.newHttpRequestBuilder() + .uri(String.format("http://0.0.0.0:%s/not-found", server.getPort())) + .build(), String.class) + .get(10L, TimeUnit.SECONDS); + } catch (ExecutionException e) { + // HttpProxyHandler raises an exception when the response is not 200 OK. + assertThat(e.getCause()).isInstanceOf(UnprocessedRequestException.class); + } + // Then + assertThat(server.getLastRequest()) + .extracting(RecordedRequest::getHeaders) + .returns("0.0.0.0:" + server.getPort(), h -> h.get("Host")) + .returns("Basic YXV0aDpjcmVk", h -> h.get("Proxy-Authorization")); + } + } + /** * Forked the upstream test case and wrapped client.sendAsync() with try-catch. */ @Override @Test @DisplayName("Proxied HttpClient adds required headers to the request") - protected void proxyConfigurationAddsRequiredHeaders() throws Exception { + protected void proxyConfigurationOtherAuthAddsRequiredHeaders() throws Exception { // Given final HttpClient.Builder builder = getHttpClientFactory() .newBuilder() .proxyAddress(new InetSocketAddress("localhost", server.getPort())) - .proxyAuthorization("auth:cred"); + .proxyAuthorization("Other kind of auth"); try (HttpClient client = builder.build()) { // When try { @@ -100,7 +154,7 @@ protected void proxyConfigurationAddsRequiredHeaders() throws Exception { .extracting(RecordedRequest::getHeaders) .extracting(Headers::toMultimap) .hasFieldOrPropertyWithValue("Host", ImmutableList.of("0.0.0.0:" + server.getPort())) - .hasFieldOrPropertyWithValue("Proxy-Authorization", ImmutableList.of("auth:cred")); + .hasFieldOrPropertyWithValue("Proxy-Authorization", ImmutableList.of("Other kind of auth")); } } } diff --git a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPostTest.java b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPostTest.java index d8212ce86a2..b142baa4090 100644 --- a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPostTest.java +++ b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPostTest.java @@ -17,6 +17,8 @@ import org.junit.jupiter.api.Disabled; +import com.linecorp.armeria.client.UnprocessedRequestException; + import io.fabric8.kubernetes.client.http.AbstractHttpPostTest; import io.fabric8.kubernetes.client.http.HttpClient; @@ -27,6 +29,11 @@ protected HttpClient.Factory getHttpClientFactory() { return new ArmeriaHttpClientFactory(); } + @Override + protected Class getConnectionFailedExceptionType() { + return UnprocessedRequestException.class; + } + @Disabled("Armeria does not support 'Expect: 100-Continue'") @Override public void expectContinue() throws Exception { diff --git a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPutTest.java b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPutTest.java index b5aa6c1f31e..ee6fcad575e 100644 --- a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPutTest.java +++ b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/ArmeriaHttpPutTest.java @@ -15,6 +15,8 @@ */ package com.linecorp.armeria.client.kubernetes; +import com.linecorp.armeria.client.UnprocessedRequestException; + import io.fabric8.kubernetes.client.http.AbstractHttpPutTest; import io.fabric8.kubernetes.client.http.HttpClient; @@ -24,4 +26,9 @@ class ArmeriaHttpPutTest extends AbstractHttpPutTest { protected HttpClient.Factory getHttpClientFactory() { return new ArmeriaHttpClientFactory(); } + + @Override + protected Class getConnectionFailedExceptionType() { + return UnprocessedRequestException.class; + } } diff --git a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroupMockServerTest.java b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroupMockServerTest.java index b1bbae90299..4ea74410dfe 100644 --- a/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroupMockServerTest.java +++ b/kubernetes/src/test/java/com/linecorp/armeria/client/kubernetes/endpoints/KubernetesEndpointGroupMockServerTest.java @@ -162,6 +162,63 @@ void createEndpointsWithNodeIpAndPort() throws InterruptedException { }); } + @Test + void clearOldEndpointsWhenServiceIsUpdated() throws InterruptedException { + // Prepare Kubernetes resources + final List nodes = ImmutableList.of(newNode("1.1.1.1"), newNode("2.2.2.2"), newNode("3.3.3.3")); + final Deployment deployment = newDeployment(); + final int nodePort = 30000; + final Service service = newService(nodePort); + final List pods = nodes.stream() + .map(node -> node.getMetadata().getName()) + .map(nodeName -> newPod(deployment.getSpec().getTemplate(), nodeName)) + .collect(toImmutableList()); + + // Create Kubernetes resources + for (Node node : nodes) { + client.nodes().resource(node).create(); + } + client.pods().resource(pods.get(0)).create(); + client.pods().resource(pods.get(1)).create(); + client.apps().deployments().resource(deployment).create(); + client.services().resource(service).create(); + + final KubernetesEndpointGroup endpointGroup = KubernetesEndpointGroup.of(client, "test", + "nginx-service"); + endpointGroup.whenReady().join(); + + // Initial state + await().untilAsserted(() -> { + final List endpoints = endpointGroup.endpoints(); + // Wait until all endpoints are ready + assertThat(endpoints).containsExactlyInAnyOrder( + Endpoint.of("1.1.1.1", nodePort), + Endpoint.of("2.2.2.2", nodePort) + ); + }); + + // Update service and deployment with new selector + final int newNodePort = 30001; + final String newSelectorName = "nginx-updated"; + final Service updatedService = newService(newNodePort, newSelectorName); + client.services().resource(updatedService).update(); + final Deployment updatedDeployment = newDeployment(newSelectorName); + client.apps().deployments().resource(updatedDeployment).update(); + + final List updatedPods = + nodes.stream() + .map(node -> node.getMetadata().getName()) + .map(nodeName -> newPod(updatedDeployment.getSpec().getTemplate(), nodeName)) + .collect(toImmutableList()); + client.pods().resource(updatedPods.get(2)).create(); + await().untilAsserted(() -> { + final List endpoints = endpointGroup.endpoints(); + assertThat(endpoints).containsExactlyInAnyOrder( + Endpoint.of("3.3.3.3", newNodePort) + ); + }); + } + @Test void shouldUsePortNameToGetNodePort() { final List nodes = ImmutableList.of(newNode("1.1.1.1"), newNode("2.2.2.2"), newNode("3.3.3.3")); @@ -292,6 +349,10 @@ private static Node newNode(String ip) { } static Service newService(@Nullable Integer nodePort) { + return newService(nodePort, "nginx"); + } + + static Service newService(@Nullable Integer nodePort, String selectorName) { final ObjectMeta metadata = new ObjectMetaBuilder() .withName("nginx-service") .build(); @@ -301,7 +362,7 @@ static Service newService(@Nullable Integer nodePort) { .build(); final ServiceSpec serviceSpec = new ServiceSpecBuilder() .withPorts(servicePort) - .withSelector(ImmutableMap.of("app", "nginx")) + .withSelector(ImmutableMap.of("app", selectorName)) .withType("NodePort") .build(); return new ServiceBuilder() @@ -310,17 +371,17 @@ static Service newService(@Nullable Integer nodePort) { .build(); } - static Deployment newDeployment() { + static Deployment newDeployment(String selectorName) { final ObjectMeta metadata = new ObjectMetaBuilder() .withName("nginx-deployment") .build(); final LabelSelector selector = new LabelSelectorBuilder() - .withMatchLabels(ImmutableMap.of("app", "nginx")) + .withMatchLabels(ImmutableMap.of("app", selectorName)) .build(); final DeploymentSpec deploymentSpec = new DeploymentSpecBuilder() .withReplicas(4) .withSelector(selector) - .withTemplate(newPodTemplate()) + .withTemplate(newPodTemplate(selectorName)) .build(); return new DeploymentBuilder() .withMetadata(metadata) @@ -328,9 +389,13 @@ static Deployment newDeployment() { .build(); } - private static PodTemplateSpec newPodTemplate() { + static Deployment newDeployment() { + return newDeployment("nginx"); + } + + private static PodTemplateSpec newPodTemplate(String selectorName) { final ObjectMeta metadata = new ObjectMetaBuilder() - .withLabels(ImmutableMap.of("app", "nginx")) + .withLabels(ImmutableMap.of("app", selectorName)) .build(); final Container container = new ContainerBuilder() .withName("nginx") diff --git a/micrometer-context/build.gradle b/micrometer-context/build.gradle new file mode 100644 index 00000000000..1af31e6e516 --- /dev/null +++ b/micrometer-context/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation libs.context.propagation + testImplementation project(':reactor3') +} diff --git a/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java new file mode 100644 index 00000000000..0844a677f10 --- /dev/null +++ b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessor.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.micrometer.context; + +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.RequestContextStorage; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.common.RequestContextUtil; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshot.Scope; +import io.micrometer.context.ThreadLocalAccessor; + +/** + * This class works with the + * Micrometer + * Context Propagation to keep the {@link RequestContext} during + * Reactor operations. + * Get the {@link RequestContextThreadLocalAccessor} to register it to the {@link ContextRegistry}. + * Then, {@link ContextRegistry} will use {@link RequestContextThreadLocalAccessor} to + * propagate context during the + * Reactor operations + * so that you can get the context using {@link RequestContext#current()}. + * However, please note that you should include Mono#contextWrite(ContextView) or + * Flux#contextWrite(ContextView) to end of the Reactor codes. + * If not, {@link RequestContext} will not be keep during Reactor Operation. + */ +@UnstableApi +public final class RequestContextThreadLocalAccessor implements ThreadLocalAccessor { + + private static final Object KEY = RequestContext.class; + + /** + * The value which obtained through {@link RequestContextThreadLocalAccessor}, + * will be stored in the Context under this {@code KEY}. + * This method will be called by {@link ContextSnapshot} internally. + */ + @Override + public Object key() { + return KEY; + } + + /** + * {@link ContextSnapshot} will call this method during the execution + * of lambda functions in {@link ContextSnapshot#wrap(Runnable)}, + * as well as during Mono#subscribe(), Flux#subscribe(), + * {@link Subscription#request(long)}, and CoreSubscriber#onSubscribe(Subscription). + * Following these calls, {@link ContextSnapshot#setThreadLocals()} is + * invoked to restore the state of {@link RequestContextStorage}. + * Furthermore, at the end of these methods, {@link Scope#close()} is executed + * to revert the {@link RequestContextStorage} to its original state. + */ + @Nullable + @Override + public RequestContext getValue() { + return RequestContext.currentOrNull(); + } + + /** + * {@link ContextSnapshot} will call this method during the execution + * of lambda functions in {@link ContextSnapshot#wrap(Runnable)}, + * as well as during Mono#subscribe(), Flux#subscribe(), + * {@link Subscription#request(long)}, and CoreSubscriber#onSubscribe(Subscription). + * Following these calls, {@link ContextSnapshot#setThreadLocals()} is + * invoked to restore the state of {@link RequestContextStorage}. + * Furthermore, at the end of these methods, {@link Scope#close()} is executed + * to revert the {@link RequestContextStorage} to its original state. + */ + @Override + @SuppressWarnings("MustBeClosedChecker") + public void setValue(RequestContext value) { + RequestContextUtil.getAndSet(value); + } + + /** + * This method will be called at the start of {@link ContextSnapshot.Scope} and + * the end of {@link ContextSnapshot.Scope}. If reactor Context does not + * contains {@link RequestContextThreadLocalAccessor#KEY}, {@link ContextSnapshot} will use + * this method to remove the value from {@link ThreadLocal}. + * Please note that {@link RequestContextUtil#pop()} return {@link AutoCloseable} instance, + * but it is not used in `Try with Resources` syntax. this is because {@link ContextSnapshot.Scope} + * will handle the {@link AutoCloseable} instance returned by {@link RequestContextUtil#pop()}. + */ + @Override + @SuppressWarnings("MustBeClosedChecker") + public void setValue() { + RequestContextUtil.pop(); + } +} diff --git a/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java new file mode 100644 index 00000000000..24f7d364c1f --- /dev/null +++ b/micrometer-context/src/main/java/com/linecorp/armeria/common/micrometer/context/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Micrometer context-propagation plugins to help keep {@link com.linecorp.armeria.common.RequestContext} + * during Reactor operations. + */ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.common.micrometer.context; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java b/micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java new file mode 100644 index 00000000000..8aaef50d66b --- /dev/null +++ b/micrometer-context/src/test/java/com/linecorp/armeria/common/micrometer/context/RequestContextThreadLocalAccessorTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.micrometer.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.internal.common.RequestContextUtil; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshot.Scope; +import io.micrometer.context.ContextSnapshotFactory; + +class RequestContextThreadLocalAccessorTest { + + @Test + void should_return_expected_key() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + final Object expectedValue = RequestContext.class; + + // When + final Object result = reqCtxAccessor.key(); + + // Then + assertThat(result).isEqualTo(expectedValue); + } + + @Test + @SuppressWarnings("MustBeClosedChecker") + void should_success_set() { + // Given + final ClientRequestContext ctx = newContext(); + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + + // When + reqCtxAccessor.setValue(ctx); + + // Then + final RequestContext currentCtx = RequestContext.current(); + assertThat(currentCtx).isEqualTo(ctx); + + RequestContextUtil.pop(); + } + + @Test + void should_throw_NPE_when_set_null() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + + // When + Then + assertThatThrownBy(() -> reqCtxAccessor.setValue(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void should_be_null_when_setValue() { + // Given + final ClientRequestContext ctx = newContext(); + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + reqCtxAccessor.setValue(ctx); + + // When + reqCtxAccessor.setValue(); + + // Then + final RequestContext reqCtx = RequestContext.currentOrNull(); + assertThat(reqCtx).isNull(); + } + + @Test + @SuppressWarnings("MustBeClosedChecker") + void should_be_restore_original_state_when_restore() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + final ClientRequestContext previousCtx = newContext(); + final ClientRequestContext currentCtx = newContext(); + reqCtxAccessor.setValue(currentCtx); + + // When + reqCtxAccessor.restore(previousCtx); + + // Then + final RequestContext reqCtx = RequestContext.currentOrNull(); + assertThat(reqCtx).isNotNull(); + assertThat(reqCtx).isEqualTo(previousCtx); + + RequestContextUtil.pop(); + } + + @Test + void should_be_null_when_restore() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + final ClientRequestContext currentCtx = newContext(); + reqCtxAccessor.setValue(currentCtx); + + // When + reqCtxAccessor.restore(); + + // Then + final RequestContext reqCtx = RequestContext.currentOrNull(); + assertThat(reqCtx).isNull(); + } + + @Test + void requestContext_should_exist_inside_scope_and_not_outside() { + // Given + final RequestContextThreadLocalAccessor reqCtxAccessor = new RequestContextThreadLocalAccessor(); + ContextRegistry.getInstance() + .registerThreadLocalAccessor(reqCtxAccessor); + final ClientRequestContext currentCtx = newContext(); + final ClientRequestContext expectedCtx = currentCtx; + reqCtxAccessor.setValue(currentCtx); + + final ContextSnapshotFactory factory = ContextSnapshotFactory.builder() + .clearMissing(true) + .build(); + final ContextSnapshot contextSnapshot = factory.captureAll(); + reqCtxAccessor.setValue(); + + // When : contextSnapshot.setThreadLocals() + try (Scope ignored = contextSnapshot.setThreadLocals()) { + + // Then : should not + final RequestContext reqCtxInScope = RequestContext.currentOrNull(); + assertThat(reqCtxInScope).isSameAs(expectedCtx); + } + + // Then + final RequestContext reqCtxOutOfScope = RequestContext.currentOrNull(); + assertThat(reqCtxOutOfScope).isNull(); + } + + static ClientRequestContext newContext() { + return ClientRequestContext.of(HttpRequest.of(HttpMethod.GET, "/")); + } +} diff --git a/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java new file mode 100644 index 00000000000..7f075c60b80 --- /dev/null +++ b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationFluxTest.java @@ -0,0 +1,641 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.reactor3; + +import static com.linecorp.armeria.reactor3.RequestContextPropagationMonoTest.ctxExists; +import static com.linecorp.armeria.reactor3.RequestContextPropagationMonoTest.newContext; +import static com.linecorp.armeria.reactor3.RequestContextPropagationMonoTest.noopSubscription; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.reactivestreams.Publisher; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.micrometer.context.RequestContextThreadLocalAccessor; +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.internal.testing.AnticipatedException; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; + +import io.micrometer.context.ContextRegistry; +import reactor.core.Disposable; +import reactor.core.publisher.ConnectableFlux; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + +@GenerateNativeImageTrace +class RequestContextPropagationFluxTest { + + @BeforeAll + static void setUp() { + ContextRegistry + .getInstance() + .registerThreadLocalAccessor(new RequestContextThreadLocalAccessor()); + Hooks.enableAutomaticContextPropagation(); + } + + @AfterAll + static void tearDown() { + Hooks.disableAutomaticContextPropagation(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxError(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + final AtomicBoolean atomicBoolean = new AtomicBoolean(); + flux = addCallbacks(Flux.error(() -> { + if (!atomicBoolean.getAndSet(true)) { + // Flux.error().publishOn() calls this error supplier immediately to see if it can retrieve + // the value via Callable.call() without ctx. + assertThat(ctxExists(ctx)).isFalse(); + } else { + assertThat(ctxExists(ctx)).isTrue(); + } + return new AnticipatedException(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxFromPublisher(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.from(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxCreate(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.create(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.next("foo"); + s.complete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxCreate_error(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.create(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.error(new AnticipatedException()); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(flux) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxConcat(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.concat(Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + }), Mono.fromCallable(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "bar"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxDefer(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.defer(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return Flux.just("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxFromStream(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.fromStream(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return Stream.of("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxCombineLatest(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.combineLatest(Mono.just("foo"), Mono.just("bar"), (a, b) -> { + assertThat(ctxExists(ctx)).isTrue(); + return a; + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxGenerate(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.generate(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.next("foo"); + s.complete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxMerge(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.mergeSequential(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onComplete(); + }, s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("bar"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxPush(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.push(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.next("foo"); + s.complete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxSwitchOnNext(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.switchOnNext(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext((Publisher) s1 -> { + assertThat(ctxExists(ctx)).isTrue(); + s1.onSubscribe(noopSubscription()); + s1.onNext("foo"); + s1.onComplete(); + }); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxZip(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.zip(Mono.just("foo"), Mono.just("bar"), (foo, bar) -> { + assertThat(ctxExists(ctx)).isTrue(); + return foo; + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxInterval(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.interval(Duration.ofMillis(100)).take(2).concatMap(a -> { + assertThat(ctxExists(ctx)).isTrue(); + return Mono.just("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxConcatDelayError(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.concatDelayError(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onError(new AnticipatedException()); + }, s -> { + s.onSubscribe(noopSubscription()); + s.onNext("bar"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .expectNextMatches("bar"::equals) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void fluxTransform(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = addCallbacks(Flux.just("foo").transform(fooFlux -> s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext(fooFlux.blockFirst()); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void connectableFlux(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + final ConnectableFlux connectableFlux = Flux.just("foo").publish(); + flux = addCallbacks(connectableFlux.autoConnect(2).publishOn(Schedulers.single()), + ctx, + useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + flux.subscribe().dispose(); + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + flux.subscribe().dispose(); + StepVerifier.create(flux) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void connectableFlux_dispose(boolean useContextCapture) throws InterruptedException { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + final ConnectableFlux connectableFlux = Flux.just("foo").publish(); + flux = addCallbacks(connectableFlux.autoConnect(2, disposable -> { + assertThat(ctxExists(ctx)).isTrue(); + }).publishOn(Schedulers.newSingle("aaa")), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + final Disposable disposable1 = flux.subscribe(); + await().pollDelay(Duration.ofMillis(200)).until(() -> !disposable1.isDisposed()); + final Disposable disposable2 = flux.subscribe(); + await().untilAsserted(() -> { + assertThat(disposable1.isDisposed()).isTrue(); + assertThat(disposable2.isDisposed()).isTrue(); + }); + } + } else { + final Disposable disposable1 = flux.subscribe(); + await().pollDelay(Duration.ofMillis(200)).until(() -> !disposable1.isDisposed()); + final Disposable disposable2 = flux.subscribe(); + await().untilAsserted(() -> { + assertThat(disposable1.isDisposed()).isTrue(); + assertThat(disposable2.isDisposed()).isTrue(); + }); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void subscriberContextIsNotMissing() { + final ClientRequestContext ctx = newContext(); + final Flux flux; + + flux = Flux.deferContextual(reactorCtx -> { + assertThat((String) reactorCtx.get("foo")).isEqualTo("bar"); + return Flux.just("baz"); + }); + + final Flux flux1 = flux.contextWrite(reactorCtx -> reactorCtx.put("foo", "bar")); + StepVerifier.create(flux1) + .expectNextMatches("baz"::equals) + .verifyComplete(); + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void ctxShouldBeCleanUpEvenIfErrorOccursDuringReactorOperationOnSchedulerThread() + throws InterruptedException { + // Given + final ClientRequestContext ctx = newContext(); + final Flux flux; + final Scheduler single = Schedulers.single(); + + // When + flux = Flux.just("Hello", "Hi") + .subscribeOn(single) + .delayElements(Duration.ofMillis(1000)) + .map(s -> { + if ("Hello".equals(s)) { + throw new RuntimeException(); + } + return s; + }) + .contextWrite(Context.of(RequestContext.class, ctx)); + + // Then + StepVerifier.create(flux) + .expectError(RuntimeException.class) + .verify(); + + final CountDownLatch latch = new CountDownLatch(1); + single.schedule(() -> { + assertThat(ctxExists(ctx)).isFalse(); + latch.countDown(); + }); + latch.await(); + + assertThat(ctxExists(ctx)).isFalse(); + } + + private static Flux addCallbacks(Flux flux0, + ClientRequestContext ctx, + boolean useContextCapture) { + final Flux flux = flux0.doFirst(() -> assertThat(ctxExists(ctx)).isTrue()) + .doOnSubscribe(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnRequest(l -> assertThat(ctxExists(ctx)).isTrue()) + .doOnNext(foo -> assertThat(ctxExists(ctx)).isTrue()) + .doOnComplete(() -> assertThat(ctxExists(ctx)).isTrue()) + .doOnEach(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnError(t -> assertThat(ctxExists(ctx)).isTrue()) + .doOnCancel(() -> assertThat(ctxExists(ctx)).isTrue()) + .doFinally(t -> assertThat(ctxExists(ctx)).isTrue()) + .doAfterTerminate(() -> assertThat(ctxExists(ctx)).isTrue()); + + if (useContextCapture) { + return flux.contextCapture(); + } + return flux.contextWrite(Context.of(RequestContext.class, ctx)); + } +} diff --git a/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java new file mode 100644 index 00000000000..1709883b9de --- /dev/null +++ b/micrometer-context/src/test/java/com/linecorp/armeria/reactor3/RequestContextPropagationMonoTest.java @@ -0,0 +1,399 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.reactor3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.reactivestreams.Subscription; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.common.micrometer.context.RequestContextThreadLocalAccessor; +import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.internal.testing.AnticipatedException; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; + +import io.micrometer.context.ContextRegistry; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; +import reactor.util.context.Context; +import reactor.util.function.Tuple2; + +@GenerateNativeImageTrace +class RequestContextPropagationMonoTest { + + @BeforeAll + static void setUp() { + ContextRegistry + .getInstance() + .registerThreadLocalAccessor(new RequestContextThreadLocalAccessor()); + Hooks.enableAutomaticContextPropagation(); + } + + @AfterAll + static void tearDown() { + Hooks.disableAutomaticContextPropagation(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoCreate_success(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.create(sink -> { + assertThat(ctxExists(ctx)).isTrue(); + sink.success("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoCreate_error(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.create(sink -> { + assertThat(ctxExists(ctx)).isTrue(); + sink.error(new AnticipatedException()); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoCreate_currentContext(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.create(sink -> { + assertThat(ctxExists(ctx)).isTrue(); + sink.success("foo"); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoDefer(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.defer(() -> Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoFromPublisher(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.from(s -> { + assertThat(ctxExists(ctx)).isTrue(); + s.onSubscribe(noopSubscription()); + s.onNext("foo"); + s.onComplete(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoError(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.error(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return new AnticipatedException(); + }).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + } else { + StepVerifier.create(mono) + .verifyErrorMatches(t -> t instanceof AnticipatedException); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoFirst(boolean useContextCapture) { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.firstWithSignal(Mono.delay(Duration.ofMillis(1000)).then(Mono.just("bar")), + Mono.fromCallable(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + })) + .publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoFromFuture(boolean useContextCapture) { + final CompletableFuture future = new CompletableFuture<>(); + future.complete("foo"); + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.fromFuture(future) + .publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoDelay(boolean useContextCapture) { + final CompletableFuture future = new CompletableFuture<>(); + future.complete("foo"); + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = addCallbacks(Mono.delay(Duration.ofMillis(100)).then(Mono.fromCallable(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches("foo"::equals) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ "true", "false" }) + void monoZip(boolean useContextCapture) { + final CompletableFuture future = new CompletableFuture<>(); + future.complete("foo"); + final ClientRequestContext ctx = newContext(); + final Mono> mono; + mono = addCallbacks(Mono.zip(Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "foo"; + }), Mono.fromSupplier(() -> { + assertThat(ctxExists(ctx)).isTrue(); + return "bar"; + })).publishOn(Schedulers.single()), ctx, useContextCapture); + + if (useContextCapture) { + try (SafeCloseable ignored = ctx.push()) { + StepVerifier.create(mono) + .expectNextMatches(t -> "foo".equals(t.getT1()) && "bar".equals(t.getT2())) + .verifyComplete(); + } + } else { + StepVerifier.create(mono) + .expectNextMatches(t -> "foo".equals(t.getT1()) && "bar".equals(t.getT2())) + .verifyComplete(); + } + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void subscriberContextIsNotMissing() { + final ClientRequestContext ctx = newContext(); + final Mono mono; + mono = Mono.deferContextual(Mono::just).handle((reactorCtx, sink) -> { + assertThat((String) reactorCtx.get("foo")).isEqualTo("bar"); + sink.next("baz"); + }); + + final Mono mono1 = mono.contextWrite(reactorCtx -> reactorCtx.put("foo", "bar")); + StepVerifier.create(mono1) + .expectNextMatches("baz"::equals) + .verifyComplete(); + assertThat(ctxExists(ctx)).isFalse(); + } + + @Test + void ctxShouldBeCleanUpEvenIfErrorOccursDuringReactorOperationOnSchedulerThread() + throws InterruptedException { + // Given + final ClientRequestContext ctx = newContext(); + final Mono mono; + final Scheduler single = Schedulers.single(); + + // When + mono = Mono.just("Hello") + .subscribeOn(single) + .delayElement(Duration.ofMillis(1000)) + .map(s -> { + if ("Hello".equals(s)) { + throw new RuntimeException(); + } + return s; + }) + .contextWrite(Context.of(RequestContext.class, ctx)); + + // Then + StepVerifier.create(mono) + .expectError(RuntimeException.class) + .verify(); + + final CountDownLatch latch = new CountDownLatch(1); + single.schedule(() -> { + assertThat(ctxExists(ctx)).isFalse(); + latch.countDown(); + }); + latch.await(); + + assertThat(ctxExists(ctx)).isFalse(); + } + + static Subscription noopSubscription() { + return new Subscription() { + @Override + public void request(long n) {} + + @Override + public void cancel() {} + }; + } + + static boolean ctxExists(ClientRequestContext ctx) { + return RequestContext.currentOrNull() == ctx; + } + + static ClientRequestContext newContext() { + return ClientRequestContext.builder(HttpRequest.of(HttpMethod.GET, "/")) + .build(); + } + + private static Mono addCallbacks(Mono mono0, ClientRequestContext ctx, + boolean useContextCapture) { + final Mono mono = mono0.doFirst(() -> assertThat(ctxExists(ctx)).isTrue()) + .doOnSubscribe(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnRequest(l -> assertThat(ctxExists(ctx)).isTrue()) + .doOnNext(foo -> assertThat(ctxExists(ctx)).isTrue()) + .doOnSuccess(t -> assertThat(ctxExists(ctx)).isTrue()) + .doOnEach(s -> assertThat(ctxExists(ctx)).isTrue()) + .doOnError(t -> assertThat(ctxExists(ctx)).isTrue()) + .doOnCancel(() -> assertThat(ctxExists(ctx)).isTrue()) + .doFinally(t -> assertThat(ctxExists(ctx)).isTrue()) + .doAfterTerminate(() -> assertThat(ctxExists(ctx)).isTrue()); + if (useContextCapture) { + return mono.contextCapture(); + } + return mono.contextWrite(Context.of(RequestContext.class, ctx)); + } +} diff --git a/nacos/build.gradle.kts b/nacos/build.gradle.kts new file mode 100644 index 00000000000..aae19538681 --- /dev/null +++ b/nacos/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies { + implementation(libs.caffeine) + testImplementation(libs.testcontainers.junit.jupiter) +} diff --git a/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java new file mode 100644 index 00000000000..3901503a2d3 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroup.java @@ -0,0 +1,133 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.client.nacos; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.endpoint.DynamicEndpointGroup; +import com.linecorp.armeria.client.endpoint.EndpointGroup; +import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy; +import com.linecorp.armeria.common.CommonPools; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.nacos.NacosClient; + +import io.netty.util.concurrent.EventExecutor; + +/** + * A Nacos-based {@link EndpointGroup} implementation that retrieves the list of {@link Endpoint} from Nacos + * using Nacos's HTTP Open API + * and updates the {@link Endpoint}s periodically. + */ +@UnstableApi +public final class NacosEndpointGroup extends DynamicEndpointGroup { + + private static final Logger logger = LoggerFactory.getLogger(NacosEndpointGroup.class); + + /** + * Returns a {@link NacosEndpointGroup} with the specified {@code serviceName}. + */ + public static NacosEndpointGroup of(URI nacosUri, String serviceName) { + return builder(nacosUri, serviceName).build(); + } + + /** + * Returns a newly-created {@link NacosEndpointGroupBuilder} with the specified {@code nacosUri} + * and {@code serviceName} to build {@link NacosEndpointGroupBuilder}. + * + * @param nacosUri the URI of Nacos API service, including the path up to but not including API version. + * (example: http://localhost:8848/nacos) + */ + public static NacosEndpointGroupBuilder builder(URI nacosUri, String serviceName) { + return new NacosEndpointGroupBuilder(nacosUri, serviceName); + } + + private final NacosClient nacosClient; + + private final long registryFetchIntervalMillis; + + private final EventExecutor eventLoop; + + @Nullable + private ScheduledFuture scheduledFuture; + + NacosEndpointGroup(EndpointSelectionStrategy selectionStrategy, boolean allowEmptyEndpoints, + long selectionTimeoutMillis, NacosClient nacosClient, + long registryFetchIntervalMillis) { + super(selectionStrategy, allowEmptyEndpoints, selectionTimeoutMillis); + this.nacosClient = requireNonNull(nacosClient, "nacosClient"); + this.registryFetchIntervalMillis = registryFetchIntervalMillis; + eventLoop = CommonPools.workerGroup().next(); + + update(); + } + + private void update() { + if (isClosing()) { + return; + } + + nacosClient.endpoints() + .handleAsync((endpoints, cause) -> { + if (isClosing()) { + return null; + } + + if (cause != null) { + logger.warn("Unexpected exception while fetching the registry from: {}", + nacosClient.uri(), cause); + } else { + setEndpoints(endpoints); + } + + scheduledFuture = eventLoop.schedule(this::update, registryFetchIntervalMillis, + TimeUnit.MILLISECONDS); + return null; + }, eventLoop); + } + + @Override + protected void doCloseAsync(CompletableFuture future) { + if (!eventLoop.inEventLoop()) { + eventLoop.execute(() -> doCloseAsync(future)); + return; + } + + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + } + + future.complete(null); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("registryFetchIntervalMillis", registryFetchIntervalMillis) + .toString(); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java new file mode 100644 index 00000000000..7f94536dd33 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilder.java @@ -0,0 +1,138 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.client.nacos; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.time.Duration; + +import com.linecorp.armeria.client.endpoint.AbstractDynamicEndpointGroupBuilder; +import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy; +import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.nacos.NacosConfigSetters; +import com.linecorp.armeria.internal.nacos.NacosClient; +import com.linecorp.armeria.internal.nacos.NacosClientBuilder; + +/** + * A builder class for {@link NacosEndpointGroup}. + *

    Examples

    + *
    {@code
    + * NacosEndpointGroup endpointGroup = NacosEndpointGroup.builder(nacosUri, "myService")
    + *                                                      .build();
    + * WebClient client = WebClient.of(SessionProtocol.HTTPS, endpointGroup);
    + * }
    + */ +@UnstableApi +public final class NacosEndpointGroupBuilder + extends AbstractDynamicEndpointGroupBuilder + implements NacosConfigSetters { + + private static final long DEFAULT_CHECK_INTERVAL_MILLIS = 10_000; + + private final NacosClientBuilder nacosClientBuilder; + private EndpointSelectionStrategy selectionStrategy = EndpointSelectionStrategy.weightedRoundRobin(); + private long registryFetchIntervalMillis = DEFAULT_CHECK_INTERVAL_MILLIS; + + NacosEndpointGroupBuilder(URI nacosUri, String serviceName) { + super(Flags.defaultResponseTimeoutMillis()); + nacosClientBuilder = NacosClient.builder(nacosUri, requireNonNull(serviceName, "serviceName")); + } + + /** + * Sets the {@link EndpointSelectionStrategy} of the {@link NacosEndpointGroup}. + */ + public NacosEndpointGroupBuilder selectionStrategy(EndpointSelectionStrategy selectionStrategy) { + this.selectionStrategy = requireNonNull(selectionStrategy, "selectionStrategy"); + return this; + } + + @Override + public NacosEndpointGroupBuilder namespaceId(String namespaceId) { + nacosClientBuilder.namespaceId(namespaceId); + return this; + } + + @Override + public NacosEndpointGroupBuilder groupName(String groupName) { + nacosClientBuilder.groupName(groupName); + return this; + } + + @Override + public NacosEndpointGroupBuilder clusterName(String clusterName) { + nacosClientBuilder.clusterName(clusterName); + return this; + } + + @Override + public NacosEndpointGroupBuilder app(String app) { + nacosClientBuilder.app(app); + return this; + } + + @Override + public NacosEndpointGroupBuilder nacosApiVersion(String nacosApiVersion) { + nacosClientBuilder.nacosApiVersion(nacosApiVersion); + return this; + } + + @Override + public NacosEndpointGroupBuilder authorization(String username, String password) { + nacosClientBuilder.authorization(username, password); + return this; + } + + /** + * Sets the healthy to retrieve only healthy instances from Nacos. + * Make sure that your target endpoints are health-checked by Nacos before enabling this feature. + * If not set, false is used by default. + */ + public NacosEndpointGroupBuilder useHealthyEndpoints(boolean useHealthyEndpoints) { + nacosClientBuilder.healthyOnly(useHealthyEndpoints); + return this; + } + + /** + * Sets the interval between fetching registry requests. + * If not set, {@value #DEFAULT_CHECK_INTERVAL_MILLIS} milliseconds is used by default. + */ + public NacosEndpointGroupBuilder registryFetchInterval(Duration registryFetchInterval) { + requireNonNull(registryFetchInterval, "registryFetchInterval"); + return registryFetchIntervalMillis(registryFetchInterval.toMillis()); + } + + /** + * Sets the interval between fetching registry requests. + * If not set, {@value #DEFAULT_CHECK_INTERVAL_MILLIS} milliseconds is used by default. + */ + public NacosEndpointGroupBuilder registryFetchIntervalMillis(long registryFetchIntervalMillis) { + checkArgument(registryFetchIntervalMillis > 0, "registryFetchIntervalMillis: %s (expected: > 0)", + registryFetchIntervalMillis); + this.registryFetchIntervalMillis = registryFetchIntervalMillis; + return this; + } + + /** + * Returns a newly-created {@link NacosEndpointGroup}. + */ + public NacosEndpointGroup build() { + return new NacosEndpointGroup(selectionStrategy, shouldAllowEmptyEndpoints(), selectionTimeoutMillis(), + nacosClientBuilder.build(), registryFetchIntervalMillis); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java new file mode 100644 index 00000000000..da9a43a38f7 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/client/nacos/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Nacos-based {@link com.linecorp.armeria.client.endpoint.EndpointGroup} implementation. + */ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.client.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java b/nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java new file mode 100644 index 00000000000..60650fd98d7 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/common/nacos/NacosConfigSetters.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.nacos; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.internal.nacos.NacosClientBuilder; + +/** + * Sets properties for building a Nacos client. + */ +@UnstableApi +public interface NacosConfigSetters> { + + /** + * Sets the namespace ID to query or register instances. + */ + SELF namespaceId(String namespaceId); + + /** + * Sets the group name to query or register instances. + */ + SELF groupName(String groupName); + + /** + * Sets the cluster name to query or register instances. + */ + SELF clusterName(String clusterName); + + /** + * Sets the app name to query or register instances. + */ + SELF app(String app); + + /** + * Sets the specified Nacos's API version. + * @param nacosApiVersion the version of Nacos API service, default: {@value + * NacosClientBuilder#DEFAULT_NACOS_API_VERSION} + */ + SELF nacosApiVersion(String nacosApiVersion); + + /** + * Sets the username and password pair for Nacos's API. + * Please refer to the + * Nacos Authentication Document + * for more details. + * + * @param username the username for access Nacos API, default: {@code null} + * @param password the password for access Nacos API, default: {@code null} + */ + SELF authorization(String username, String password); +} diff --git a/nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java new file mode 100644 index 00000000000..28027bc31fd --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/common/nacos/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Various classes used internally. Anything in this package can be changed or removed at any time. + */ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.common.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java new file mode 100644 index 00000000000..af6fd80b408 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/LoginClient.java @@ -0,0 +1,137 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static java.util.Objects.requireNonNull; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.SimpleDecoratingHttpClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpEntity; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.auth.AuthToken; +import com.linecorp.armeria.common.util.AsyncLoader; +import com.linecorp.armeria.common.util.Exceptions; + +/** + * A Nacos client that is responsible for + * Nacos Authentication. + */ +final class LoginClient extends SimpleDecoratingHttpClient { + + static Function newDecorator(WebClient webClient, + String username, String password) { + return delegate -> new LoginClient(delegate, webClient, username, password); + } + + private final WebClient webClient; + private final String queryParamsForLogin; + + private final AsyncLoader tokenLoader = + AsyncLoader.builder(cache -> loginInternal()) + .expireIf(LoginResult::isExpired) + .build(); + + LoginClient(HttpClient delegate, WebClient webClient, String username, String password) { + super(delegate); + this.webClient = requireNonNull(webClient, "webClient"); + queryParamsForLogin = QueryParams.builder() + .add("username", requireNonNull(username, "username")) + .add("password", requireNonNull(password, "password")) + .toQueryString(); + } + + private CompletableFuture login() { + return tokenLoader.load().thenApply(loginResult -> loginResult.accessToken); + } + + private CompletableFuture loginInternal() { + return webClient.prepare().post("/v1/auth/login") + .content(MediaType.FORM_DATA, queryParamsForLogin) + .asJson(LoginResult.class) + .as(HttpEntity::content) + .execute(); + } + + @Override + public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) { + final CompletableFuture future = login().thenApply(accessToken -> { + try { + final HttpRequest newReq = req.mapHeaders(headers -> { + return headers.toBuilder() + .set(HttpHeaderNames.AUTHORIZATION, accessToken.asHeaderValue()) + .build(); + }); + ctx.updateRequest(newReq); + return unwrap().execute(ctx, newReq); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + }); + + return HttpResponse.of(future); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class LoginResult { + private final AuthToken accessToken; + + private final long createdAtNanos; + private final long tokenTtlNanos; + + @Nullable + private final Boolean globalAdmin; + + @JsonCreator + LoginResult(@JsonProperty("accessToken") String accessToken, @JsonProperty("tokenTtl") int tokenTtl, + @JsonProperty("globalAdmin") @Nullable Boolean globalAdmin) { + this.accessToken = AuthToken.ofOAuth2(accessToken); + createdAtNanos = System.nanoTime(); + tokenTtlNanos = TimeUnit.SECONDS.toNanos(tokenTtl); + this.globalAdmin = globalAdmin; + } + + boolean isExpired() { + final long elapsedNanos = System.nanoTime() - createdAtNanos; + return elapsedNanos >= tokenTtlNanos; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("accessToken", accessToken) + .add("tokenTtl", TimeUnit.NANOSECONDS.toSeconds(tokenTtlNanos)) + .add("globalAdmin", globalAdmin) + .toString(); + } + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java new file mode 100644 index 00000000000..4f004b34977 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClient.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.retry.RetryConfig; +import com.linecorp.armeria.client.retry.RetryRule; +import com.linecorp.armeria.client.retry.RetryingClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.annotation.Nullable; + +public final class NacosClient { + + private static final Function retryingClientDecorator = + RetryingClient.newDecorator(RetryConfig.builder(RetryRule.onServerErrorStatus()) + .maxTotalAttempts(3) + .build()); + + public static NacosClientBuilder builder(URI nacosUri, String serviceName) { + return new NacosClientBuilder(nacosUri, serviceName); + } + + private final URI uri; + + private final QueryInstancesClient queryInstancesClient; + + private final RegisterInstanceClient registerInstanceClient; + + NacosClient(URI uri, String nacosApiVersion, @Nullable String username, @Nullable String password, + String serviceName, @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, @Nullable String app) { + this.uri = uri; + + final WebClientBuilder builder = WebClient.builder(uri) + .decorator(retryingClientDecorator); + if (username != null && password != null) { + builder.decorator(LoginClient.newDecorator(builder.build(), username, password)); + } + + final WebClient webClient = builder.build(); + + queryInstancesClient = QueryInstancesClient.of(webClient, nacosApiVersion, serviceName, namespaceId, + groupName, clusterName, healthyOnly, app); + registerInstanceClient = RegisterInstanceClient.of(webClient, nacosApiVersion, serviceName, namespaceId, + groupName, clusterName, app); + } + + public CompletableFuture> endpoints() { + return queryInstancesClient.endpoints(); + } + + /** + * Registers an instance to Nacos with service name. + * + * @return a {@link HttpResponse} indicating the result of the registration operation. + */ + public HttpResponse register(Endpoint endpoint) { + requireNonNull(endpoint, "endpoint"); + return registerInstanceClient.register(endpoint.host(), endpoint.port(), endpoint.weight()); + } + + /** + * De-registers an instance to Nacos with service name. + * + * @return a {@link HttpResponse} indicating the result of the de-registration operation. + */ + public HttpResponse deregister(Endpoint endpoint) { + requireNonNull(endpoint, "endpoint"); + return registerInstanceClient.deregister(endpoint.host(), endpoint.port(), endpoint.weight()); + } + + /** + * Returns the {@link URI} of Nacos uri. + */ + public URI uri() { + return uri; + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java new file mode 100644 index 00000000000..fba015b632d --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientBuilder.java @@ -0,0 +1,117 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.util.regex.Pattern; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.nacos.NacosConfigSetters; + +public final class NacosClientBuilder implements NacosConfigSetters { + + public static final String DEFAULT_NACOS_API_VERSION = "v2"; + private static final Pattern NACOS_API_VERSION_PATTERN = Pattern.compile("^v[0-9][-._a-zA-Z0-9]*$"); + + private final URI nacosUri; + private final String serviceName; + + private String nacosApiVersion = DEFAULT_NACOS_API_VERSION; + + @Nullable + private String username; + + @Nullable + private String password; + + @Nullable + private String namespaceId; + + @Nullable + private String groupName; + + @Nullable + private String clusterName; + + @Nullable + private Boolean healthyOnly; + + @Nullable + private String app; + + NacosClientBuilder(URI nacosUri, String serviceName) { + this.nacosUri = requireNonNull(nacosUri, "nacosUri"); + this.serviceName = requireNonNull(serviceName, "serviceName"); + } + + @Override + public NacosClientBuilder namespaceId(String namespaceId) { + this.namespaceId = requireNonNull(namespaceId, "namespaceId"); + return this; + } + + @Override + public NacosClientBuilder groupName(String groupName) { + this.groupName = requireNonNull(groupName, "groupName"); + return this; + } + + @Override + public NacosClientBuilder clusterName(String clusterName) { + this.clusterName = requireNonNull(clusterName, "clusterName"); + return this; + } + + @Override + public NacosClientBuilder app(String app) { + this.app = requireNonNull(app, "app"); + return this; + } + + @Override + public NacosClientBuilder nacosApiVersion(String nacosApiVersion) { + requireNonNull(nacosApiVersion, "nacosApiVersion"); + checkArgument(NACOS_API_VERSION_PATTERN.matcher(nacosApiVersion).matches(), + "nacosApiVersion: %s (expected: a version string that starts with 'v', e.g. 'v1')", + nacosApiVersion); + this.nacosApiVersion = nacosApiVersion; + return this; + } + + @Override + public NacosClientBuilder authorization(String username, String password) { + requireNonNull(username, "username"); + requireNonNull(password, "password"); + + this.username = username; + this.password = password; + + return this; + } + + public NacosClientBuilder healthyOnly(boolean healthyOnly) { + this.healthyOnly = healthyOnly; + return this; + } + + public NacosClient build() { + return new NacosClient(nacosUri, nacosApiVersion, username, password, serviceName, namespaceId, + groupName, clusterName, healthyOnly, app); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java new file mode 100644 index 00000000000..0039f399aaf --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/NacosClientUtil.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.QueryParamsBuilder; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * Utility methods related to Nacos clients. + */ +final class NacosClientUtil { + + private static final String NAMESPACE_ID_PARAM = "namespaceId"; + + private static final String GROUP_NAME_PARAM = "groupName"; + + private static final String SERVICE_NAME_PARAM = "serviceName"; + + private static final String CLUSTER_NAME_PARAM = "clusterName"; + + private static final String HEALTHY_ONLY_PARAM = "healthyOnly"; + + private static final String APP_PARAM = "app"; + + private static final String IP_PARAM = "ip"; + + private static final String PORT_PARAM = "port"; + + private static final String WEIGHT_PARAM = "weight"; + + private NacosClientUtil() {} + + /** + * Encodes common Nacos API parameters as {@code QueryParamsBuilder}. + */ + static QueryParams queryParams(String serviceName, @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, + @Nullable String app, @Nullable String ip, @Nullable Integer port, + @Nullable Integer weight) { + final QueryParamsBuilder paramsBuilder = QueryParams.builder(); + paramsBuilder.add(SERVICE_NAME_PARAM, serviceName); + if (namespaceId != null) { + paramsBuilder.add(NAMESPACE_ID_PARAM, namespaceId); + } + if (groupName != null) { + paramsBuilder.add(GROUP_NAME_PARAM, groupName); + } + if (clusterName != null) { + paramsBuilder.add(CLUSTER_NAME_PARAM, clusterName); + } + if (healthyOnly != null) { + paramsBuilder.add(HEALTHY_ONLY_PARAM, healthyOnly.toString()); + } + if (app != null) { + paramsBuilder.add(APP_PARAM, app); + } + if (ip != null) { + paramsBuilder.add(IP_PARAM, ip); + } + if (port != null) { + paramsBuilder.add(PORT_PARAM, port.toString()); + } + if (weight != null) { + paramsBuilder.add(WEIGHT_PARAM, weight.toString()); + } + return paramsBuilder.build(); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java new file mode 100644 index 00000000000..0176925a101 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/QueryInstancesClient.java @@ -0,0 +1,216 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpEntity; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * A Nacos client that is responsible for + * Nacos Open-Api - Query instances. + */ +final class QueryInstancesClient { + + private final WebClient webClient; + private final String pathForQuery; + + QueryInstancesClient(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, @Nullable String app) { + this.webClient = webClient; + + final StringBuilder pathBuilder = new StringBuilder("/") + .append(nacosApiVersion) + .append("/ns/instance/list?"); + final QueryParams params = NacosClientUtil + .queryParams(requireNonNull(serviceName, "serviceName"), namespaceId, groupName, + clusterName, healthyOnly, app, null, null, null); + pathBuilder.append(params.toQueryString()); + + pathForQuery = pathBuilder.toString(); + } + + static QueryInstancesClient of(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable Boolean healthyOnly, + @Nullable String app) { + return new QueryInstancesClient(webClient, nacosApiVersion, serviceName, namespaceId, groupName, + clusterName, healthyOnly, app); + } + + @Nullable + private static Endpoint toEndpoint(Host host) { + if (Boolean.FALSE.equals(host.enabled)) { + return null; + } else if (host.weight != null && host.weight.intValue() >= 0) { + return Endpoint.of(host.ip, host.port).withWeight(host.weight.intValue()); + } else { + return Endpoint.of(host.ip, host.port); + } + } + + CompletableFuture> endpoints() { + return queryInstances() + .thenApply(response -> { + requireNonNull(response.data, "Response data cannot be null"); + requireNonNull(response.data.hosts, "Response data.hosts cannot be null"); + return response.data.hosts.stream() + .map(QueryInstancesClient::toEndpoint) + .filter(Objects::nonNull) + .collect(toImmutableList()); + }); + } + + CompletableFuture queryInstances() { + return webClient.prepare() + .get(pathForQuery) + .asJson(QueryInstancesResponse.class) + .as(HttpEntity::content) + .execute(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class QueryInstancesResponse { + @Nullable + private final Data data; + + @JsonCreator + QueryInstancesResponse(@JsonProperty("data") Data data) { + this.data = data; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class Data { + @Nullable + private final List hosts; + + @JsonCreator + Data(@JsonProperty("hosts") List hosts) { + this.hosts = hosts; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class Host { + @Nullable + private final String instanceId; + + private final String ip; + + private final Integer port; + + @Nullable + private final Double weight; + + @Nullable + private final Boolean healthy; + + @Nullable + private final Boolean enabled; + + @Nullable + private final Boolean ephemeral; + + @Nullable + private final String clusterName; + + @Nullable + private final String serviceName; + + @Nullable + private final Map metadata; + + @Nullable + private final Integer instanceHeartBeatInterval; + + @Nullable + private final String instanceIdGenerator; + + @Nullable + private final Integer instanceHeartBeatTimeOut; + + @Nullable + private final Integer ipDeleteTimeout; + + @JsonCreator + Host(@JsonProperty("instanceId") @Nullable String instanceId, @JsonProperty("ip") String ip, + @JsonProperty("port") Integer port, @JsonProperty("weight") @Nullable Double weight, + @JsonProperty("healthy") @Nullable Boolean healthy, + @JsonProperty("enabled") @Nullable Boolean enabled, + @JsonProperty("ephemeral") @Nullable Boolean ephemeral, + @JsonProperty("clusterName") @Nullable String clusterName, + @JsonProperty("serviceName") @Nullable String serviceName, + @JsonProperty("metadata") @Nullable Map metadata, + @JsonProperty("instanceHeartBeatInterval") @Nullable Integer instanceHeartBeatInterval, + @JsonProperty("instanceIdGenerator") @Nullable String instanceIdGenerator, + @JsonProperty("instanceHeartBeatTimeOut") @Nullable Integer instanceHeartBeatTimeOut, + @JsonProperty("ipDeleteTimeout") @Nullable Integer ipDeleteTimeout + ) { + this.instanceId = instanceId; + this.ip = ip; + this.port = port; + this.weight = weight; + this.healthy = healthy; + this.enabled = enabled; + this.ephemeral = ephemeral; + this.clusterName = clusterName; + this.serviceName = serviceName; + this.metadata = metadata; + this.instanceHeartBeatInterval = instanceHeartBeatInterval; + this.instanceIdGenerator = instanceIdGenerator; + this.instanceHeartBeatTimeOut = instanceHeartBeatTimeOut; + this.ipDeleteTimeout = ipDeleteTimeout; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("instanceId", instanceId) + .add("ip", ip) + .add("port", port) + .add("weight", weight) + .add("healthy", healthy) + .add("enabled", enabled) + .add("ephemeral", ephemeral) + .add("clusterName", clusterName) + .add("serviceName", serviceName) + .add("metaData", metadata) + .add("instanceHeartBeatInterval", instanceHeartBeatInterval) + .add("instanceIdGenerator", instanceIdGenerator) + .add("instanceHeartBeatTimeOut", instanceHeartBeatTimeOut) + .add("ipDeleteTimeout", ipDeleteTimeout) + .toString(); + } + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java new file mode 100644 index 00000000000..49d76449fd5 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/RegisterInstanceClient.java @@ -0,0 +1,91 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * A Nacos client that is responsible for + * Nacos Open-Api - Register instance. + */ +final class RegisterInstanceClient { + + private final WebClient webClient; + private final String instanceApiPath; + private final String serviceName; + + @Nullable + private final String namespaceId; + + @Nullable + private final String groupName; + + @Nullable + private final String clusterName; + + @Nullable + private final String app; + + RegisterInstanceClient(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable String app) { + this.webClient = webClient; + instanceApiPath = new StringBuilder("/").append(nacosApiVersion).append("/ns/instance").toString(); + + this.serviceName = requireNonNull(serviceName, "serviceName"); + this.namespaceId = namespaceId; + this.groupName = groupName; + this.clusterName = clusterName; + this.app = app; + } + + static RegisterInstanceClient of(WebClient webClient, String nacosApiVersion, String serviceName, + @Nullable String namespaceId, @Nullable String groupName, + @Nullable String clusterName, @Nullable String app) { + return new RegisterInstanceClient(webClient, nacosApiVersion, serviceName, namespaceId, groupName, + clusterName, app); + } + + /** + * Registers a service into the Nacos. + */ + HttpResponse register(String ip, int port, int weight) { + final QueryParams params = NacosClientUtil.queryParams(serviceName, namespaceId, groupName, clusterName, + null, app, requireNonNull(ip, "ip"), port, + weight); + + return webClient.prepare().post(instanceApiPath).content(MediaType.FORM_DATA, params.toQueryString()) + .execute(); + } + + /** + * De-registers a service from the Nacos. + */ + HttpResponse deregister(String ip, int port, int weight) { + final QueryParams params = NacosClientUtil.queryParams(serviceName, namespaceId, groupName, clusterName, + null, app, requireNonNull(ip, "ip"), port, + weight); + + return webClient.prepare().delete(instanceApiPath).content(MediaType.FORM_DATA, params.toQueryString()) + .execute(); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java new file mode 100644 index 00000000000..600661d3d7e --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/internal/nacos/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Various classes used internally. Anything in this package can be changed or removed at any time. + */ +@NonNullByDefault +package com.linecorp.armeria.internal.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java new file mode 100644 index 00000000000..fef61696c9f --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListener.java @@ -0,0 +1,144 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.nacos; + +import static java.util.Objects.requireNonNull; + +import java.net.Inet4Address; +import java.net.URI; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.util.SystemInfo; +import com.linecorp.armeria.internal.nacos.NacosClient; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerListener; +import com.linecorp.armeria.server.ServerListenerAdapter; +import com.linecorp.armeria.server.ServerPort; + +/** + * A {@link ServerListener} which registers the current {@link Server} to + * Nacos. + */ +@UnstableApi +public final class NacosUpdatingListener extends ServerListenerAdapter { + + private static final Logger logger = LoggerFactory.getLogger(NacosUpdatingListener.class); + + /** + * Returns a newly-created {@link NacosUpdatingListenerBuilder} with the specified {@code nacosUri} + * and {@code serviceName} to build {@link NacosUpdatingListener}. + * + * @param nacosUri the URI of Nacos API service, including the path up to but not including API version. + * (example: http://localhost:8848/nacos) + */ + public static NacosUpdatingListenerBuilder builder(URI nacosUri, String serviceName) { + return new NacosUpdatingListenerBuilder(nacosUri, serviceName); + } + + private final NacosClient nacosClient; + + @Nullable + private final Endpoint endpoint; + + private volatile boolean isRegistered; + + NacosUpdatingListener(NacosClient nacosClient, @Nullable Endpoint endpoint) { + this.nacosClient = requireNonNull(nacosClient, "nacosClient"); + this.endpoint = endpoint; + } + + private static Endpoint defaultEndpoint(Server server) { + final ServerPort serverPort = server.activePort(); + assert serverPort != null; + + final Inet4Address inet4Address = SystemInfo.defaultNonLoopbackIpV4Address(); + final String host = inet4Address != null ? inet4Address.getHostAddress() : server.defaultHostname(); + return Endpoint.of(host, serverPort.localAddress().getPort()); + } + + private static void warnIfInactivePort(Server server, int port) { + for (ServerPort serverPort : server.activePorts().values()) { + if (serverPort.localAddress().getPort() == port) { + return; + } + } + logger.warn("The specified port number {} does not exist. (expected one of activePorts: {})", + port, server.activePorts()); + } + + @Override + public void serverStarted(Server server) { + final Endpoint endpoint = getEndpoint(server); + nacosClient.register(endpoint) + .aggregate() + .handle((res, cause) -> { + if (cause != null) { + logger.warn("Failed to register {}:{} to Nacos: {}", + endpoint.host(), endpoint.port(), nacosClient.uri(), cause); + return null; + } + + if (res.status() != HttpStatus.OK) { + logger.warn("Failed to register {}:{} to Nacos: {} (status: {}, content: {})", + endpoint.host(), endpoint.port(), nacosClient.uri(), res.status(), + res.contentUtf8()); + return null; + } + + logger.info("Registered {}:{} to Nacos: {}", + endpoint.host(), endpoint.port(), nacosClient.uri()); + isRegistered = true; + return null; + }); + } + + private Endpoint getEndpoint(Server server) { + if (endpoint != null) { + if (endpoint.hasPort()) { + warnIfInactivePort(server, endpoint.port()); + } + return endpoint; + } + return defaultEndpoint(server); + } + + @Override + public void serverStopping(Server server) { + final Endpoint endpoint = getEndpoint(server); + if (isRegistered) { + nacosClient.deregister(endpoint) + .aggregate() + .handle((res, cause) -> { + if (cause != null) { + logger.warn("Failed to deregister {}:{} from Nacos: {}", + endpoint.ipAddr(), endpoint.port(), nacosClient.uri(), cause); + } else if (res.status() != HttpStatus.OK) { + logger.warn( + "Failed to deregister {}:{} from Nacos: {}. (status: {}, content: {})", + endpoint.ipAddr(), endpoint.port(), nacosClient.uri(), res.status(), + res.contentUtf8()); + } + return null; + }); + } + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java new file mode 100644 index 00000000000..c378ec36736 --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerBuilder.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.nacos; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.net.URI; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.nacos.NacosConfigSetters; +import com.linecorp.armeria.internal.nacos.NacosClient; +import com.linecorp.armeria.internal.nacos.NacosClientBuilder; +import com.linecorp.armeria.server.Server; + +/** + * Builds a new {@link NacosUpdatingListener}, which registers the server to Nacos. + *

    Examples

    + *
    {@code
    + * NacosUpdatingListener listener = NacosUpdatingListener.builder(nacosUri, "myService")
    + *                                                       .build();
    + * ServerBuilder sb = Server.builder();
    + * sb.serverListener(listener);
    + * }
    + */ +@UnstableApi +public final class NacosUpdatingListenerBuilder implements NacosConfigSetters { + + private final NacosClientBuilder nacosClientBuilder; + @Nullable + private Endpoint endpoint; + + /** + * Creates a {@link NacosUpdatingListenerBuilder} with a service name. + */ + NacosUpdatingListenerBuilder(URI nacosUri, String serviceName) { + requireNonNull(serviceName, "serviceName"); + checkArgument(!serviceName.isEmpty(), "serviceName can't be empty"); + nacosClientBuilder = NacosClient.builder(nacosUri, serviceName); + } + + /** + * Sets the {@link Endpoint} to register. If not set, the current host name is used by default. + */ + public NacosUpdatingListenerBuilder endpoint(Endpoint endpoint) { + this.endpoint = requireNonNull(endpoint, "endpoint"); + return this; + } + + @Override + public NacosUpdatingListenerBuilder namespaceId(String namespaceId) { + nacosClientBuilder.namespaceId(namespaceId); + return this; + } + + @Override + public NacosUpdatingListenerBuilder groupName(String groupName) { + nacosClientBuilder.groupName(groupName); + return this; + } + + @Override + public NacosUpdatingListenerBuilder clusterName(String clusterName) { + nacosClientBuilder.clusterName(clusterName); + return this; + } + + @Override + public NacosUpdatingListenerBuilder app(String app) { + nacosClientBuilder.app(app); + return this; + } + + @Override + public NacosUpdatingListenerBuilder nacosApiVersion(String nacosApiVersion) { + nacosClientBuilder.nacosApiVersion(nacosApiVersion); + return this; + } + + @Override + public NacosUpdatingListenerBuilder authorization(String username, String password) { + nacosClientBuilder.authorization(username, password); + return this; + } + + /** + * Returns a newly-created {@link NacosUpdatingListener} that registers the {@link Server} to + * Nacos when the {@link Server} starts. + */ + public NacosUpdatingListener build() { + return new NacosUpdatingListener(nacosClientBuilder.build(), endpoint); + } +} diff --git a/nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java b/nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java new file mode 100644 index 00000000000..0651abf2f9c --- /dev/null +++ b/nacos/src/main/java/com/linecorp/armeria/server/nacos/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Automatic service registration and discovery with Nacos. + **/ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.server.nacos; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java new file mode 100644 index 00000000000..8dafb9f56c6 --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupBuilderTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.client.nacos; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.common.Flags; + +class NacosEndpointGroupBuilderTest { + + @Test + void selectionTimeoutDefault() { + try (NacosEndpointGroup group = NacosEndpointGroup.of(URI.create("http://127.0.0.1/node"), + "testService")) { + assertThat(group.selectionTimeoutMillis()).isEqualTo(Flags.defaultResponseTimeoutMillis()); + } + } + + @Test + void selectionTimeoutCustom() { + try (NacosEndpointGroup group = + NacosEndpointGroup.builder(URI.create("http://127.0.0.1/node"), "testService") + .selectionTimeoutMillis(4000) + .build()) { + assertThat(group.selectionTimeoutMillis()).isEqualTo(4000); + } + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java new file mode 100644 index 00000000000..570dd0950ae --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/client/nacos/NacosEndpointGroupTest.java @@ -0,0 +1,193 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.client.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.internal.nacos.NacosTestBase; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerListener; +import com.linecorp.armeria.server.nacos.NacosUpdatingListener; + +@GenerateNativeImageTrace +class NacosEndpointGroupTest extends NacosTestBase { + + private static final List servers = new ArrayList<>(); + private static final String DEFAULT_CLUSTER_NAME = "c1"; + private static volatile List sampleEndpoints; + + @BeforeAll + static void startServers() { + await().pollInSameThread() + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThatCode(() -> { + final List endpoints = newSampleEndpoints(); + servers.clear(); + for (Endpoint endpoint : endpoints) { + final Server server = Server.builder() + .http(endpoint.port()) + .service("/", new EchoService()) + .build(); + final ServerListener listener = + NacosUpdatingListener + .builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .clusterName(DEFAULT_CLUSTER_NAME) + .build(); + server.addListener(listener); + server.start().join(); + servers.add(server); + } + sampleEndpoints = endpoints; + }).doesNotThrowAnyException()); + } + + @AfterAll + static void stopServers() { + servers.forEach(Server::close); + servers.clear(); + } + + @Test + void testNacosEndpointGroupWithClient() { + try (NacosEndpointGroup endpointGroup = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)) + .build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + + // stop a server + servers.get(0).stop().join(); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()) + .hasSize(sampleEndpoints.size() - 1)); + + // restart the server + await().pollInSameThread().pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> { + // The port bound to the server could be stolen while stopping the server. + assertThatCode(servers.get(0).start()::join).doesNotThrowAnyException(); + }); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + } + + @Test + void testNacosEndpointGroupWithUrl() { + try (NacosEndpointGroup endpointGroup = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)) + .build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + + // stop a server + servers.get(0).stop().join(); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()) + .hasSize(sampleEndpoints.size() - 1)); + + // restart the server + await().pollInSameThread().pollInterval(Duration.ofSeconds(1)).untilAsserted(() -> { + // The port bound to the server could be stolen while stopping the server. + assertThatCode(servers.get(0).start()::join).doesNotThrowAnyException(); + }); + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + } + + @Test + void testSelectStrategy() { + try (NacosEndpointGroup endpointGroup = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)) + .build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.selectNow(null)) + .isNotEqualTo(endpointGroup.selectNow(null))); + } + } + + @Test + void testNacosEndpointGroupWithClusterName() { + final NacosEndpointGroupBuilder builder = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)); + // default cluster name + try (NacosEndpointGroup endpointGroup = builder.clusterName(DEFAULT_CLUSTER_NAME).build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + // non-existent cluster name + try (NacosEndpointGroup endpointGroup = builder.clusterName("c2").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).isEmpty()); + } + } + + @Test + void testNacosEndpointGroupWithNamespaceId() { + final NacosEndpointGroupBuilder builder = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)); + // default namespace id + try (NacosEndpointGroup endpointGroup = builder.namespaceId("public").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + // non-existent namespace id + try (NacosEndpointGroup endpointGroup = builder.namespaceId("private").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).isEmpty()); + } + } + + @Test + void testNacosEndpointGroupWithGroupName() { + final NacosEndpointGroupBuilder builder = + NacosEndpointGroup.builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .registryFetchInterval(Duration.ofSeconds(1)); + try (NacosEndpointGroup endpointGroup = builder.build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).hasSameSizeAs(sampleEndpoints)); + } + try (NacosEndpointGroup endpointGroup = builder.groupName("not-default-group").build()) { + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(endpointGroup.endpoints()).isEmpty()); + } + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java new file mode 100644 index 00000000000..ba374a1dca1 --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosClientBuilderTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpStatus; + +class NacosClientBuilderTest extends NacosTestBase { + + @Test + void gets403WhenNoToken() throws Exception { + final HttpStatus status = WebClient.of(nacosUri()) + .blocking() + .get("/v1/ns/service/list?pageNo=0&pageSize=10") + .status(); + assertThat(status).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void nacosApiVersionCanNotStartsWithSlash() { + assertThrows(IllegalArgumentException.class, () -> + NacosClient.builder(URI.create("http://localhost:8500"), serviceName).nacosApiVersion("/v1")); + assertDoesNotThrow(() -> NacosClient.builder(URI.create("http://localhost:8500"), serviceName) + .nacosApiVersion("v1")); + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java new file mode 100644 index 00000000000..edf4816b028 --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/internal/nacos/NacosTestBase.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.internal.nacos; + +import static com.google.common.base.Preconditions.checkState; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import org.junit.jupiter.api.BeforeAll; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.CompletionActions; +import com.linecorp.armeria.internal.testing.FlakyTest; +import com.linecorp.armeria.server.AbstractHttpService; +import com.linecorp.armeria.server.ServiceRequestContext; + +/** + * A helper class for testing with Nacos. + */ +@FlakyTest +@Testcontainers(disabledWithoutDocker = true) +public abstract class NacosTestBase { + + protected static final String serviceName = "testService"; + protected static final String NACOS_AUTH_TOKEN = "armeriaarmeriaarmeriaarmeriaarmeriaarmeriaarmeriaarmeria"; + protected static final String NACOS_AUTH_SECRET = "nacos"; + @Container + static final GenericContainer nacosContainer = + new GenericContainer(DockerImageName.parse("nacos/nacos-server:v2.3.0-slim")) + .withExposedPorts(8848) + .withEnv("MODE", "standalone") + .withEnv("NACOS_AUTH_ENABLE", "true") + .withEnv("NACOS_AUTH_TOKEN", NACOS_AUTH_TOKEN) + .withEnv("NACOS_AUTH_IDENTITY_KEY", NACOS_AUTH_SECRET) + .withEnv("NACOS_AUTH_IDENTITY_VALUE", NACOS_AUTH_SECRET); + @Nullable + private static URI nacosUri; + + protected NacosTestBase() {} + + protected static List newSampleEndpoints() { + final int[] ports = unusedPorts(3); + return ImmutableList.of(Endpoint.of("host.docker.internal", ports[0]).withWeight(2), + Endpoint.of("host.docker.internal", ports[1]).withWeight(4), + Endpoint.of("host.docker.internal", ports[2]).withWeight(2)); + } + + @BeforeAll + static void start() { + // Initialize Nacos Client + nacosUri = URI.create( + "http://" + nacosContainer.getHost() + ':' + nacosContainer.getMappedPort(8848) + "/nacos"); + } + + protected static NacosClient client(@Nullable String serviceName, @Nullable String groupName) { + final NacosClientBuilder builder; + if (serviceName != null) { + builder = NacosClient.builder(nacosUri, serviceName); + } else { + builder = NacosClient.builder(nacosUri, NacosTestBase.serviceName); + } + + if (groupName != null) { + builder.groupName(groupName); + } + return builder.authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .build(); + } + + protected static URI nacosUri() { + checkState(nacosUri != null, "nacosUri has not initialized."); + return nacosUri; + } + + protected static int[] unusedPorts(int numPorts) { + final int[] ports = new int[numPorts]; + final Random random = ThreadLocalRandom.current(); + for (int i = 0; i < numPorts; i++) { + for (;;) { + final int candidatePort = random.nextInt(64512) + 1024; + try (ServerSocket ss = new ServerSocket()) { + ss.bind(new InetSocketAddress("127.0.0.1", candidatePort)); + ports[i] = candidatePort; + break; + } catch (IOException e) { + // Port in use or unable to bind. + continue; + } + } + } + + return ports; + } + + public static class EchoService extends AbstractHttpService { + private volatile HttpStatus responseStatus = HttpStatus.OK; + + @Override + protected final HttpResponse doHead(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.of(req.aggregate() + .thenApply(aReq -> HttpResponse.of(HttpStatus.OK)) + .exceptionally(CompletionActions::log)); + } + + @Override + protected final HttpResponse doPost(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.of(req.aggregate() + .thenApply(this::echo) + .exceptionally(CompletionActions::log)); + } + + protected HttpResponse echo(AggregatedHttpRequest aReq) { + final HttpStatus httpStatus = HttpStatus.valueOf(aReq.contentUtf8()); + if (httpStatus != HttpStatus.UNKNOWN) { + responseStatus = httpStatus; + } + return HttpResponse.of(ResponseHeaders.of(responseStatus), aReq.content()); + } + } +} diff --git a/nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java b/nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java new file mode 100644 index 00000000000..bb5b558ffca --- /dev/null +++ b/nacos/src/test/java/com/linecorp/armeria/server/nacos/NacosUpdatingListenerTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024 LY Corporation + + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.internal.nacos.NacosTestBase; +import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerListener; + +@GenerateNativeImageTrace +class NacosUpdatingListenerTest extends NacosTestBase { + + private static final List servers = new ArrayList<>(); + private static volatile List sampleEndpoints; + + @BeforeAll + static void startServers() throws JsonProcessingException { + await().pollInSameThread() + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThatCode(() -> { + final List endpoints = newSampleEndpoints(); + servers.clear(); + for (Endpoint endpoint : endpoints) { + final Server server = Server.builder() + .http(endpoint.port()) + .service("/echo", new EchoService()) + .build(); + final ServerListener listener = + NacosUpdatingListener + .builder(nacosUri(), serviceName) + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .endpoint(endpoint) + .build(); + server.addListener(listener); + server.start().join(); + servers.add(server); + } + sampleEndpoints = endpoints; + }).doesNotThrowAnyException()); + } + + @AfterAll + static void stopServers() throws Exception { + servers.forEach(Server::close); + servers.clear(); + } + + @Test + void testBuild() { + assertThat(NacosUpdatingListener.builder(nacosUri(), serviceName) + .build()).isNotNull(); + assertThat(NacosUpdatingListener.builder(nacosUri(), serviceName) + .build()).isNotNull(); + } + + @Test + void testEndpointsCountOfListeningServiceWithAServerStopAndStart() { + // Checks sample endpoints created when initialized. + await().untilAsserted(() -> assertThat(client(null, null).endpoints() + .join()).hasSameSizeAs(sampleEndpoints)); + + // When we close one server then the listener deregister it automatically from nacos. + servers.get(0).stop().join(); + + await().untilAsserted(() -> { + final List results = client(null, null) + .endpoints().join(); + assertThat(results).hasSize(sampleEndpoints.size() - 1); + }); + + // Endpoints increased after service restart. + servers.get(0).start().join(); + + await().untilAsserted(() -> assertThat(client(null, null).endpoints() + .join()).hasSameSizeAs(sampleEndpoints)); + } + + @Test + void testThatGroupNameIsSpecified() { + final int port = unusedPorts(1)[0]; + final Endpoint endpoint = Endpoint.of("host.docker.internal", port).withWeight(1); + + final Server server = Server.builder() + .http(port) + .service("/echo", new EchoService()) + .build(); + final ServerListener listener = + NacosUpdatingListener.builder(nacosUri(), "testThatGroupNameIsSpecified") + .nacosApiVersion("v1") + .authorization(NACOS_AUTH_SECRET, NACOS_AUTH_SECRET) + .endpoint(endpoint) + .groupName("groupName") + .build(); + server.addListener(listener); + server.start().join(); + await().untilAsserted(() -> assertThat(client("testThatGroupNameIsSpecified", "groupName") + .endpoints().join()).hasSize(1)); + server.stop(); + } +} diff --git a/native-image-config/build.gradle.kts b/native-image-config/build.gradle.kts index 5a9c31188b8..350b1096185 100644 --- a/native-image-config/build.gradle.kts +++ b/native-image-config/build.gradle.kts @@ -123,7 +123,6 @@ tasks.register("simplifyNativeImageConfig", SimplifyNativeImageConfigTask::class excludedResourceRegexes.apply { // Exclude the resource files that are referenced only from the tests. add("""Test(?:Utils?)?\.""".toRegex()) - add("""^test(?:ing)?[/\\.]""".toRegex()) add("""^CatalogManager\.properties$""".toRegex()) add("""^META-INF/armeria/grpc$""".toRegex()) add("""^META-INF/dgminfo""".toRegex()) @@ -143,12 +142,13 @@ tasks.register("simplifyNativeImageConfig", SimplifyNativeImageConfigTask::class add("""^jndi\.properties$""".toRegex()) add("""^junit-platform\.properties$""".toRegex()) add("""^log4testng\.properties$""".toRegex()) - add("""^logback-test\.xml$""".toRegex()) + add("""^logback-test\.(xml|properties|scmo)$""".toRegex()) add("""^mockito-extensions/""".toRegex()) add("""^mozilla/""".toRegex()) add("""^org/apache/hc/""".toRegex()) add("""^org/apache/http/""".toRegex()) add("""^org/apache/xml/""".toRegex()) + add("""^test(?:ing)?[/\\.]""".toRegex()) add("""^testcontainers\.properties$""".toRegex()) } } diff --git a/rxjava3/src/test/java/com/linecorp/armeria/server/rxjava3/ObservableResponseConverterFunctionTest.java b/rxjava3/src/test/java/com/linecorp/armeria/server/rxjava3/ObservableResponseConverterFunctionTest.java index 12f1a282695..1801528c3b6 100644 --- a/rxjava3/src/test/java/com/linecorp/armeria/server/rxjava3/ObservableResponseConverterFunctionTest.java +++ b/rxjava3/src/test/java/com/linecorp/armeria/server/rxjava3/ObservableResponseConverterFunctionTest.java @@ -35,6 +35,7 @@ import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.ResponseEntity; import com.linecorp.armeria.common.ResponseHeaders; import com.linecorp.armeria.internal.testing.AnticipatedException; import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace; @@ -90,6 +91,11 @@ public Maybe httpResponse() { public Maybe> httpResult() { return Maybe.just(HttpResult.of("a")); } + + @Get("/response-entity") + public Maybe> responseEntity() { + return Maybe.just(ResponseEntity.of("a")); + } }); sb.annotatedService("/single", new Object() { @@ -118,6 +124,11 @@ public Single httpResponse() { public Single> httpResult() { return Single.just(HttpResult.of("a")); } + + @Get("/response-entity") + public Single> responseEntity() { + return Single.just(ResponseEntity.of("a")); + } }); sb.annotatedService("/completable", new Object() { diff --git a/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/common/scalapb/ScalaPbJsonMarshaller.scala b/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/common/scalapb/ScalaPbJsonMarshaller.scala index 0775c017ae5..215d1dd6bc1 100644 --- a/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/common/scalapb/ScalaPbJsonMarshaller.scala +++ b/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/common/scalapb/ScalaPbJsonMarshaller.scala @@ -58,7 +58,7 @@ final class ScalaPbJsonMarshaller private ( override def deserializeMessage[A](marshaller: Marshaller[A], in: InputStream): A = { val companion = getMessageCompanion(marshaller) val jsonString = Source.fromInputStream(in)(Codec.UTF8).mkString - val message = jsonParser.fromJsonString(jsonString)(companion) + val message = jsonParser.fromJsonString(jsonString)(using companion) marshaller match { case marshaller: TypeMappedMarshaller[_, _] => val method = typeMapperMethodCache.computeIfAbsent( diff --git a/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/server/scalapb/ScalaPbRequestConverterFunction.scala b/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/server/scalapb/ScalaPbRequestConverterFunction.scala index df63731a88a..67f4d2f51cf 100644 --- a/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/server/scalapb/ScalaPbRequestConverterFunction.scala +++ b/scalapb/scalapb_3/src/main/scala/com/linecorp/armeria/server/scalapb/ScalaPbRequestConverterFunction.scala @@ -173,7 +173,7 @@ final class ScalaPbRequestConverterFunction private (jsonParser: Parser, resultT */ private def jsonToScalaPbMessage(expectedResultType: Class[_], json: String): Any with Serializable = { val messageType = extractGeneratedMessageType(expectedResultType) - val message: GeneratedMessage = jsonParser.fromJsonString(json)(getCompanion(messageType)) + val message: GeneratedMessage = jsonParser.fromJsonString(json)(using getCompanion(messageType)) toGenerateMessageOrOneof(expectedResultType, message) } } diff --git a/settings.gradle b/settings.gradle index e16cf5341f9..64a00b3b1af 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,7 +6,7 @@ plugins { // automatically download one based on the foojay Disco API. // https://docs.gradle.org/8.1.1/userguide/toolchains.html#sec:provisioning id 'org.gradle.toolchains.foojay-resolver-convention' version '0.6.0' - id 'com.gradle.develocity' version '3.18' + id 'com.gradle.develocity' version '3.18.2' // adds additional metadata to build scans id 'com.gradle.common-custom-user-data-gradle-plugin' version '2.0.2' } @@ -122,6 +122,7 @@ includeWithFlags ':logback13', 'java', 'publish', 'rel project(':logback13').projectDir = file('logback/logback13') includeWithFlags ':logback14', 'java11', 'publish', 'relocate', 'no_aggregation' project(':logback14').projectDir = file('logback/logback14') +includeWithFlags ':micrometer-context', 'java', 'relocate', 'native' includeWithFlags ':native-image-config' includeWithFlags ':oauth2', 'java', 'publish', 'relocate', 'native' includeWithFlags ':prometheus1', 'java', 'publish', 'relocate', 'native' @@ -194,6 +195,7 @@ includeWithFlags ':zookeeper3', 'java', 'publish', 'rel includeWithFlags ':saml', 'java', 'publish', 'relocate', 'native' includeWithFlags ':bucket4j', 'java', 'publish', 'relocate', 'native' includeWithFlags ':consul', 'java', 'publish', 'relocate', 'native' +includeWithFlags ':nacos', 'java', 'publish', 'relocate', 'native' // Published Javadoc-only projects includeWithFlags ':javadoc', 'java', 'publish', 'no_aggregation' @@ -217,6 +219,7 @@ includeWithFlags ':it:jackson-provider', 'java', 'relocate includeWithFlags ':it:kotlin', 'java', 'relocate', 'kotlin' includeWithFlags ':it:kubernetes-chaos-tests', 'java', 'relocate' includeWithFlags ':it:logback1.4', 'java11', 'relocate' +includeWithFlags ':it:logback1.5', 'java11', 'relocate', 'no_aggregation' includeWithFlags ':it:multipart', 'java17', 'relocate' includeWithFlags ':it:nio', 'java', 'relocate' includeWithFlags ':it:okhttp', 'java', 'relocate' diff --git a/site/src/pages/release-notes/1.30.2.mdx b/site/src/pages/release-notes/1.30.2.mdx new file mode 100644 index 00000000000..223bc9d325b --- /dev/null +++ b/site/src/pages/release-notes/1.30.2.mdx @@ -0,0 +1,20 @@ +--- +date: 2024-11-22 +--- + +## 🛠️ Bug fixes + +- Fixed a race condition which intermittently prevented from completing. #5981 #5986 +- Fixed a bug where the original request path is not exposed to . #5931 #5932 + - You can access the raw request path via . +- Fixed a bug where the is unnecessarily pushed and popped. #5985 + +## 🙇 Thank you + + diff --git a/site/src/pages/release-notes/1.31.0.mdx b/site/src/pages/release-notes/1.31.0.mdx new file mode 100644 index 00000000000..076a0dd14bd --- /dev/null +++ b/site/src/pages/release-notes/1.31.0.mdx @@ -0,0 +1,138 @@ +--- +date: 2024-11-13 +--- + +## 🌟 New features + +- **Dynamic TLS Configuration**: You can now update TLS configurations dynamically using #5033 #5228 + - You can create a which specifies dynamically. + ```java + // return a TlsKeyPair for host patterns + TlsProvider tlsProvider = TlsProvider + .builder() + .keyPair("*.a.com", TlsKeyPair.of(...)) + .keyPair("*.b.com", TlsKeyPair.of(...)) + .build(); + + // return a TlsKeyPair dynamically + TlsProvider tlsProvider = hostname -> TlsKeyPair.of(...); + ``` + - The can be used for both servers and clients. + ```java + // client-side + ClientFactory + .builder() + .tlsProvider(tlsProvider); + + // server-side + Server + .builder() + .tlsProvider(tlsProvider); + ``` +- **Access to Raw Request Path**: You can access the raw request path via . #5931 #5932 + - This can be useful when you need to get the original request path sent from the client, even if it's potentially insecure. + ```java + ServiceRequestContext ctx = ...; + String rawPath = ctx.rawPath(); + // rawPath may contain unencoded, invalid or insecure characters. + ``` +- **Additional Static Factory Methods for ResponseEntity**: More static factory methods are added for . #5954 + ```java + ResponseEntity response = ResponseEntity.of(200); + ResponseEntity response = ResponseEntity.of("Hello!"); + ``` +- **Timeout Support for HttpRequest and HttpResponse**: You can specify or to set a timeout + for an or . #5744 #5761 + ```java + HttpRequest + .streaming(HttpMethod.GET, "/") + .timeout(Duration.ofSeconds(1)) // timeout if there is a delay exceeding 1 second between each data chunk + ``` +- **Nacos Integration for Server Registration and Endpoint Retrieval**: You can now register your server to or retrieve from +a [Nacos](https://nacos.io/) registry server. #5365 #5409 + - Use to register the to [Nacos](https://nacos.io/): + ```java + Server server = ...; + server.addListener( + NacosUpdatingListener + .builder(nacosUri, "my-service") + ... + .build()); + ``` + - Use for clients: + ```java + EndpointGroup group = NacosEndpointGroup.of(nacosUri, "my-service"); + WebClient client = WebClient.of(SessionProtocol.HTTP, group); + ``` + +## 📈 Improvements + +- now supports `disable_active_health_check`. #5879 + +## 🛠️ Bug fixes + +- DNS resolver now correctly adds search domains for hostnames with trailing dots. #5963 +- CORS headers for failed requests are now correctly set even when a custom is configured. #5493 #5939 +- Fixed a bug where is incompatible with the OpenTelemetry gRPC agent. #5937 #5938 +- The GraalVM native image metadata has been updated to reflect recent code changes. #5946 +- Spring Actuator correctly collects metrics when using Spring WebFlux integration. #5882 #5884 + +## ☢️ Breaking changes + +- ABI compatibility of has been broken. #5954 + +## ⛓ Dependencies + +- Brotli4j 1.16.0 → 1.17.0 +- java-control-plane 1.0.45 → 1.0.46 +- Eureka 2.0.3 → 2.0.4 +- GraphQL Kotlin 7.1.4 → 8.2.1 +- Java gRPC 1.65.1 → 1.68.1 +- Jackson 2.17.2 → 2.18.1 +- Jetty + - 11.0.22 → 11.0.24 + - 12.0.12 → 12.0.14 +- Kotlin 3.8.0 → 3.8.1 +- Kotlin coroutines 1.8.1 → 1.9.0 +- Fabric8 Kubernetes Client 6.13.1 → 6.13.4 +- Micrometer 1.13.2 → 1.13.6 +- Micrometer Tracing 1.3.2 → 1.3.5 +- Netty 4.1.112.Final → 4.1.115.Final +- prometheus 1.3.1 → 1.3.2 +- Protobuf 3.25.1 → 3.25.5 +- protobuf-jackson 2.5.0 → 2.6.0 +- Reactor 3.6.8 → 3.6.11 +- RXJava 3.1.8 → 3.1.9 +- Sangria 4.1.1 → 4.2.2 +- Scala + - 2.12.19 → 2.12.20 + - 2.13.14 → 2.13.15 + - 3.4.2 → 3.6.1 +- Spring 6.1.11 → 6.1.14 +- Spring Boot 3.3.2 → 3.3.5 +- Tomcat + - 9.0.91 → 9.0.96 + - 10.1.26 → 10.1.31 + +## 🙇 Thank you + + \ No newline at end of file diff --git a/site/src/pages/release-notes/1.31.1.mdx b/site/src/pages/release-notes/1.31.1.mdx new file mode 100644 index 00000000000..fc86f1298be --- /dev/null +++ b/site/src/pages/release-notes/1.31.1.mdx @@ -0,0 +1,25 @@ +--- +date: 2024-11-22 +--- + +## 🛠️ Bug fixes + +- Fixed a race condition which intermittently prevented from completing. #5981 #5986 +- `armeria-grpc` no longer exposes `protobuf-java` 4.x as a compile-time dependency. #5990 #5992 + +## 📈 Improvements + +- Slightly improved performance when is used. #5984 #5985 + +## 🙇 Thank you + + \ No newline at end of file diff --git a/site/src/pages/release-notes/1.31.2.mdx b/site/src/pages/release-notes/1.31.2.mdx new file mode 100644 index 00000000000..68daf15d4ea --- /dev/null +++ b/site/src/pages/release-notes/1.31.2.mdx @@ -0,0 +1,18 @@ +--- +date: 2024-12-03 +--- + +## 🛠️ Bug fixes + +- Fixed a bug where stale endpoints remained when a Kubernetes service was updated. #6012 +- Fix a bug where weights could unintentionally be set to 0 in ramping-up strategies. #6014 + +## 🙇 Thank you + + diff --git a/spring/boot2-webflux-autoconfigure/build.gradle b/spring/boot2-webflux-autoconfigure/build.gradle index a3f58820359..37ca955225b 100644 --- a/spring/boot2-webflux-autoconfigure/build.gradle +++ b/spring/boot2-webflux-autoconfigure/build.gradle @@ -86,6 +86,8 @@ task generateSources(type: Copy) { exclude '**/package-info.java' exclude '**/org.springframework.boot.autoconfigure.AutoConfiguration.imports' exclude '**/TlsUtil.java' + // Micrometer observation is not supported for spring-boot-2 + exclude '**/ObservationTest.java' } into "${project.ext.genSrcDir}" diff --git a/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java b/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java index 5b51d68b9b3..293401a9040 100644 --- a/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java +++ b/spring/boot2-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java @@ -51,7 +51,14 @@ public boolean setStatusCode(@Nullable HttpStatus status) { @Override @Nullable public HttpStatus getStatusCode() { - return statusCode != null ? HttpStatus.resolve(statusCode) : null; + if (statusCode != null) { + return HttpStatus.resolve(statusCode); + } + final Integer statusCode0 = getStatusCode0(); + if (statusCode0 != null) { + return HttpStatus.resolve(statusCode0); + } + return null; } @Override @@ -67,6 +74,7 @@ public boolean setRawStatusCode(@Nullable Integer statusCode) { @Override @Nullable public Integer getRawStatusCode() { - return statusCode; + final HttpStatus statusCode = getStatusCode(); + return statusCode != null ? statusCode.value() : null; } } diff --git a/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaAutoConfigurationTest.java b/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaAutoConfigurationTest.java index 46e8e1b1f5b..c700c24f1de 100644 --- a/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaAutoConfigurationTest.java +++ b/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaAutoConfigurationTest.java @@ -382,7 +382,7 @@ void testPortConfiguration() { void testMetrics() { assertThat(GrpcClients.newClient(newUrl("h2c") + '/', TestServiceBlockingStub.class) .hello(HelloRequest.getDefaultInstance()) - .getMessage()).isNotNull(); + .getMessage()).isEqualTo("Hello, "); final String metricReport = WebClient.of(newUrl("http")) .get("/internal/metrics") diff --git a/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java b/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java index 44c2cae2b6d..e1093a41298 100644 --- a/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java +++ b/spring/boot3-autoconfigure/src/test/java/com/linecorp/armeria/spring/ArmeriaSettingsConfigurationTest.java @@ -28,11 +28,14 @@ import org.springframework.test.context.ActiveProfiles; import com.linecorp.armeria.common.DependencyInjector; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.Server; import com.linecorp.armeria.server.ServerConfig; import com.linecorp.armeria.server.ServerErrorHandler; +import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.VirtualHost; import com.linecorp.armeria.spring.ArmeriaSettingsConfigurationTest.TestConfiguration; @@ -119,6 +122,9 @@ void buildServerBasedOnProperties() { assertThat(config.gracefulShutdown().quietPeriod().toMillis()).isEqualTo(1000); assertThat(config.dependencyInjector().getInstance(Object.class)).isSameAs(dummyObject); - assertThat(config.errorHandler().onServiceException(null, null)).isSameAs(dummyResponse); + final ServiceRequestContext ctx = ServiceRequestContext.of( + HttpRequest.of(HttpMethod.GET, "/")); + assertThat(config.errorHandler().onServiceException(ctx, new RuntimeException())) + .isSameAs(dummyResponse); } } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java index efc41de27ce..2ee52c105d4 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponse.java @@ -303,4 +303,7 @@ protected abstract Mono writeAndFlushWithInternal( */ protected void touchDataBuffer(DataBuffer buffer) { } + + @Nullable + abstract Integer getStatusCode0(); } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java index 46bde5e8008..e0c2d40b89b 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/AbstractServerHttpResponseVersionSpecific.java @@ -50,7 +50,14 @@ public boolean setStatusCode(@Nullable HttpStatusCode status) { @Override @Nullable public HttpStatusCode getStatusCode() { - return statusCode; + if (statusCode != null) { + return statusCode; + } + final Integer statusCode0 = getStatusCode0(); + if (statusCode0 != null) { + return HttpStatusCode.valueOf(statusCode0); + } + return null; } @Override @@ -62,6 +69,7 @@ public boolean setRawStatusCode(@Nullable Integer statusCode) { @Override @Nullable public Integer getRawStatusCode() { + final HttpStatusCode statusCode = getStatusCode(); return statusCode != null ? statusCode.value() : null; } } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java index f8dd3ae00e5..0b5f4282277 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaHttpHandlerAdapter.java @@ -15,6 +15,7 @@ */ package com.linecorp.armeria.spring.web.reactive; +import static com.linecorp.armeria.common.logging.RequestLogProperty.RESPONSE_HEADERS; import static java.util.Objects.requireNonNull; import java.util.concurrent.CompletableFuture; @@ -28,6 +29,7 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.logging.RequestLog; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.spring.internal.common.DataBufferFactoryWrapper; @@ -70,6 +72,15 @@ Mono handle(ServiceRequestContext ctx, HttpRequest req, CompletableFuture< .doOnError(cause -> { logger.debug("{} Failed to handle a request", ctx, cause); convertedResponse.setComplete(cause).subscribe(); + }) + .doOnCancel(() -> { + // If Armeria has already returned a response (e.g. RequestTimeout) + // make a best effort to reflect this in the returned response + final RequestLog requestLog = ctx.log().getIfAvailable(RESPONSE_HEADERS); + if (requestLog != null) { + convertedResponse.setRawStatusCode(requestLog.responseStatus().code()); + convertedResponse.setComplete().subscribe(); + } }); } } diff --git a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java index ab8e630ecb1..f45246b87f4 100644 --- a/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java +++ b/spring/boot3-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java @@ -120,6 +120,15 @@ protected void applyStatusCode() { } } + @Nullable + @Override + Integer getStatusCode0() { + if (future.isDone() && !future.isCompletedExceptionally()) { + return armeriaHeaders.status().code(); + } + return null; + } + @Override protected void applyHeaders() { getHeaders().forEach((name, values) -> armeriaHeaders.add(HttpHeaderNames.of(name), values)); diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java new file mode 100644 index 00000000000..02653219e39 --- /dev/null +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ObservationTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.spring.web.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.server.logging.LoggingService; +import com.linecorp.armeria.spring.ArmeriaServerConfigurator; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation.Context; +import io.micrometer.observation.ObservationHandler; +import reactor.core.publisher.Mono; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class ObservationTest { + + private static final AtomicReference ctxStatusRef = new AtomicReference<>(); + + @SpringBootApplication + @Configuration + static class TestConfiguration { + @RestController + static class TestController { + @GetMapping("/hello") + Mono hello(@RequestParam String mode) { + return switch (mode) { + case "throw" -> throw new RuntimeException("exception thrown"); + case "success" -> Mono.just("world"); + case "timeout" -> Mono.never(); + default -> throw new RuntimeException("Unexpected mode: " + mode); + }; + } + } + + @Bean + public ArmeriaServerConfigurator serverConfigurator() { + return sb -> sb.decorator(LoggingService.newDecorator()) + .requestTimeout(Duration.ofSeconds(1)); + } + + @Bean + public ObservationHandler observationHandler() { + return new ObservationHandler<>() { + @Override + public boolean supportsContext(Context context) { + return true; + } + + @Override + public void onStop(Context context) { + final KeyValue keyValue = context.getLowCardinalityKeyValue("status"); + if (keyValue != null) { + ctxStatusRef.set(keyValue.getValue()); + } + } + }; + } + } + + @LocalServerPort + int port; + + @BeforeEach + void beforeEach() { + ctxStatusRef.set(null); + } + + @Test + void ok() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/hello?mode=success"); + assertThat(response.status().code()).isEqualTo(200); + assertThat(response.contentUtf8()).isEqualTo("world"); + + await().untilAsserted(() -> assertThat(ctxStatusRef.get()).isNotNull()); + final String ctxStatus = ctxStatusRef.get(); + assertThat(ctxStatus).isNotNull(); + assertThat(ctxStatus).isEqualTo("200"); + } + + @Test + void throwsException() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/hello?mode=throw"); + assertThat(response.status().code()).isEqualTo(500); + + await().untilAsserted(() -> assertThat(ctxStatusRef.get()).isNotNull()); + final String ctxStatus = ctxStatusRef.get(); + assertThat(ctxStatus).isNotNull(); + assertThat(ctxStatus).isEqualTo("500"); + } + + @Test + void timesOut() throws Exception { + final WebClient client = WebClient.of("http://127.0.0.1:" + port); + final AggregatedHttpResponse response = client.blocking().get("/hello?mode=timeout"); + assertThat(response.status().code()).isEqualTo(503); + + await().untilAsserted(() -> assertThat(ctxStatusRef.get()).isNotNull()); + final String ctxStatus = ctxStatusRef.get(); + assertThat(ctxStatus).isNotNull(); + assertThat(ctxStatus).isEqualTo("503"); + } +} diff --git a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ReactiveWebServerLoadBalancerInteropTest.java b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ReactiveWebServerLoadBalancerInteropTest.java index 48c50893de5..84b5d2d3856 100644 --- a/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ReactiveWebServerLoadBalancerInteropTest.java +++ b/spring/boot3-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ReactiveWebServerLoadBalancerInteropTest.java @@ -22,6 +22,8 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.route; import static org.springframework.web.reactive.function.server.ServerResponse.ok; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; import org.junit.jupiter.api.AfterEach; @@ -50,7 +52,7 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; +import ch.qos.logback.core.AppenderBase; import reactor.core.publisher.Mono; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @@ -79,7 +81,7 @@ RouterFunction routerFunction() { int port; final Logger httpWebHandlerAdapterLogger = (Logger) LoggerFactory.getLogger(HttpWebHandlerAdapter.class); - final ListAppender logAppender = new ListAppender<>(); + final ConcurrentListAppender logAppender = new ConcurrentListAppender<>(); @BeforeEach public void attachAppender() { @@ -148,4 +150,14 @@ private void assertNoErrorLogByHttpWebHandlerAdapter() { .collect(Collectors.toList())) .isEmpty(); } + + private static final class ConcurrentListAppender extends AppenderBase { + + List list = new CopyOnWriteArrayList<>(); + + @Override + protected void append(E eventObject) { + list.add(eventObject); + } + } } diff --git a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java index 7e61bad7d06..637cec96a96 100644 --- a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java +++ b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/common/thrift/ThriftMetadataAccess.java @@ -36,7 +36,7 @@ public final class ThriftMetadataAccess { private static boolean preInitializeThriftClass; - private static final String THRIFT_OPTIONS_PROPERTIES = "../../common/thrift/thrift-options.properties"; + private static final String THRIFT_OPTIONS_PROPERTIES = "thrift-options.properties"; static { try { diff --git a/tomcat10/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java b/tomcat10/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java index e11756065ae..9061aa03f2d 100644 --- a/tomcat10/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java +++ b/tomcat10/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java @@ -54,6 +54,9 @@ protected void finishResponse() throws IOException {} @Override protected void ack(ContinueResponseTiming continueResponseTiming) {} + @Override + protected void earlyHints() throws IOException {} + @Override protected void flush() throws IOException {} diff --git a/tomcat9/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java b/tomcat9/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java index 7a3029eb2d2..5452b1cfca9 100644 --- a/tomcat9/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java +++ b/tomcat9/src/main/java/com/linecorp/armeria/server/tomcat/ArmeriaProcessor.java @@ -51,6 +51,9 @@ protected void finishResponse() throws IOException {} @Override protected void ack(ContinueResponseTiming continueResponseTiming) {} + @Override + protected void earlyHints() throws IOException {} + @Override protected void flush() throws IOException {} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java new file mode 100644 index 00000000000..36d2753f3eb --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/StaticHttpHealthChecker.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.client.endpoint; + +import java.util.concurrent.CompletableFuture; + +import com.linecorp.armeria.client.endpoint.healthcheck.HealthCheckerContext; +import com.linecorp.armeria.common.util.UnmodifiableFuture; +import com.linecorp.armeria.internal.client.endpoint.healthcheck.HttpHealthChecker; + +final class StaticHttpHealthChecker implements HttpHealthChecker { + + public static HttpHealthChecker of(HealthCheckerContext ctx, double healthy) { + return new StaticHttpHealthChecker(ctx, healthy); + } + + private StaticHttpHealthChecker(HealthCheckerContext ctx, double healthy) { + ctx.updateHealth(healthy, null, null, null); + } + + @Override + public CompletableFuture closeAsync() { + return UnmodifiableFuture.completedFuture(null); + } + + @Override + public void close() { + } +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java index dadc53091dc..ef12afd7b00 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java @@ -30,7 +30,7 @@ import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.util.AsyncCloseable; -import com.linecorp.armeria.internal.client.endpoint.healthcheck.HttpHealthChecker; +import com.linecorp.armeria.internal.client.endpoint.healthcheck.DefaultHttpHealthChecker; import io.envoyproxy.envoy.config.cluster.v3.Cluster; import io.envoyproxy.envoy.config.core.v3.HealthCheck.HttpHealthCheck; @@ -57,13 +57,17 @@ final class XdsHealthCheckedEndpointGroupBuilder return ctx -> { final LbEndpoint lbEndpoint = EndpointUtil.lbEndpoint(ctx.originalEndpoint()); final HealthCheckConfig healthCheckConfig = lbEndpoint.getEndpoint().getHealthCheckConfig(); + if (healthCheckConfig.getDisableActiveHealthCheck()) { + // health check is disabled, so assume the endpoint is healthy + return StaticHttpHealthChecker.of(ctx, 1.0); + } final String path = httpHealthCheck.getPath(); final String host = Strings.emptyToNull(httpHealthCheck.getHost()); - final HttpHealthChecker checker = - new HttpHealthChecker(ctx, endpoint(healthCheckConfig, ctx.originalEndpoint()), - path, httpMethod(httpHealthCheck) == HttpMethod.GET, - protocol(cluster), host); + final DefaultHttpHealthChecker checker = + new DefaultHttpHealthChecker(ctx, endpoint(healthCheckConfig, ctx.originalEndpoint()), + path, httpMethod(httpHealthCheck) == HttpMethod.GET, + protocol(cluster), host); checker.start(); return checker; }; diff --git a/xds/src/test/java/com/linecorp/armeria/xds/MissingResourceTest.java b/xds/src/test/java/com/linecorp/armeria/xds/MissingResourceTest.java index d4950524d64..9dff37e4676 100644 --- a/xds/src/test/java/com/linecorp/armeria/xds/MissingResourceTest.java +++ b/xds/src/test/java/com/linecorp/armeria/xds/MissingResourceTest.java @@ -212,7 +212,7 @@ static class TestRequestObserver implements StreamObserver { @Override public void onNext(DiscoveryRequest value) { - if (value.getErrorDetail() != null && value.getErrorDetail().getCode() != 0) { + if (value.hasErrorDetail() && value.getErrorDetail().getCode() != 0) { logger.warn("Unexpected request with error: {}", value.getErrorDetail()); return; } diff --git a/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java b/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java index 4c319796d97..c5d395ef3a9 100644 --- a/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java +++ b/xds/src/test/java/com/linecorp/armeria/xds/XdsTestResources.java @@ -52,6 +52,7 @@ import io.envoyproxy.envoy.config.core.v3.TransportSocket; import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; import io.envoyproxy.envoy.config.endpoint.v3.Endpoint; +import io.envoyproxy.envoy.config.endpoint.v3.Endpoint.HealthCheckConfig; import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint; import io.envoyproxy.envoy.config.endpoint.v3.LocalityLbEndpoints; import io.envoyproxy.envoy.config.listener.v3.ApiListener; @@ -105,6 +106,11 @@ public static LbEndpoint endpoint(String address, int port, Metadata metadata) { public static LbEndpoint endpoint(String address, int port, Metadata metadata, int weight, HealthStatus healthStatus) { + return endpoint(address, port, metadata, weight, healthStatus, HealthCheckConfig.getDefaultInstance()); + } + + public static LbEndpoint endpoint(String address, int port, Metadata metadata, int weight, + HealthStatus healthStatus, HealthCheckConfig healthCheckConfig) { return LbEndpoint .newBuilder() .setLoadBalancingWeight(UInt32Value.of(weight)) @@ -112,6 +118,7 @@ public static LbEndpoint endpoint(String address, int port, Metadata metadata, i .setHealthStatus(healthStatus) .setEndpoint(Endpoint.newBuilder() .setAddress(address(address, port)) + .setHealthCheckConfig(healthCheckConfig) .build()).build(); } diff --git a/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java b/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java index ece7115fd88..ddae44adc8f 100644 --- a/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java +++ b/xds/src/test/java/com/linecorp/armeria/xds/client/endpoint/HealthCheckedTest.java @@ -35,6 +35,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import com.google.common.collect.ImmutableList; @@ -59,8 +60,10 @@ import io.envoyproxy.envoy.config.core.v3.HealthCheck.HttpHealthCheck; import io.envoyproxy.envoy.config.core.v3.HealthStatus; import io.envoyproxy.envoy.config.core.v3.Locality; +import io.envoyproxy.envoy.config.core.v3.Metadata; import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment.Policy; +import io.envoyproxy.envoy.config.endpoint.v3.Endpoint.HealthCheckConfig; import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint; import io.envoyproxy.envoy.config.listener.v3.Listener; import io.envoyproxy.envoy.type.v3.Percent; @@ -222,6 +225,68 @@ void panicCase(double panicThreshold, boolean panicMode) { } } + @ParameterizedTest + @EnumSource(value = HealthStatus.class, names = {"UNKNOWN", "UNHEALTHY", "HEALTHY"}) + void disabled(HealthStatus healthStatus) { + final Listener listener = staticResourceListener(); + final HealthCheckConfig disabledConfig = HealthCheckConfig.newBuilder() + .setDisableActiveHealthCheck(true).build(); + + final List healthyEndpoints = + server.server().activePorts().keySet() + .stream().map(addr -> testEndpoint(addr, healthStatus, disabledConfig)) + .collect(Collectors.toList()); + assertThat(healthyEndpoints).hasSize(3); + final List unhealthyEndpoints = + noHealthCheck.server().activePorts().keySet() + .stream().map(addr -> testEndpoint(addr, healthStatus, disabledConfig)) + .collect(Collectors.toList()); + assertThat(unhealthyEndpoints).hasSize(3); + final List allEndpoints = ImmutableList.builder() + .addAll(healthyEndpoints) + .addAll(unhealthyEndpoints).build(); + + final ClusterLoadAssignment loadAssignment = + ClusterLoadAssignment + .newBuilder() + .addEndpoints(localityLbEndpoints(Locality.getDefaultInstance(), allEndpoints)) + .setPolicy(Policy.newBuilder().setWeightedPriorityHealth(true)) + .build(); + final HttpHealthCheck httpHealthCheck = HttpHealthCheck.newBuilder() + .setPath("/monitor/healthcheck") + .build(); + final Cluster cluster = createStaticCluster("cluster", loadAssignment) + .toBuilder() + .addHealthChecks(HealthCheck.newBuilder().setHttpHealthCheck(httpHealthCheck)) + .setCommonLbConfig(CommonLbConfig.newBuilder() + .setHealthyPanicThreshold(Percent.newBuilder().setValue(0))) + .build(); + + final Bootstrap bootstrap = staticBootstrap(listener, cluster); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + EndpointGroup endpointGroup = XdsEndpointGroup.of("listener", xdsBootstrap)) { + await().untilAsserted(() -> assertThat(endpointGroup.whenReady()).isDone()); + + final ClientRequestContext ctx = + ClientRequestContext.of(HttpRequest.of(HttpMethod.GET, "/")); + final Endpoint endpoint = endpointGroup.selectNow(ctx); + + // The healthStatus set to the endpoint overrides + if (healthStatus == HealthStatus.HEALTHY || healthStatus == HealthStatus.UNKNOWN) { + assertThat(endpoint).isNotNull(); + } else { + assertThat(healthStatus).isEqualTo(HealthStatus.UNHEALTHY); + assertThat(endpoint).isNull(); + } + } + } + + private static LbEndpoint testEndpoint(InetSocketAddress address, HealthStatus healthStatus, + HealthCheckConfig config) { + return endpoint(address.getAddress().getHostAddress(), address.getPort(), + Metadata.getDefaultInstance(), 1, healthStatus, config); + } + private static List ports(ServerExtension server) { return server.server().activePorts().keySet().stream() .map(InetSocketAddress::getPort).collect(Collectors.toList());