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
130 changes: 130 additions & 0 deletions client-v2/docs/custom-url-path-example.md
Original file line number Diff line number Diff line change
@@ -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<String, String> 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)
14 changes: 14 additions & 0 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@ public ClassicHttpResponse executeRequest(Endpoint server, Map<String, Object> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -254,7 +254,7 @@ public void testDefaultSettings() {
.setSocketSndbuf(100000)
.build()) {
Map<String, String> 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");
Expand Down Expand Up @@ -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.
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> config = new HashMap<>();
config.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), "/sales/db");

Map<String, Object> 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<String, String> config = new HashMap<>();
config.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), "");

Map<String, Object> 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<String, String> config = new HashMap<>();
// Don't set CUSTOM_URL_PATH

Map<String, Object> 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<String, String> config = new HashMap<>();
config.put(ClientConfigProperties.CUSTOM_URL_PATH.getKey(), testPath);

Map<String, Object> parsedConfig = ClientConfigProperties.parseConfigMap(config);

String customPath = (String) parsedConfig.get(ClientConfigProperties.CUSTOM_URL_PATH.getKey());
assertEquals(customPath, testPath, "Failed for path: " + testPath);
}
}
}