Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 90 additions & 12 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URI;
import java.time.Duration;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
Expand Down Expand Up @@ -144,7 +143,7 @@ public class Client implements AutoCloseable {

private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,
ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy,
Object metricsRegistry, Supplier<String> queryIdGenerator) {
Object metricsRegistry, Supplier<String> queryIdGenerator, HostResolver hostResolver) {
this.configuration = ClientConfigProperties.parseConfigMap(configuration);
this.readOnlyConfig = Collections.unmodifiableMap(configuration);
this.metricsRegistry = metricsRegistry;
Expand Down Expand Up @@ -191,7 +190,7 @@ private Client(Collection<Endpoint> endpoints, Map<String,String> configuration,
this.lz4Factory = LZ4Factory.fastestJavaInstance();
}

this.httpClientHelper = new HttpAPIClientHelper(this.configuration, metricsRegistry, initSslContext, lz4Factory);
this.httpClientHelper = new HttpAPIClientHelper(this.configuration, metricsRegistry, initSslContext, lz4Factory, hostResolver);
this.serverVersion = configuration.getOrDefault(ClientConfigProperties.SERVER_VERSION.getKey(), "unknown");
this.dbUser = configuration.getOrDefault(ClientConfigProperties.USER.getKey(), ClientConfigProperties.USER.getDefObjVal());
this.typeHintMapping = (Map<ClickHouseDataType, Class<?>>) this.configuration.get(ClientConfigProperties.TYPE_HINT_MAPPING.getKey());
Expand Down Expand Up @@ -264,6 +263,7 @@ public static class Builder {
private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy;
private Object metricRegistry = null;
private Supplier<String> queryIdGenerator;
private HostResolver hostResolver;

public Builder() {
this.endpoints = new HashSet<>();
Expand All @@ -277,6 +277,7 @@ public Builder() {

allowBinaryReaderToReuseBuffers(false);
columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE;
hostResolver = HostResolver.DEFAULT;
}

/**
Expand All @@ -293,32 +294,37 @@ public Builder() {
*/
public Builder addEndpoint(String endpoint) {
try {
URL endpointURL = new URL(endpoint);

String protocolStr = endpointURL.getProtocol();
URI endpointUri = URI.create(endpoint);
String protocolStr = endpointUri.getScheme();
if (protocolStr == null) {
throw new IllegalArgumentException("Protocol should be set in endpoint");
}
if (!protocolStr.equalsIgnoreCase("https") &&
!protocolStr.equalsIgnoreCase("http")) {
throw new IllegalArgumentException("Only HTTP and HTTPS protocols are supported");
}

boolean secure = protocolStr.equalsIgnoreCase("https");
String host = endpointURL.getHost();
ParsedAuthority authority = parseAuthority(endpointUri.getRawAuthority(), endpoint);
String host = authority.host;
if (host == null || host.isEmpty()) {
throw new IllegalArgumentException("Host cannot be empty in endpoint: " + endpoint);
}

int port = endpointURL.getPort();
int port = authority.port;
if (port <= 0) {
throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified");
}

String path = endpointURL.getPath();
String path = endpointUri.getPath();
if (path == null || path.isEmpty()) {
path = "/";
}

return addEndpoint(Protocol.HTTP, host, port, secure, path);
} catch (MalformedURLException e) {
} catch (ValidationUtils.SettingsValidationException e) {
throw e;
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Endpoint should be a valid URL string, but was " + endpoint, e);
}
}
Expand Down Expand Up @@ -351,6 +357,78 @@ public Builder addEndpoint(Protocol protocol, String host, int port, boolean sec

}

/**
* Sets custom host resolver to resolve endpoint hostnames.
* This is useful in custom DNS environments such as Kubernetes or service mesh deployments.
* By default, {@link java.net.InetAddress#getByName(String)} is used.
*
* @param hostResolver resolver implementation
* @return this builder instance
*/
public Builder setHostResolver(HostResolver hostResolver) {
ValidationUtils.checkNotNull(hostResolver, "hostResolver");
this.hostResolver = hostResolver;
return this;
}

private static ParsedAuthority parseAuthority(String rawAuthority, String endpoint) {
if (rawAuthority == null || rawAuthority.trim().isEmpty()) {
throw new IllegalArgumentException("Host cannot be empty in endpoint: " + endpoint);
}

String authority = rawAuthority;
int userInfoSeparator = authority.lastIndexOf('@');
if (userInfoSeparator >= 0) {
authority = authority.substring(userInfoSeparator + 1);
}

if (authority.startsWith("[")) {
int ipv6End = authority.indexOf(']');
if (ipv6End < 0) {
throw new IllegalArgumentException("Invalid endpoint authority: " + rawAuthority);
}

String host = authority.substring(0, ipv6End + 1);
if (ipv6End + 1 >= authority.length() || authority.charAt(ipv6End + 1) != ':') {
throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified");
}

String portPart = authority.substring(ipv6End + 2);
return new ParsedAuthority(host, parsePort(portPart));
}

int portSeparator = authority.lastIndexOf(':');
if (portSeparator <= 0 || portSeparator == authority.length() - 1) {
throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified");
}

if (authority.indexOf(':') != portSeparator) {
throw new IllegalArgumentException("Invalid endpoint authority: " + rawAuthority);
}

String host = authority.substring(0, portSeparator);
String portPart = authority.substring(portSeparator + 1);
return new ParsedAuthority(host, parsePort(portPart));
}

private static int parsePort(String portPart) {
try {
return Integer.parseInt(portPart);
} catch (NumberFormatException e) {
throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified");
}
}

private static final class ParsedAuthority {
private final String host;
private final int port;

private ParsedAuthority(String host, int port) {
this.host = host;
this.port = port;
}
}


/**
* Sets a configuration option. This method can be used to set any configuration option.
Expand Down Expand Up @@ -1152,7 +1230,7 @@ public Client build() {
}

return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor,
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator);
this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, this.hostResolver);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.clickhouse.client.api;

import java.net.InetAddress;
import java.net.UnknownHostException;

@FunctionalInterface
public interface HostResolver {
HostResolver DEFAULT = InetAddress::getByName;

InetAddress resolve(String host) throws UnknownHostException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
import com.clickhouse.client.api.ClientMisconfigurationException;
import com.clickhouse.client.api.ConnectionInitiationException;
import com.clickhouse.client.api.ConnectionReuseStrategy;
import com.clickhouse.client.api.HostResolver;
import com.clickhouse.client.api.DataTransferException;
import com.clickhouse.client.api.ServerException;
import com.clickhouse.client.api.enums.ProxyType;
import com.clickhouse.client.api.http.ClickHouseHttpProto;
import com.clickhouse.client.api.transport.Endpoint;
import org.apache.hc.client5.http.DnsResolver;
import com.clickhouse.data.ClickHouseFormat;
import net.jpountz.lz4.LZ4Factory;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
Expand All @@ -22,7 +24,6 @@
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.entity.mime.MultipartPartBuilder;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
Expand All @@ -32,10 +33,9 @@
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.io.ManagedHttpClientConnection;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
import org.apache.hc.client5.http.socket.LayeredConnectionSocketFactory;
import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.TlsSocketStrategy;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ConnectionRequestTimeoutException;
import org.apache.hc.core5.http.ContentType;
Expand All @@ -46,8 +46,10 @@
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.NoHttpResponseException;
import org.apache.hc.core5.http.URIScheme;
import org.apache.hc.core5.http.config.CharCodingConfig;
import org.apache.hc.core5.http.config.Http1Config;
import org.apache.hc.core5.http.config.Lookup;
import org.apache.hc.core5.http.config.RegistryBuilder;
import org.apache.hc.core5.http.impl.io.DefaultHttpResponseParserFactory;
import org.apache.hc.core5.http.io.SocketConfig;
Expand Down Expand Up @@ -76,6 +78,7 @@
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NoRouteToHostException;
import java.net.Socket;
Expand All @@ -93,7 +96,6 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Arrays;
import java.util.HashMap;
Expand Down Expand Up @@ -130,9 +132,11 @@ public class HttpAPIClientHelper {
ConnPoolControl<?> poolControl;

LZ4Factory lz4Factory;
private final HostResolver hostResolver;

public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) {
public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory, HostResolver hostResolver) {
this.metricsRegistry = metricsRegistry;
this.hostResolver = Objects.requireNonNull(hostResolver, "hostResolver");
this.httpClient = createHttpClient(initSslContext, configuration);
this.lz4Factory = lz4Factory;
assert this.lz4Factory != null;
Expand Down Expand Up @@ -205,11 +209,13 @@ private ConnectionConfig createConnectionConfig(Map<String, Object> configuratio
}

private HttpClientConnectionManager basicConnectionManager(LayeredConnectionSocketFactory sslConnectionSocketFactory, SocketConfig socketConfig, Map<String, Object> configuration) {
RegistryBuilder<ConnectionSocketFactory> registryBuilder = RegistryBuilder.create();
registryBuilder.register("http", PlainConnectionSocketFactory.getSocketFactory());
registryBuilder.register("https", sslConnectionSocketFactory);
Lookup<TlsSocketStrategy> tlsSocketStrategyLookup = RegistryBuilder.<TlsSocketStrategy>create()
.register(URIScheme.HTTPS.id, (socket, target, port, attachment, context) ->
(SSLSocket) sslConnectionSocketFactory.createLayeredSocket(socket, target, port, context))
.build();

BasicHttpClientConnectionManager connManager = new BasicHttpClientConnectionManager(registryBuilder.build());
BasicHttpClientConnectionManager connManager = BasicHttpClientConnectionManager.create(
null, createDnsResolverAdapter(), tlsSocketStrategyLookup, null);
connManager.setConnectionConfig(createConnectionConfig(configuration));
connManager.setSocketConfig(socketConfig);

Expand Down Expand Up @@ -248,6 +254,7 @@ private HttpClientConnectionManager poolConnectionManager(LayeredConnectionSocke
connMgrBuilder.setConnectionFactory(connectionFactory);
connMgrBuilder.setSSLSocketFactory(sslConnectionSocketFactory);
connMgrBuilder.setDefaultSocketConfig(socketConfig);
connMgrBuilder.setDnsResolver(createDnsResolverAdapter());
PoolingHttpClientConnectionManager phccm = connMgrBuilder.build();
poolControl = phccm;
if (metricsRegistry != null) {
Expand All @@ -266,6 +273,20 @@ private HttpClientConnectionManager poolConnectionManager(LayeredConnectionSocke
return phccm;
}

private DnsResolver createDnsResolverAdapter() {
return new DnsResolver() {
@Override
public InetAddress[] resolve(String host) throws UnknownHostException {
return new InetAddress[]{hostResolver.resolve(host)};
}

@Override
public String resolveCanonicalHostname(String host) throws UnknownHostException {
return hostResolver.resolve(host).getCanonicalHostName();
}
};
}

public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String, Object> configuration) {
// Top Level builders
HttpClientBuilder clientBuilder = HttpClientBuilder.create();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.clickhouse.client.api.ClientMisconfigurationException;

import java.net.URI;
import java.net.URL;

public class HttpEndpoint implements Endpoint {

Expand Down Expand Up @@ -33,7 +32,9 @@ public HttpEndpoint(String host, int port, boolean secure, String path){
// Use URI constructor to properly handle encoding of path segments
// Encode path segments separately to preserve slashes
try {
this.uri = new URI(secure ? "https" : "http", null, host, port, this.path, null, null);
String scheme = secure ? "https" : "http";
String encodedPath = new URI(null, null, this.path, null).getRawPath();
this.uri = new URI(scheme + "://" + host + ":" + port + encodedPath);
} catch (Exception e) {
throw new ClientMisconfigurationException("Failed to create endpoint URL", e);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.clickhouse.client.api;

import org.testng.Assert;
import org.testng.annotations.Test;

import java.lang.reflect.Field;
import java.util.List;

public class ClientBuilderTest {

@Test
public void testAddEndpointToleratesUnderscoreHostname() throws Exception {
try (Client client = new Client.Builder()
.setHostResolver(HostResolver.DEFAULT)
.addEndpoint("http://host_with_underscore:8123")
.setUsername("default")
.setPassword("")
.build()) {

String firstEndpoint = extractFirstEndpointUri(client);
Assert.assertEquals(firstEndpoint, "http://host_with_underscore:8123/",
"Endpoint URI should preserve original hostname");
}
}

@Test
public void testSetHostResolverRejectsNull() {
Assert.assertThrows(IllegalArgumentException.class,
() -> new Client.Builder().setHostResolver(null));
}

private static String extractFirstEndpointUri(Client client) throws Exception {
Field endpointsField = Client.class.getDeclaredField("endpoints");
endpointsField.setAccessible(true);

@SuppressWarnings("unchecked")
List<com.clickhouse.client.api.transport.Endpoint> endpoints =
(List<com.clickhouse.client.api.transport.Endpoint>) endpointsField.get(client);
return endpoints.get(0).getURI().toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,11 @@ public void testUtf8CharactersInPath() {
Assert.assertTrue(cyrillicEndpoint.getURI().toASCIIString().contains("%"),
"Cyrillic path should be percent-encoded in ASCII representation");
}

@Test
public void testUnderscoreHostIsAcceptedInUri() {
HttpEndpoint endpoint = new HttpEndpoint("host_with_underscore", 8123, false, "/");
Assert.assertEquals(endpoint.getHost(), "host_with_underscore", "Original host should be preserved");
Assert.assertEquals(endpoint.getURI().toString(), "http://host_with_underscore:8123/");
}
}
Loading