From c4b4425ab72fdb9f50096770fbb359d33807206c Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 25 Mar 2026 16:28:39 -0700 Subject: [PATCH 1/4] Implemented proper handling of hostnames with . Added DNS resolver --- .../com/clickhouse/client/api/Client.java | 102 +++++++++++++++--- .../clickhouse/client/api/HostResolver.java | 11 ++ .../api/internal/HttpAPIClientHelper.java | 39 +++++-- .../client/api/transport/HttpEndpoint.java | 5 +- .../client/api/ClientBuilderTest.java | 41 +++++++ .../api/transport/HttpEndpointTest.java | 7 ++ 6 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java create mode 100644 client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 73ec21155..fb3a9ce1d 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -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; @@ -144,7 +143,7 @@ public class Client implements AutoCloseable { private Client(Collection endpoints, Map configuration, ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy, - Object metricsRegistry, Supplier queryIdGenerator) { + Object metricsRegistry, Supplier queryIdGenerator, HostResolver hostResolver) { this.configuration = ClientConfigProperties.parseConfigMap(configuration); this.readOnlyConfig = Collections.unmodifiableMap(configuration); this.metricsRegistry = metricsRegistry; @@ -191,7 +190,7 @@ private Client(Collection endpoints, Map 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>) this.configuration.get(ClientConfigProperties.TYPE_HINT_MAPPING.getKey()); @@ -264,6 +263,7 @@ public static class Builder { private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy; private Object metricRegistry = null; private Supplier queryIdGenerator; + private HostResolver hostResolver; public Builder() { this.endpoints = new HashSet<>(); @@ -277,6 +277,7 @@ public Builder() { allowBinaryReaderToReuseBuffers(false); columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE; + hostResolver = HostResolver.DEFAULT; } /** @@ -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); } } @@ -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. @@ -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); } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java b/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java new file mode 100644 index 000000000..48afd8c0b --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java @@ -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; +} diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index 76e2dec93..b5068209e 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -130,9 +132,11 @@ public class HttpAPIClientHelper { ConnPoolControl poolControl; LZ4Factory lz4Factory; + private final HostResolver hostResolver; - public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) { + public HttpAPIClientHelper(Map 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; @@ -205,11 +209,13 @@ private ConnectionConfig createConnectionConfig(Map configuratio } private HttpClientConnectionManager basicConnectionManager(LayeredConnectionSocketFactory sslConnectionSocketFactory, SocketConfig socketConfig, Map configuration) { - RegistryBuilder registryBuilder = RegistryBuilder.create(); - registryBuilder.register("http", PlainConnectionSocketFactory.getSocketFactory()); - registryBuilder.register("https", sslConnectionSocketFactory); + Lookup tlsSocketStrategyLookup = RegistryBuilder.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); @@ -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) { @@ -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 configuration) { // Top Level builders HttpClientBuilder clientBuilder = HttpClientBuilder.create(); diff --git a/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java b/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java index 839b167d6..2e945a55b 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java @@ -3,7 +3,6 @@ import com.clickhouse.client.api.ClientMisconfigurationException; import java.net.URI; -import java.net.URL; public class HttpEndpoint implements Endpoint { @@ -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); } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java new file mode 100644 index 000000000..08c72bfcc --- /dev/null +++ b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java @@ -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 endpoints = + (List) endpointsField.get(client); + return endpoints.get(0).getURI().toString(); + } +} diff --git a/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java b/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java index 8870cae00..733fc2ebe 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java @@ -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/"); + } } From 6e24aef95c7cb847c4fedc6099d84a30ef745772 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 2 Apr 2026 15:59:28 -0700 Subject: [PATCH 2/4] Compacted endpoint code --- .../com/clickhouse/client/api/Client.java | 96 +-------------- .../client/api/transport/HttpEndpoint.java | 112 ++++++++++++++---- .../api/transport/HttpEndpointTest.java | 14 +++ 3 files changed, 111 insertions(+), 111 deletions(-) diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index fb3a9ce1d..391106082 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -53,7 +53,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.InvocationTargetException; -import java.net.URI; import java.time.Duration; import java.time.ZoneId; import java.time.temporal.ChronoUnit; @@ -294,34 +293,7 @@ public Builder() { */ public Builder addEndpoint(String endpoint) { try { - 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"); - 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 = authority.port; - if (port <= 0) { - throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified"); - } - - String path = endpointUri.getPath(); - if (path == null || path.isEmpty()) { - path = "/"; - } - - return addEndpoint(Protocol.HTTP, host, port, secure, path); + return addEndpoint(new HttpEndpoint(endpoint)); } catch (ValidationUtils.SettingsValidationException e) { throw e; } catch (IllegalArgumentException e) { @@ -342,19 +314,14 @@ public Builder addEndpoint(Protocol protocol, String host, int port, boolean sec } public Builder addEndpoint(Protocol protocol, String host, int port, boolean secure, String basePath) { - ValidationUtils.checkNonBlank(host, "host"); ValidationUtils.checkNotNull(protocol, "protocol"); - ValidationUtils.checkRange(port, 1, ValidationUtils.TCP_PORT_NUMBER_MAX, "port"); ValidationUtils.checkNotNull(basePath, "basePath"); if (protocol == Protocol.HTTP) { - HttpEndpoint httpEndpoint = new HttpEndpoint(host, port, secure, basePath); - this.endpoints.add(httpEndpoint); + return addEndpoint(new HttpEndpoint(host, port, secure, basePath)); } else { throw new IllegalArgumentException("Unsupported protocol: " + protocol); } - return this; - } /** @@ -371,62 +338,9 @@ public Builder setHostResolver(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; - } + private Builder addEndpoint(Endpoint endpoint) { + this.endpoints.add(endpoint); + return this; } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java b/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java index 2e945a55b..b71226d04 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/transport/HttpEndpoint.java @@ -1,8 +1,11 @@ package com.clickhouse.client.api.transport; import com.clickhouse.client.api.ClientMisconfigurationException; +import com.clickhouse.client.api.internal.ValidationUtils; import java.net.URI; +import java.net.MalformedURLException; +import java.net.URL; public class HttpEndpoint implements Endpoint { @@ -18,26 +21,28 @@ public class HttpEndpoint implements Endpoint { private final String path; - public HttpEndpoint(String host, int port, boolean secure, String path){ - this.host = host; - this.port = port; - this.secure = secure; - if (path != null && !path.isEmpty()) { - // Ensure basePath starts with / - this.path = path.startsWith("/") ? path : "/" + path; - } else { - this.path = "/"; - } - - // Use URI constructor to properly handle encoding of path segments - // Encode path segments separately to preserve slashes - try { - 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); - } + public HttpEndpoint(String endpoint) { + this(parseEndpointUrl(endpoint)); + } + + public HttpEndpoint(String host, int port, boolean secure, String path) { + this(new EndpointDetails(validateHost(host), validatePort(port), secure, normalizePath(path))); + } + + private HttpEndpoint(URL endpointUrl) { + this(new EndpointDetails( + validateHost(endpointUrl.getHost()), + validatePort(endpointUrl.getPort()), + isSecure(endpointUrl.getProtocol()), + decodePath(endpointUrl.getPath()))); + } + + private HttpEndpoint(EndpointDetails endpointDetails) { + this.host = endpointDetails.host; + this.port = endpointDetails.port; + this.secure = endpointDetails.secure; + this.path = endpointDetails.path; + this.uri = createUri(endpointDetails.host, endpointDetails.port, endpointDetails.secure, endpointDetails.path); this.info = uri.toString(); } @@ -78,4 +83,71 @@ public boolean equals(Object obj) { public int hashCode() { return uri.hashCode(); } + + private static URL parseEndpointUrl(String endpoint) { + try { + return new URL(endpoint); + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Failed to parse endpoint URL", e); + } + } + + private static String validateHost(String host) { + ValidationUtils.checkNonBlank(host, "host"); + return host; + } + + private static int validatePort(int port) { + if (port <= 0) { + throw new ValidationUtils.SettingsValidationException("port", "Valid port must be specified"); + } + ValidationUtils.checkRange(port, 1, ValidationUtils.TCP_PORT_NUMBER_MAX, "port"); + return port; + } + + private static boolean isSecure(String protocol) { + if ("https".equalsIgnoreCase(protocol)) { + return true; + } + if ("http".equalsIgnoreCase(protocol)) { + return false; + } + throw new IllegalArgumentException("Only HTTP and HTTPS protocols are supported"); + } + + private static String normalizePath(String path) { + if (path != null && !path.isEmpty()) { + return path.startsWith("/") ? path : "/" + path; + } + return "/"; + } + + private static String decodePath(String path) { + String normalizedPath = normalizePath(path); + return URI.create(normalizedPath.replace(" ", "%20")).getPath(); + } + + private static URI createUri(String host, int port, boolean secure, String path) { + try { + String scheme = secure ? "https" : "http"; + String authority = host + ":" + port; + return new URI(scheme, authority, path, null, null); + } catch (Exception e) { + throw new ClientMisconfigurationException("Failed to create endpoint URL", e); + } + } + + private static final class EndpointDetails { + private final String host; + private final int port; + private final boolean secure; + private final String path; + + private EndpointDetails(String host, int port, boolean secure, String path) { + this.host = host; + this.port = port; + this.secure = secure; + this.path = path; + } + } } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java b/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java index 733fc2ebe..cb41de0ad 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/transport/HttpEndpointTest.java @@ -167,4 +167,18 @@ public void testUnderscoreHostIsAcceptedInUri() { Assert.assertEquals(endpoint.getHost(), "host_with_underscore", "Original host should be preserved"); Assert.assertEquals(endpoint.getURI().toString(), "http://host_with_underscore:8123/"); } + + @Test + public void testUrlEndpointPreservesUnderscoreHost() { + HttpEndpoint endpoint = new HttpEndpoint("http://host_with_underscore:8123/"); + Assert.assertEquals(endpoint.getHost(), "host_with_underscore", "Original host should be preserved"); + Assert.assertEquals(endpoint.getURI().toString(), "http://host_with_underscore:8123/"); + } + + @Test + public void testUrlEndpointIgnoresQueryAndFragment() { + HttpEndpoint endpoint = new HttpEndpoint("http://localhost:8123/sales%20db?ignored=value#fragment"); + Assert.assertEquals(endpoint.getPath(), "/sales db", "Path should be decoded before URI creation"); + Assert.assertEquals(endpoint.getURI().toString(), "http://localhost:8123/sales%20db"); + } } From 1ce6ce537bf25e736ec91658308b31da5182724d Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 2 Apr 2026 16:07:35 -0700 Subject: [PATCH 3/4] removed dns resolver --- .../com/clickhouse/client/api/Client.java | 22 +++-------------- .../clickhouse/client/api/HostResolver.java | 11 --------- .../api/internal/HttpAPIClientHelper.java | 24 ++----------------- .../client/api/ClientBuilderTest.java | 7 ------ 4 files changed, 5 insertions(+), 59 deletions(-) delete mode 100644 client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 391106082..10004f15c 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -142,7 +142,7 @@ public class Client implements AutoCloseable { private Client(Collection endpoints, Map configuration, ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy, - Object metricsRegistry, Supplier queryIdGenerator, HostResolver hostResolver) { + Object metricsRegistry, Supplier queryIdGenerator) { this.configuration = ClientConfigProperties.parseConfigMap(configuration); this.readOnlyConfig = Collections.unmodifiableMap(configuration); this.metricsRegistry = metricsRegistry; @@ -189,7 +189,7 @@ private Client(Collection endpoints, Map configuration, this.lz4Factory = LZ4Factory.fastestJavaInstance(); } - this.httpClientHelper = new HttpAPIClientHelper(this.configuration, metricsRegistry, initSslContext, lz4Factory, hostResolver); + this.httpClientHelper = new HttpAPIClientHelper(this.configuration, metricsRegistry, initSslContext, lz4Factory); this.serverVersion = configuration.getOrDefault(ClientConfigProperties.SERVER_VERSION.getKey(), "unknown"); this.dbUser = configuration.getOrDefault(ClientConfigProperties.USER.getKey(), ClientConfigProperties.USER.getDefObjVal()); this.typeHintMapping = (Map>) this.configuration.get(ClientConfigProperties.TYPE_HINT_MAPPING.getKey()); @@ -262,7 +262,6 @@ public static class Builder { private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy; private Object metricRegistry = null; private Supplier queryIdGenerator; - private HostResolver hostResolver; public Builder() { this.endpoints = new HashSet<>(); @@ -276,7 +275,6 @@ public Builder() { allowBinaryReaderToReuseBuffers(false); columnToMethodMatchingStrategy = DefaultColumnToMethodMatchingStrategy.INSTANCE; - hostResolver = HostResolver.DEFAULT; } /** @@ -324,20 +322,6 @@ 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 Builder addEndpoint(Endpoint endpoint) { this.endpoints.add(endpoint); return this; @@ -1144,7 +1128,7 @@ public Client build() { } return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor, - this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, this.hostResolver); + this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator); } } diff --git a/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java b/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java deleted file mode 100644 index 48afd8c0b..000000000 --- a/client-v2/src/main/java/com/clickhouse/client/api/HostResolver.java +++ /dev/null @@ -1,11 +0,0 @@ -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; -} diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index c4566cfa3..12f387e1d 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -9,13 +9,11 @@ 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; @@ -78,7 +76,6 @@ 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; @@ -132,11 +129,9 @@ public class HttpAPIClientHelper { ConnPoolControl poolControl; LZ4Factory lz4Factory; - private final HostResolver hostResolver; - public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory, HostResolver hostResolver) { + public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) { this.metricsRegistry = metricsRegistry; - this.hostResolver = Objects.requireNonNull(hostResolver, "hostResolver"); this.httpClient = createHttpClient(initSslContext, configuration); this.lz4Factory = lz4Factory; assert this.lz4Factory != null; @@ -215,7 +210,7 @@ private HttpClientConnectionManager basicConnectionManager(LayeredConnectionSock .build(); BasicHttpClientConnectionManager connManager = BasicHttpClientConnectionManager.create( - null, createDnsResolverAdapter(), tlsSocketStrategyLookup, null); + null, null, tlsSocketStrategyLookup, null); connManager.setConnectionConfig(createConnectionConfig(configuration)); connManager.setSocketConfig(socketConfig); @@ -254,7 +249,6 @@ 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) { @@ -273,20 +267,6 @@ 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 configuration) { // Top Level builders HttpClientBuilder clientBuilder = HttpClientBuilder.create(); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java index 08c72bfcc..044a37de0 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java @@ -11,7 +11,6 @@ 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("") @@ -23,12 +22,6 @@ public void testAddEndpointToleratesUnderscoreHostname() throws Exception { } } - @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); From 84f84ab3e58e7121ae08869190df9bb2fcdf3b5d Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Thu, 2 Apr 2026 16:19:39 -0700 Subject: [PATCH 4/4] Added test that host with underscore accepted but properly causes UnknownHostException --- .../java/com/clickhouse/client/ClientTests.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java index d63f9b2cb..c8904708b 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java @@ -5,6 +5,7 @@ import com.clickhouse.client.api.ClientException; import com.clickhouse.client.api.ClientFaultCause; import com.clickhouse.client.api.ClientMisconfigurationException; +import com.clickhouse.client.api.ConnectionInitiationException; import com.clickhouse.client.api.ConnectionReuseStrategy; import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.enums.Protocol; @@ -30,6 +31,7 @@ import java.io.ByteArrayInputStream; import java.net.ConnectException; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -569,6 +571,21 @@ public void testQueryIdGenerator() throws Exception { Assert.assertEquals(actualIds, new ArrayList<>(queryIds)); } + @Test(groups = {"integration"}) + public void testHostnameWithUnderscore() throws Exception { + + try (Client client = new Client.Builder().addEndpoint("http://localhost_db:8123") + .setUsername("default") + .build()) { + client.queryAll("SELECT 1"); + fail("Exception expected"); + } catch (ClientException e) { + Assert.assertTrue(e.getCause() instanceof ConnectionInitiationException); + ConnectionInitiationException ce = (ConnectionInitiationException) e.getCause(); + Assert.assertTrue(ce.getCause() instanceof UnknownHostException); + } + } + public boolean isVersionMatch(String versionExpression, Client client) { List serverVersion = client.queryAll("SELECT version()"); return ClickHouseVersion.of(serverVersion.get(0).getString(1)).check(versionExpression);