diff --git a/build.gradle b/build.gradle index 3e2677bd6..25bcdacf6 100644 --- a/build.gradle +++ b/build.gradle @@ -62,7 +62,6 @@ allprojects { subprojects { apply plugin: 'java-library' - apply plugin: 'org.inferred.processors' sourceCompatibility = 1.8 diff --git a/dialogue-apache-hc4-client/src/main/java/com/palantir/dialogue/hc4/DialogueApacheHttpClient.java b/dialogue-apache-hc4-client/src/main/java/com/palantir/dialogue/hc4/DialogueApacheHttpClient.java new file mode 100644 index 000000000..3ad43f73c --- /dev/null +++ b/dialogue-apache-hc4-client/src/main/java/com/palantir/dialogue/hc4/DialogueApacheHttpClient.java @@ -0,0 +1,334 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.palantir.dialogue.hc4; + +import com.google.common.net.HostAndPort; +import com.google.common.primitives.Ints; +import com.palantir.conjure.java.api.config.service.BasicCredentials; +import com.palantir.conjure.java.client.config.CipherSuites; +import com.palantir.conjure.java.client.config.ClientConfiguration; +import com.palantir.dialogue.Channel; +import com.palantir.dialogue.blocking.BlockingChannelAdapter; +import com.palantir.dialogue.core.DialogueConfig; +import com.palantir.dialogue.core.HttpChannelFactory; +import com.palantir.dialogue.core.Listenable; +import com.palantir.dialogue.core.SharedResources; +import com.palantir.logsafe.Preconditions; +import com.palantir.logsafe.SafeArg; +import com.palantir.logsafe.UnsafeArg; +import com.palantir.logsafe.exceptions.SafeIllegalArgumentException; +import java.net.MalformedURLException; +import java.net.ProxySelector; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLSocketFactory; +import org.apache.http.Header; +import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; +import org.apache.http.auth.AuthOption; +import org.apache.http.auth.AuthScheme; +import org.apache.http.auth.AuthSchemeProvider; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.AuthenticationStrategy; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.AuthSchemes; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.config.SocketConfig; +import org.apache.http.conn.ssl.DefaultHostnameVerifier; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.auth.BasicSchemeFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.client.ProxyAuthenticationStrategy; +import org.apache.http.impl.conn.SystemDefaultRoutePlanner; +import org.apache.http.protocol.HttpContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public enum DialogueApacheHttpClient implements HttpChannelFactory { + INSTANCE; + + private static final Logger log = LoggerFactory.getLogger(DialogueApacheHttpClient.class); + private static final String STORE = "DialogueApacheHttpClient"; + + @Override + public Channel construct(String uri, Listenable config, SharedResources sharedResources) { + CloseableHttpClient client = sharedResources + .getStore(STORE) + .getOrComputeIfAbsent( + "one-off-client-construction", + unused -> { + return constructLiveReloadingClient(config); // we'll re-use this instance every time + }, + CloseableHttpClient.class); + + return BlockingChannelAdapter.of(new ApacheHttpClientBlockingChannel(client, url(uri))); + } + + private static CloseableHttpClient constructLiveReloadingClient(Listenable listenable) { + ConfigurationSubset params = deriveSubsetWeCareAbout(listenable.getListenableCurrentValue()); + + listenable.subscribe(() -> { + ConfigurationSubset newParams = deriveSubsetWeCareAbout(listenable.getListenableCurrentValue()); + if (params.equals(newParams)) { + // this means users changed something which is irrelevant to us (e.g. a url) + return; + } + + log.warn( + "Unable to live-reload some configuration changes, ignoring them and using the old configuration " + + "{} {}", + SafeArg.of("old", params), + SafeArg.of("new", newParams)); + }); + + return createCloseableHttpClient(params); + } + + private static ConfigurationSubset deriveSubsetWeCareAbout(DialogueConfig config) { + ClientConfiguration legacyConf = config.legacyClientConfiguration; + + ConfigurationSubset conf = new ConfigurationSubset(); + conf.connectTimeout = legacyConf.connectTimeout(); + conf.readTimeout = legacyConf.readTimeout(); + conf.writeTimeout = legacyConf.writeTimeout(); + conf.sslSocketFactory = legacyConf.sslSocketFactory(); + conf.enableGcmCipherSuites = legacyConf.enableGcmCipherSuites(); + conf.fallbackToCommonNameVerification = legacyConf.fallbackToCommonNameVerification(); + conf.meshProxy = legacyConf.meshProxy(); + conf.proxy = legacyConf.proxy(); + conf.proxyCredentials = legacyConf.proxyCredentials(); + return conf; + } + + private static CloseableHttpClient createCloseableHttpClient(ConfigurationSubset conf) { + log.info("Constructing ClosableHttpClient with conf {}", UnsafeArg.of("conf", conf)); + Preconditions.checkArgument( + !conf.fallbackToCommonNameVerification, "fallback-to-common-name-verification is not supported"); + Preconditions.checkArgument(!conf.meshProxy.isPresent(), "Mesh proxy is not supported"); + + long socketTimeoutMillis = Math.max(conf.readTimeout.toMillis(), conf.writeTimeout.toMillis()); + int connectTimeout = Ints.checkedCast(conf.connectTimeout.toMillis()); + + HttpClientBuilder builder = HttpClients.custom() + .setDefaultRequestConfig(RequestConfig.custom() + .setSocketTimeout(Ints.checkedCast(socketTimeoutMillis)) + .setConnectTimeout(connectTimeout) + // Don't allow clients to block forever waiting on a connection to become available + .setConnectionRequestTimeout(connectTimeout) + // Match okhttp, disallow redirects + .setRedirectsEnabled(false) + .setRelativeRedirectsAllowed(false) + .build()) + .setDefaultSocketConfig( + SocketConfig.custom().setSoKeepAlive(true).build()) + .evictIdleConnections(55, TimeUnit.SECONDS) + .setMaxConnPerRoute(1000) + .setMaxConnTotal(Integer.MAX_VALUE) + .setRoutePlanner(new SystemDefaultRoutePlanner(null, conf.proxy)) + .disableAutomaticRetries() + // Must be disabled otherwise connections are not reused when client certificates are provided + .disableConnectionState() + // Match okhttp behavior disabling cookies + .disableCookieManagement() + // Dialogue handles content-compression with ContentDecodingChannel + .disableContentCompression() + .setSSLSocketFactory( + new SSLConnectionSocketFactory( + conf.sslSocketFactory, + new String[] {"TLSv1.2"}, + conf.enableGcmCipherSuites + ? CipherSuites.allCipherSuites() + : CipherSuites.fastCipherSuites(), + new DefaultHostnameVerifier())) + .setDefaultCredentialsProvider(NullCredentialsProvider.INSTANCE) + .setTargetAuthenticationStrategy(NullAuthenticationStrategy.INSTANCE) + .setProxyAuthenticationStrategy(NullAuthenticationStrategy.INSTANCE) + .setDefaultAuthSchemeRegistry( + RegistryBuilder.create().build()); + + conf.proxyCredentials.ifPresent(credentials -> { + builder.setDefaultCredentialsProvider(new SingleCredentialsProvider(credentials)) + .setProxyAuthenticationStrategy(ProxyAuthenticationStrategy.INSTANCE) + .setDefaultAuthSchemeRegistry(RegistryBuilder.create() + .register(AuthSchemes.BASIC, new BasicSchemeFactory()) + .build()); + }); + + CloseableHttpClient build = builder.build(); + // resources will be closed by the 'SharedResources' class + return build; + } + + // can't use immutables because intellij complains of a cycles + static final class ConfigurationSubset { + private Duration connectTimeout; + + private Duration readTimeout; + + private Duration writeTimeout; + + private boolean enableGcmCipherSuites; + + private boolean fallbackToCommonNameVerification; + + private Optional proxyCredentials; + + private Optional meshProxy; + + private ProxySelector proxy; + + private SSLSocketFactory sslSocketFactory; + + @Override + @SuppressWarnings("CyclomaticComplexity") + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ConfigurationSubset that = (ConfigurationSubset) obj; + return enableGcmCipherSuites == that.enableGcmCipherSuites + && fallbackToCommonNameVerification == that.fallbackToCommonNameVerification + && connectTimeout.equals(that.connectTimeout) + && readTimeout.equals(that.readTimeout) + && writeTimeout.equals(that.writeTimeout) + && proxyCredentials.equals(that.proxyCredentials) + && meshProxy.equals(that.meshProxy) + && proxy.equals(that.proxy) + && sslSocketFactory.equals(that.sslSocketFactory); + } + + @Override + public int hashCode() { + return Objects.hash( + connectTimeout, + readTimeout, + writeTimeout, + enableGcmCipherSuites, + fallbackToCommonNameVerification, + proxyCredentials, + meshProxy, + proxy, + sslSocketFactory); + } + + @Override + public String toString() { + return "ConfigurationSubset{" + + "connectTimeout=" + + connectTimeout + + ", readTimeout=" + + readTimeout + + ", writeTimeout=" + + writeTimeout + + ", enableGcmCipherSuites=" + + enableGcmCipherSuites + + ", fallbackToCommonNameVerification=" + + fallbackToCommonNameVerification + + ", proxyCredentials=" + + proxyCredentials + + ", meshProxy=" + + meshProxy + + ", proxy=" + + proxy + + ", sslSocketFactory=" + + sslSocketFactory + + '}'; + } + } + + private static URL url(String uri) { + try { + return new URL(uri); + } catch (MalformedURLException e) { + throw new SafeIllegalArgumentException("Failed to parse URL", e); + } + } + + private enum NullCredentialsProvider implements CredentialsProvider { + INSTANCE; + + @Override + public void setCredentials(AuthScope _authscope, Credentials _credentials) {} + + @Override + public Credentials getCredentials(AuthScope _authscope) { + return null; + } + + @Override + public void clear() {} + } + + private static final class SingleCredentialsProvider implements CredentialsProvider { + private final Credentials credentials; + + SingleCredentialsProvider(BasicCredentials basicCredentials) { + credentials = new UsernamePasswordCredentials(basicCredentials.username(), basicCredentials.password()); + } + + @Override + public void setCredentials(AuthScope _authscope, Credentials _credentials) {} + + @Override + public Credentials getCredentials(AuthScope _authscope) { + return credentials; + } + + @Override + public void clear() {} + } + + private enum NullAuthenticationStrategy implements AuthenticationStrategy { + INSTANCE; + + @Override + public boolean isAuthenticationRequested(HttpHost _authhost, HttpResponse _response, HttpContext _context) { + return false; + } + + @Override + public Map getChallenges(HttpHost _authhost, HttpResponse _response, HttpContext _context) { + return Collections.emptyMap(); + } + + @Override + public Queue select( + Map _challenges, HttpHost _authhost, HttpResponse _response, HttpContext _context) { + return new ArrayDeque<>(1); + } + + @Override + public void authSucceeded(HttpHost _authhost, AuthScheme _authScheme, HttpContext _context) {} + + @Override + public void authFailed(HttpHost _authhost, AuthScheme _authScheme, HttpContext _context) {} + } +} diff --git a/dialogue-client-test-lib/build.gradle b/dialogue-client-test-lib/build.gradle index 3c105de13..7e0d39ace 100644 --- a/dialogue-client-test-lib/build.gradle +++ b/dialogue-client-test-lib/build.gradle @@ -9,4 +9,11 @@ dependencies { compile 'junit:junit' compile 'org.assertj:assertj-core' compile 'org.mockito:mockito-core' + + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation project(':dialogue-core') + testImplementation project(':dialogue-serde') + testRuntimeOnly 'org.slf4j:slf4j-simple' + testRuntimeOnly project(':dialogue-apache-hc4-client') } diff --git a/dialogue-client-test-lib/src/test/java/com/palantir/dialogue/core/DialogueTest.java b/dialogue-client-test-lib/src/test/java/com/palantir/dialogue/core/DialogueTest.java new file mode 100644 index 000000000..78a2a7d77 --- /dev/null +++ b/dialogue-client-test-lib/src/test/java/com/palantir/dialogue/core/DialogueTest.java @@ -0,0 +1,228 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.palantir.conjure.java.api.config.service.UserAgent; +import com.palantir.conjure.java.api.config.ssl.SslConfiguration; +import com.palantir.conjure.java.client.config.ClientConfiguration; +import com.palantir.conjure.java.client.config.ClientConfigurations; +import com.palantir.conjure.java.config.ssl.SslSocketFactories; +import com.palantir.conjure.java.dialogue.serde.DefaultConjureRuntime; +import com.palantir.dialogue.Channel; +import com.palantir.dialogue.ConjureRuntime; +import com.palantir.dialogue.ConstructUsing; +import com.palantir.dialogue.Deserializer; +import com.palantir.dialogue.Endpoint; +import com.palantir.dialogue.Factory; +import com.palantir.dialogue.HttpMethod; +import com.palantir.dialogue.Request; +import com.palantir.dialogue.Response; +import com.palantir.dialogue.TypeMarker; +import com.palantir.dialogue.UrlBuilder; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +// has to live in a separate project to avoid IntelliJ complaining about a cycle +class DialogueTest { + private static final SslConfiguration SSL_CONFIG = SslConfiguration.of( + Paths.get("../dialogue-client-test-lib/src/main/resources/trustStore.jks"), + Paths.get("../dialogue-client-test-lib/src/main/resources/keyStore.jks"), + "keystore"); + private static final UserAgent USER_AGENT = UserAgent.of(UserAgent.Agent.of("foo", "1.0.0")); + private static final ConjureRuntime RUNTIME = + DefaultConjureRuntime.builder().build(); + private static final DialogueConfig FOO = DialogueConfig.builder() + .from(createTestConfig("https://foo")) + .httpClientType(DialogueConfig.HttpClientType.APACHE) + .userAgent(USER_AGENT) + .build(); + private static final DialogueConfig NEW_URLS = DialogueConfig.builder() + .from(createTestConfig("https://baz")) + .httpClientType(DialogueConfig.HttpClientType.APACHE) + .userAgent(USER_AGENT) + .build(); + private static final TestListenableValue listenableConfig = new TestListenableValue<>(FOO); + + @Test + void live_reloads_urls() throws Exception { + try (ClientPool clients = Dialogue.newClientPool(RUNTIME)) { + + // this is how I want people to interact with dialogue! + AsyncFooService asyncFooService = clients.get(AsyncFooService.class, listenableConfig); + + assertThatThrownBy(asyncFooService.doSomething()::get) + .hasMessageContaining("java.net.UnknownHostException: foo"); + + listenableConfig.setValue(NEW_URLS); + + assertThatThrownBy(asyncFooService.doSomething()::get) + .describedAs("urls live-reloaded under the hood!") + .hasMessageContaining("java.net.UnknownHostException: baz"); + } + } + + @Test + void can_create_a_raw_channel() throws Exception { + try (ClientPool clients = Dialogue.newClientPool(RUNTIME)) { + + Channel rawChannel = clients.rawHttpChannel("https://foo", listenableConfig); + + ListenableFuture response = + rawChannel.execute(FakeEndpoint.INSTANCE, Request.builder().build()); + + assertThatThrownBy(() -> Futures.getUnchecked(response)) + .hasMessageContaining("java.net.UnknownHostException: foo"); + } + } + + @Test + void warns_when_live_reloading_is_impossible() throws Exception { + try (ClientPool clientPool = Dialogue.newClientPool(RUNTIME)) { + + Channel channel = clientPool.rawHttpChannel("https://foo", listenableConfig); + assertThat(channel).isNotNull(); + + listenableConfig.setValue(DialogueConfig.builder() + .httpClientType(DialogueConfig.HttpClientType.APACHE) + .userAgent(USER_AGENT) + .from(ClientConfiguration.builder() + .from(createTestConfig("https://foo")) + .connectTimeout(Duration.ofSeconds(1)) // produces a log.warn, but nothing assertable + .build()) + .build()); + } + } + + @Test + void dialogue_can_reflectively_instantiate_stuff() throws Exception { + Channel channel = mock(Channel.class); + when(channel.execute(any(), any())).thenReturn(Futures.immediateFuture(TestResponse.INSTANCE)); + AsyncFooService instance = ClientPoolImpl.conjure(AsyncFooService.class, channel, RUNTIME); + assertThat(instance).isInstanceOf(AsyncFooService.class); + + assertThat(instance.doSomething().get()).isEqualTo("Hello"); + } + + private static ClientConfiguration createTestConfig(String... uri) { + return ClientConfiguration.builder() + .from(ClientConfigurations.of( + ImmutableList.copyOf(uri), + SslSocketFactories.createSslSocketFactory(SSL_CONFIG), + SslSocketFactories.createX509TrustManager(SSL_CONFIG))) + .maxNumRetries(0) + .build(); + } + + /** This is an example of what I'd like conjure-java to generate. */ + @ConstructUsing(MyFactory.class) + private interface AsyncFooService { + ListenableFuture doSomething(); + + // still fine to have a little static method here, but I don't expect people to use it much + static AsyncFooService of(Channel channel, ConjureRuntime runtime) { + return MyFactory.INSTANCE.construct(channel, runtime); + } + } + + /** This is an example of what I'd like conjure-java to generate. */ + enum MyFactory implements Factory { + INSTANCE; + + @Override + public AsyncFooService construct(Channel channel, ConjureRuntime runtime) { + return new AsyncFooService() { + private final Deserializer stringDeserializer = + runtime.bodySerDe().deserializer(new TypeMarker() {}); + + @Override + public ListenableFuture doSomething() { + Request request = Request.builder().build(); + ListenableFuture call = channel.execute(FakeEndpoint.INSTANCE, request); + return Futures.transform(call, stringDeserializer::deserialize, MoreExecutors.directExecutor()); + } + }; + } + } + + private enum FakeEndpoint implements Endpoint { + INSTANCE; + + @Override + public void renderPath(Map _params, UrlBuilder url) { + url.pathSegment("/string"); + } + + @Override + public HttpMethod httpMethod() { + return HttpMethod.GET; + } + + @Override + public String serviceName() { + return "MyService"; + } + + @Override + public String endpointName() { + return "endpoint"; + } + + @Override + public String version() { + return "1.0.0"; + } + } + + private enum TestResponse implements Response { + INSTANCE; + + @Override + public InputStream body() { + return new ByteArrayInputStream("\"Hello\"".getBytes(StandardCharsets.UTF_8)); + } + + @Override + public int code() { + return 200; + } + + @Override + public Map> headers() { + return ImmutableMap.of("Content-Type", ImmutableList.of("application/json")); + } + + @Override + public void close() {} + } +} diff --git a/dialogue-core/build.gradle b/dialogue-core/build.gradle index 43c7ed07e..e8c0ecdf0 100644 --- a/dialogue-core/build.gradle +++ b/dialogue-core/build.gradle @@ -1,5 +1,6 @@ apply from: "$rootDir/gradle/publish-jar.gradle" apply plugin: 'com.palantir.metric-schema' +apply plugin: 'org.inferred.processors' dependencies { compile project(':dialogue-target') @@ -11,18 +12,20 @@ dependencies { compile 'com.palantir.tracing:tracing' compile 'io.dropwizard.metrics:metrics-core' - testImplementation 'com.palantir.tracing:tracing-test-utils' + testImplementation 'com.palantir.conjure.java.runtime:keystores' testImplementation 'com.palantir.safe-logging:preconditions-assertj' - testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'com.palantir.tracing:tracing-test-utils' testImplementation 'org.assertj:assertj-core' testImplementation 'org.assertj:assertj-guava' + testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.mockito:mockito-core' testImplementation 'org.mockito:mockito-junit-jupiter' testRuntimeOnly 'org.slf4j:slf4j-simple' annotationProcessor 'org.immutables:value' - testAnnotationProcessor 'org.immutables:value' compile 'org.immutables:value::annotations' + + testAnnotationProcessor 'org.immutables:value' } configurations.testCompileClasspath.exclude module: 'junit' // prefer junit5 diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/ClientPool.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/ClientPool.java new file mode 100644 index 000000000..270127546 --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/ClientPool.java @@ -0,0 +1,51 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.palantir.dialogue.Channel; +import java.io.Closeable; +import java.io.IOException; + +/** + * Facilitates creating many clients which all share the same connection pool. Should only create one of these per + * server. Close it when your server shuts down to release resources. + */ +public interface ClientPool extends Closeable { + + /** Returns an implementation of the given dialogueInterface, hooked up to a fresh smart channel underneath. */ + T get(Class dialogueInterface, Listenable config); + + /** + * Returns a channel for interacting with the given abstract upstream service, which routes traffic + * appropriately to the various available nodes. Live-reloaded every time the urls change. + */ + Channel smartChannel(Listenable config); + + /** + * Gets a direct channel to a single host within the specified Config. Live-reloads under the hood. The channel + * will always fail if the specified uri is not listed in the latest version of the config. Somewhat dangerous + * because this has no limits / failover. + */ + Channel rawHttpChannel(String uri, Listenable config); + + /** + * Releases all underlying resources (e.g. connection pools). All previously returned clients will become + * non-functional after calling this. Call this at server shutdown time. + */ + @Override + void close() throws IOException; +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/ClientPoolImpl.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/ClientPoolImpl.java new file mode 100644 index 000000000..3362f2da9 --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/ClientPoolImpl.java @@ -0,0 +1,115 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.google.common.annotations.VisibleForTesting; +import com.palantir.dialogue.Channel; +import com.palantir.dialogue.ConjureRuntime; +import com.palantir.dialogue.ConstructUsing; +import com.palantir.dialogue.Factory; +import com.palantir.logsafe.Preconditions; +import com.palantir.logsafe.SafeArg; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class ClientPoolImpl implements ClientPool { + private static final Logger log = LoggerFactory.getLogger(ClientPoolImpl.class); + private static AtomicInteger instances = new AtomicInteger(0); + + private final SharedResources sharedResources = new SharedResourcesImpl(); + private final ConjureRuntime runtime; + + @SuppressWarnings("Slf4jLogsafeArgs") + ClientPoolImpl(ConjureRuntime runtime) { + this.runtime = runtime; + + int instanceNumber = instances.incrementAndGet(); + if (instanceNumber > 1) { + log.warn("Constructing ClientPool number {}, try to re-use the existing instance", instanceNumber); + } + } + + @Override + public T get(Class dialogueInterface, Listenable config) { + Channel channel = smartChannel(config); + // Note: if you call this many times, you'll get entirely independent 'smart channel' instances, which means + // they each have their own idea about blacklisting / concurrency limiting etc. + return conjure(dialogueInterface, channel, runtime); + } + + @Override + public Channel smartChannel(Listenable config) { + // This is a naive approach to live reloading, as it throws away all kinds of useful state (e.g. active request + // count, blacklisting info etc). + return RefreshingChannelFactory.RefreshingChannel.create(config::getListenableCurrentValue, conf -> { + List channels = conf.uris().stream() + .map(uri -> { + // important that this re-uses resources under the hood, as it gets called often! + return rawHttpChannel(uri, config); + }) + .collect(Collectors.toList()); + + return Channels.create(channels, conf.userAgent, conf.legacyClientConfiguration); + }); + } + + @Override + public Channel rawHttpChannel(String uri, Listenable config) { + Class factoryClazz = config.getListenableCurrentValue().httpChannelFactory; + + config.subscribe(() -> { + if (config.getListenableCurrentValue().httpChannelFactory != factoryClazz) { + log.warn("Live-reloading the *type* of underlying http channel is not currently supported"); + } + }); + + HttpChannelFactory channelFactory = getOnlyEnumConstant(factoryClazz); + + // by passing in sharedResources, we're enable to avoid re-creating the expensive underlying clients + return channelFactory.construct(uri, config, sharedResources); + } + + /** Just give me a working implementation of the provided conjure-generated interface. */ + @VisibleForTesting + static T conjure(Class dialogueInterface, Channel smartChannel, ConjureRuntime runtime) { + ConstructUsing annotation = dialogueInterface.getDeclaredAnnotation(ConstructUsing.class); + Preconditions.checkNotNull( + annotation, + "@ConstructUsing annotation must be present on interface", + SafeArg.of("interface", dialogueInterface.getName())); + + Class factoryClass = annotation.value(); + // this is safe because of the ConstructUsing annotation's type parameters + Factory factory = (Factory) getOnlyEnumConstant(factoryClass); + return factory.construct(smartChannel, runtime); + } + + private static F getOnlyEnumConstant(Class factoryClass) { + Preconditions.checkState(factoryClass.isEnum(), "Factory must be an enum"); + Preconditions.checkState(factoryClass.getEnumConstants().length == 1, "Enum must have 1 value"); + return factoryClass.getEnumConstants()[0]; + } + + @Override + public void close() throws IOException { + sharedResources.close(); + } +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/Dialogue.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/Dialogue.java new file mode 100644 index 000000000..ffb7143fd --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/Dialogue.java @@ -0,0 +1,35 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.google.errorprone.annotations.MustBeClosed; +import com.palantir.dialogue.ConjureRuntime; + +public final class Dialogue { + + /** + * Facilitates creating many clients which all share the same connection pool and smart logic (including + * concurrency limiters / blacklisting info etc). Should only create one of these per server. Close it when your + * server shuts down to release resources. + */ + @MustBeClosed + public static ClientPool newClientPool(ConjureRuntime runtime) { + return new ClientPoolImpl(runtime); + } + + private Dialogue() {} +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/DialogueConfig.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/DialogueConfig.java new file mode 100644 index 000000000..6a6cda541 --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/DialogueConfig.java @@ -0,0 +1,107 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.palantir.conjure.java.api.config.service.UserAgent; +import com.palantir.conjure.java.client.config.ClientConfiguration; +import com.palantir.logsafe.Preconditions; +import com.palantir.logsafe.SafeArg; +import com.palantir.logsafe.exceptions.SafeRuntimeException; +import java.util.List; + +/** + * Configuration specifying everything necessary to talk to an abstract upstream 'service', with a number of + * possible uris. Intended to give us the flexibility to not require the legacy conjure-java-runtime jars at some + * point in the future. + * + * All getters package private initially. + */ +@SuppressWarnings("VisibilityModifier") +public final class DialogueConfig { + + // TODO(dfox): switch to getters when we can be bothered + public final ClientConfiguration legacyClientConfiguration; + final Class httpChannelFactory; + final UserAgent userAgent; + + private DialogueConfig(Builder builder) { + this.legacyClientConfiguration = + Preconditions.checkNotNull(builder.legacyClientConfiguration, "legacyClientConfiguration"); + this.httpChannelFactory = Preconditions.checkNotNull(builder.httpClientType, "httpClientType"); + this.userAgent = Preconditions.checkNotNull(builder.userAgent, "userAgent"); + } + + List uris() { + return legacyClientConfiguration.uris(); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private ClientConfiguration legacyClientConfiguration; + private Class httpClientType; + private UserAgent userAgent; + + /** this method exists to provide a seamless transition from conjure-java-runtime. */ + public Builder from(ClientConfiguration cjrClientConfig) { + this.legacyClientConfiguration = cjrClientConfig; + return this; + } + + public Builder httpClientType(HttpClientType rawType) { + this.httpClientType = rawType.getFactoryClass(); + return this; + } + + public Builder userAgent(UserAgent value) { + this.userAgent = value; + return this; + } + + public DialogueConfig build() { + return new DialogueConfig(this); + } + } + + public enum HttpClientType { + APACHE("com.palantir.dialogue.hc4.DialogueApacheHttpClient"), + OKHTTP("TODO"), + HTTP_URL_CONNECTION("TODO"), + JAVA9_HTTPCLIENT("TODO"); + + private final String className; + + HttpClientType(String className) { + this.className = className; + } + + Class getFactoryClass() { + try { + return (Class) Class.forName(className); + } catch (ClassNotFoundException e) { + throw new SafeRuntimeException( + "Unable to find HttpClientType, are you missing a dependency?", + e, + SafeArg.of("type", this.name()), + SafeArg.of("class", className)); + } + } + } +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/HttpChannelFactory.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/HttpChannelFactory.java new file mode 100644 index 000000000..7d8cb6a7a --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/HttpChannelFactory.java @@ -0,0 +1,25 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.palantir.dialogue.Channel; + +/** Used with {@link com.palantir.dialogue.ConstructUsing} to allow reflective construction. */ +public interface HttpChannelFactory { + + Channel construct(String uri, Listenable config, SharedResources sharedResources); +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/Listenable.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/Listenable.java new file mode 100644 index 000000000..e5bdbce8f --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/Listenable.java @@ -0,0 +1,32 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import java.io.Closeable; + +public interface Listenable { + + // long method name just to make it obvious when something is not live reloading + T getListenableCurrentValue(); + + Subscription subscribe(Runnable _updateListener); + + interface Subscription extends Closeable { + @Override + void close(); + } +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/SharedResources.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/SharedResources.java new file mode 100644 index 000000000..1d7b47ac1 --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/SharedResources.java @@ -0,0 +1,30 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import java.io.Closeable; +import java.util.function.Function; + +/** A mutable central store where we can create expensive things once. Inspired by JUnit5's ExtensionContext.Store. */ +public interface SharedResources extends Closeable { + + Store getStore(String namespace); + + interface Store { + V getOrComputeIfAbsent(K key, Function defaultCreator, Class requiredType); + } +} diff --git a/dialogue-core/src/main/java/com/palantir/dialogue/core/SharedResourcesImpl.java b/dialogue-core/src/main/java/com/palantir/dialogue/core/SharedResourcesImpl.java new file mode 100644 index 000000000..a0c9261d0 --- /dev/null +++ b/dialogue-core/src/main/java/com/palantir/dialogue/core/SharedResourcesImpl.java @@ -0,0 +1,80 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.palantir.logsafe.SafeArg; +import com.palantir.logsafe.UnsafeArg; +import java.io.Closeable; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import javax.annotation.concurrent.ThreadSafe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ThreadSafe +final class SharedResourcesImpl implements SharedResources { + private static final Logger log = LoggerFactory.getLogger(SharedResourcesImpl.class); + private final LoadingCache stores = + Caffeine.newBuilder().maximumSize(100).build(namespace -> new StoreImpl()); + + @Override + public Store getStore(String namespace) { + return stores.get(namespace); + } + + @Override + public void close() { + Map map = stores.asMap(); + map.forEach((namespace, store) -> { + try { + log.info("Closing store for namespace {}", SafeArg.of("namespace", namespace)); + store.close(); + } catch (RuntimeException e) { + log.info("Failed to close store, resources may be leaked", SafeArg.of("namespace", namespace), e); + } + }); + } + + @ThreadSafe + private static final class StoreImpl implements Store, Closeable { + private final ConcurrentHashMap items = new ConcurrentHashMap<>(); + + @Override + public V getOrComputeIfAbsent( + K key, Function defaultCreator, Class requiredType) { + Closeable object = items.compute(key, (k, existing) -> { + return existing != null ? existing : defaultCreator.apply((K) k); + }); + return requiredType.cast(object); + } + + @Override + public void close() { + for (Closeable value : items.values()) { + try { + value.close(); + } catch (IOException e) { + log.warn("Failed to close value", UnsafeArg.of("value", value), e); + } + } + } + } +} diff --git a/dialogue-core/src/test/java/com/palantir/dialogue/core/TestListenableValue.java b/dialogue-core/src/test/java/com/palantir/dialogue/core/TestListenableValue.java new file mode 100644 index 000000000..d61e774c5 --- /dev/null +++ b/dialogue-core/src/test/java/com/palantir/dialogue/core/TestListenableValue.java @@ -0,0 +1,80 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue.core; + +import com.google.common.collect.ImmutableList; +import com.palantir.logsafe.Preconditions; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// this is a test only implementation, I'd prefer to pass in Witchcraft's Refreshable +final class TestListenableValue implements Listenable { + private static final Logger log = LoggerFactory.getLogger(TestListenableValue.class); + + private final AtomicReference> listeners = new AtomicReference<>(ImmutableList.of()); + private volatile T value; + + TestListenableValue(T value) { + this.value = Preconditions.checkNotNull(value, "initial value"); + } + + @Override + public T getListenableCurrentValue() { + return value; + } + + void setValue(T newValue) { + this.value = newValue; + for (Runnable runnable : listeners.get()) { + try { + runnable.run(); + } catch (RuntimeException e) { + log.error("Failed to notify subscriber", e); + } + } + } + + @Override + public Subscription subscribe(Runnable updateListener) { + for (ImmutableList items = listeners.get(); + !listeners.compareAndSet(items, add(items, updateListener)); + items = listeners.get()) { + /* empty */ + } + + return new Subscription() { + @Override + public void close() { + for (ImmutableList items = listeners.get(); + !listeners.compareAndSet(items, remove(items, updateListener)); + items = listeners.get()) { + /* empty */ + } + } + }; + } + + private static ImmutableList add(List existing, T updateListener) { + return ImmutableList.builder().addAll(existing).add(updateListener).build(); + } + + private static ImmutableList remove(List existing, T updateListener) { + return existing.stream().filter(item -> item != updateListener).collect(ImmutableList.toImmutableList()); + } +} diff --git a/dialogue-target/build.gradle b/dialogue-target/build.gradle index 35465ecf6..dba1fc678 100644 --- a/dialogue-target/build.gradle +++ b/dialogue-target/build.gradle @@ -1,4 +1,5 @@ apply from: "$rootDir/gradle/publish-jar.gradle" +apply plugin: 'org.inferred.processors' dependencies { compile 'com.palantir.conjure.java:conjure-lib' diff --git a/dialogue-target/src/main/java/com/palantir/dialogue/ConstructUsing.java b/dialogue-target/src/main/java/com/palantir/dialogue/ConstructUsing.java new file mode 100644 index 000000000..4518c40e8 --- /dev/null +++ b/dialogue-target/src/main/java/com/palantir/dialogue/ConstructUsing.java @@ -0,0 +1,33 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ConstructUsing { + + /** + * A factory class with a zero-arg constructor that can be used to construct an instance of this interface using + * reflection. + */ + Class> value(); +} diff --git a/dialogue-target/src/main/java/com/palantir/dialogue/Factory.java b/dialogue-target/src/main/java/com/palantir/dialogue/Factory.java new file mode 100644 index 000000000..7f511bfa6 --- /dev/null +++ b/dialogue-target/src/main/java/com/palantir/dialogue/Factory.java @@ -0,0 +1,26 @@ +/* + * (c) Copyright 2020 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.dialogue; + +/** + * Used with {@link com.palantir.dialogue.ConstructUsing} to allow reflective construction of + * conjure-generated dialogue interfaces. + */ +public interface Factory { + + T construct(Channel channel, ConjureRuntime runtime); +} diff --git a/simulation/build.gradle b/simulation/build.gradle index 9a5589350..4bb6bfb3e 100644 --- a/simulation/build.gradle +++ b/simulation/build.gradle @@ -1,5 +1,7 @@ +apply plugin: 'org.inferred.processors' + versionsLock { - testProject() + testProject() } dependencies {