diff --git a/client-v2/docs/custom-url-path-example.md b/client-v2/docs/custom-url-path-example.md new file mode 100644 index 000000000..975377fc2 --- /dev/null +++ b/client-v2/docs/custom-url-path-example.md @@ -0,0 +1,130 @@ +# Custom URL Path Configuration + +## Overview + +The custom URL path feature allows you to route requests to different database instances behind a load balancer by configuring a custom path to be appended to the base endpoint URL. + +## Use Case + +When multiple ClickHouse database instances are behind a load balancer, you may need to route requests to different databases based on URL paths. For example: + +- `https://myhost.com:8123/sales/db` → Routes to sales analytics DB +- `https://myhost.com:8123/app/db` → Routes to app stats DB + +## Configuration + +### Using the Builder API + +```java +import com.clickhouse.client.api.Client; + +// Configure client with custom URL path +Client client = new Client.Builder() + .addEndpoint("https://myhost.com:8123") + .customURLPath("/sales/db") + .setUsername("default") + .setPassword("password") + .setDefaultDatabase("my_database") + .build(); +``` + +### Using Configuration Properties + +```java +import com.clickhouse.client.api.Client; +import java.util.HashMap; +import java.util.Map; + +Map config = new HashMap<>(); +config.put("custom_url_path", "/sales/db"); +config.put("user", "default"); +config.put("password", "password"); +config.put("database", "my_database"); + +Client client = new Client.Builder() + .addEndpoint("https://myhost.com:8123") + .setOptions(config) + .build(); +``` + +## Important Notes + +1. **Database Name Header**: The database name is sent via the `X-ClickHouse-Database` header, not as part of the URL path. This ensures proper database routing even when custom paths are used. + +2. **Path Format**: The custom path is appended to the base URL as-is. It's recommended to start with "/" for clarity (e.g., "/sales/db"). + +3. **Existing Paths**: If your base endpoint URL already contains a path (e.g., "https://myhost.com:8123/api"), the custom path will be concatenated (e.g., "https://myhost.com:8123/api/sales/db"). + +## Example Scenarios + +### Scenario 1: Simple Custom Path + +```java +// Base URL: https://myhost.com:8123 +// Custom Path: /sales/db +// Result: https://myhost.com:8123/sales/db + +Client client = new Client.Builder() + .addEndpoint("https://myhost.com:8123") + .customURLPath("/sales/db") + .setUsername("default") + .setPassword("password") + .build(); +``` + +### Scenario 2: Combining with Existing Path + +```java +// Base URL: https://myhost.com:8123/api +// Custom Path: /sales/db +// Result: https://myhost.com:8123/api/sales/db + +Client client = new Client.Builder() + .addEndpoint("https://myhost.com:8123/api") + .customURLPath("/sales/db") + .setUsername("default") + .setPassword("password") + .build(); +``` + +### Scenario 3: Multiple Clients for Different Databases + +```java +// Client for sales database +Client salesClient = new Client.Builder() + .addEndpoint("https://myhost.com:8123") + .customURLPath("/sales/db") + .setUsername("default") + .setPassword("password") + .setDefaultDatabase("sales") + .build(); + +// Client for app stats database +Client appClient = new Client.Builder() + .addEndpoint("https://myhost.com:8123") + .customURLPath("/app/db") + .setUsername("default") + .setPassword("password") + .setDefaultDatabase("app_stats") + .build(); +``` + +## Load Balancer Configuration + +When using this feature, ensure your load balancer is configured to route based on URL paths. For example, with nginx: + +```nginx +location /sales/db { + proxy_pass http://sales-clickhouse-backend; +} + +location /app/db { + proxy_pass http://app-clickhouse-backend; +} +``` + +## Compatibility + +- Available in: client-v2 API +- Minimum version: 0.9.4-SNAPSHOT +- Server compatibility: All ClickHouse versions (server-side configuration required for routing) 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 f7cc187d4..49abd87c2 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 @@ -1048,6 +1048,20 @@ public Builder sslSocketSNI(String sni) { return this; } + /** + * Sets a custom URL path to be appended to the base URL for routing requests. + * This is useful when multiple database instances are behind a load balancer and routing + * is configured by path. For example: "/sales/db" or "/app/db". + * The path will be appended to the base endpoint URL as-is. + * + * @param path - custom URL path (e.g., "/sales/db") + * @return this builder instance + */ + public Builder customURLPath(String path) { + this.configuration.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), path); + return this; + } + public Client build() { // check if endpoint are empty. so can not initiate client if (this.endpoints.isEmpty()) { diff --git a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java index 131fc0f20..25f1bad3c 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java @@ -182,6 +182,12 @@ public Object parseValue(String value) { * SNI SSL parameter that will be set for each outbound SSL socket. */ SSL_SOCKET_SNI("ssl_socket_sni", String.class,""), + + /** + * Custom URL path to be appended to the base URL for routing requests. + * For example: "/sales/db" or "/app/db" + */ + CUSTOM_URL_PATH("custom_url_path", String.class, ""), ; private static final Logger LOG = LoggerFactory.getLogger(ClientConfigProperties.class); 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 de3f4de78..d4f21ed1d 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 @@ -423,6 +423,18 @@ public ClassicHttpResponse executeRequest(Endpoint server, Map r URI uri; try { URIBuilder uriBuilder = new URIBuilder(server.getBaseURL()); + + // Add custom URL path if configured + String customPath = (String) requestConfig.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey()); + if (customPath != null && !customPath.isEmpty()) { + String existingPath = uriBuilder.getPath(); + if (existingPath == null || existingPath.isEmpty() || existingPath.equals("/")) { + uriBuilder.setPath(customPath); + } else { + uriBuilder.setPath(existingPath + customPath); + } + } + addQueryParams(uriBuilder, requestConfig); uri = uriBuilder.normalizeSyntax().build(); } catch (URISyntaxException e) { 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 d78953cac..50c3d9f16 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java @@ -221,7 +221,7 @@ public void testDefaultSettings() { Assert.assertEquals(config.get(p.getKey()), p.getDefaultValue(), "Default value doesn't match"); } } - Assert.assertEquals(config.size(), 32); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 33); // to check everything is set. Increment when new added. } try (Client client = new Client.Builder() @@ -254,7 +254,7 @@ public void testDefaultSettings() { .setSocketSndbuf(100000) .build()) { Map config = client.getConfiguration(); - Assert.assertEquals(config.size(), 33); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 34); // to check everything is set. Increment when new added. Assert.assertEquals(config.get(ClientConfigProperties.DATABASE.getKey()), "mydb"); Assert.assertEquals(config.get(ClientConfigProperties.MAX_EXECUTION_TIME.getKey()), "10"); Assert.assertEquals(config.get(ClientConfigProperties.COMPRESSION_LZ4_UNCOMPRESSED_BUF_SIZE.getKey()), "300000"); @@ -321,7 +321,7 @@ public void testWithOldDefaults() { Assert.assertEquals(config.get(p.getKey()), p.getDefaultValue(), "Default value doesn't match"); } } - Assert.assertEquals(config.size(), 32); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 33); // to check everything is set. Increment when new added. } } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/CustomURLPathTest.java b/client-v2/src/test/java/com/clickhouse/client/api/CustomURLPathTest.java new file mode 100644 index 000000000..a877eb58c --- /dev/null +++ b/client-v2/src/test/java/com/clickhouse/client/api/CustomURLPathTest.java @@ -0,0 +1,111 @@ +package com.clickhouse.client.api; + +import org.testng.annotations.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +/** + * Unit tests for custom URL path configuration feature. + * Tests that the configuration property and builder method work correctly. + */ +public class CustomURLPathTest { + + @Test(groups = {"unit"}) + public void testClientConfigPropertiesHasCustomURLPath() { + // Test that CUSTOM_URL_PATH property exists and has correct key + assertEquals(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), "custom_url_path"); + assertEquals(ClientConfigProperties.CUSTOM_URL_PATH.getDefaultValue(), ""); + } + + @Test(groups = {"unit"}) + public void testClientBuilderCustomURLPathMethod() { + // Test that the builder method exists and sets configuration correctly + // We create a minimal client configuration to test the builder method + try { + Client.Builder builder = new Client.Builder() + .addEndpoint("http://localhost:8123") + .setUsername("default") + .setPassword("") + .customURLPath("/sales/db"); + + // Build client to verify configuration is set + Client client = builder.build(); + try { + // Verify configuration was set correctly + Map config = client.getConfiguration(); + assertNotNull(config); + assertEquals(config.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey()), "/sales/db"); + } finally { + client.close(); + } + } catch (Exception e) { + throw new RuntimeException("Failed to test customURLPath builder method", e); + } + } + + @Test(groups = {"unit"}) + public void testClientConfigPropertyParsing() { + // Test that the configuration property can be parsed correctly + Map config = new HashMap<>(); + config.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), "/sales/db"); + + Map parsedConfig = ClientConfigProperties.parseConfigMap(config); + + String customPath = (String) parsedConfig.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey()); + assertEquals(customPath, "/sales/db"); + } + + @Test(groups = {"unit"}) + public void testEmptyCustomURLPath() { + // Test with empty custom path + Map config = new HashMap<>(); + config.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), ""); + + Map parsedConfig = ClientConfigProperties.parseConfigMap(config); + + String customPath = (String) parsedConfig.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey()); + assertEquals(customPath, ""); + } + + @Test(groups = {"unit"}) + public void testNoCustomURLPathConfiguration() { + // Test without custom path configured - should use default + Map config = new HashMap<>(); + // Don't set CUSTOM_URL_PATH + + Map parsedConfig = ClientConfigProperties.parseConfigMap(config); + + // Should not be in parsed config if not provided + Object customPath = parsedConfig.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey()); + // Either null or empty string is acceptable for unset value + if (customPath != null) { + assertEquals(customPath, ""); + } + } + + @Test(groups = {"unit"}) + public void testCustomURLPathWithDifferentPaths() { + // Test various path formats + String[] testPaths = { + "/sales/db", + "/app/db", + "/custom", + "/a/b/c/d", + "/123/456" + }; + + for (String testPath : testPaths) { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), testPath); + + Map parsedConfig = ClientConfigProperties.parseConfigMap(config); + + String customPath = (String) parsedConfig.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey()); + assertEquals(customPath, testPath, "Failed for path: " + testPath); + } + } +}