From cfa5e3e574ac485ec27a6db36a7c01225cb9556b Mon Sep 17 00:00:00 2001 From: "viktoriya.kutsarova" Date: Mon, 1 Dec 2025 14:54:21 +0200 Subject: [PATCH] Expose method to add upstream driver libraries to CLIENT SETINFO payload --- src/main/java/io/lettuce/core/RedisURI.java | 154 +++++++++++++++++- .../core/RedisURIBuilderUnitTests.java | 71 +++++++- .../io/lettuce/core/RedisURIUnitTests.java | 111 ++++++++++++- .../ServerCommandIntegrationTests.java | 9 + 4 files changed, 338 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/lettuce/core/RedisURI.java b/src/main/java/io/lettuce/core/RedisURI.java index 0ad65d99fb..ff9452158e 100644 --- a/src/main/java/io/lettuce/core/RedisURI.java +++ b/src/main/java/io/lettuce/core/RedisURI.java @@ -225,6 +225,24 @@ public class RedisURI implements Serializable, ConnectionPoint { public static final Duration DEFAULT_TIMEOUT_DURATION = Duration.ofSeconds(DEFAULT_TIMEOUT); + /** + * Regex pattern for driver name validation. The name must start with a lowercase letter and contain only lowercase letters, + * digits, hyphens, and underscores. Mostly follows Maven artifactId naming conventions but also allows underscores. + * + * @see Maven Naming Conventions + */ + private static final String DRIVER_NAME_PATTERN = "^[a-z][a-z0-9_-]*$"; + + /** + * Official semver.org regex pattern for semantic versioning validation. + * + * @see semver.org + * regex + */ + private static final String SEMVER_PATTERN = "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)" + + "(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + "(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$"; + private String host; private String socket; @@ -239,9 +257,11 @@ public class RedisURI implements Serializable, ConnectionPoint { private String libraryName = LettuceVersion.getName(); + private String upstreamDrivers; + private String libraryVersion = LettuceVersion.getVersion(); - private RedisCredentialsProvider credentialsProvider = new StaticCredentialsProvider(null, null);; + private RedisCredentialsProvider credentialsProvider = new StaticCredentialsProvider(null, null); private boolean ssl = false; @@ -606,13 +626,19 @@ public void setClientName(String clientName) { } /** - * Returns the library name. + * Returns the library name to be sent via {@code CLIENT SETINFO}. + *

+ * If upstream drivers have been added via {@link #addUpstreamDriver(String, String)}, the returned value will include them + * in the format: {@code libraryName(driver1_v1.0.0;driver2_v2.0.0)}. Otherwise, returns just the library name. * - * @return the library name. + * @return the library name, potentially including upstream driver information. * @since 6.3 */ public String getLibraryName() { - return libraryName; + if (upstreamDrivers == null) { + return libraryName; + } + return libraryName + "(" + upstreamDrivers + ")"; } /** @@ -628,6 +654,79 @@ public void setLibraryName(String libraryName) { this.libraryName = libraryName; } + /** + * Adds an upstream driver to be appended to the library name when sent via {@code CLIENT SETINFO}. + *

+ * This method allows upstream libraries (e.g., Spring Data Redis) that use Lettuce as their Redis driver to identify + * themselves. Multiple upstream drivers can be added and will be formatted according to the Redis CLIENT SETINFO format: + * {@code lettuce(driver1_v1.0.0;driver2_v2.0.0)}. + *

+ * Each newly added upstream driver is prepended to the list, so the most recently added driver appears first. For example, + * if you call {@code addUpstreamDriver("spring-data-redis", "3.2.0")} followed by + * {@code addUpstreamDriver("spring-boot", "3.3.0")}, the resulting library name will be + * {@code lettuce(spring-boot_v3.3.0;spring-data-redis_v3.2.0)}. + *

+ * The driver name must follow Maven artifactId + * naming conventions: lowercase letters, digits, hyphens, and underscores only, starting with a lowercase letter (e.g., + * {@code spring-data-redis}, {@code lettuce-core}, {@code akka-redis_2.13}). + *

+ * The driver version must follow semantic versioning (e.g., {@code 1.0.0}, + * {@code 2.1.3-beta}, {@code 1.0.0-alpha.1}, {@code 1.0.0+build.123}). + * + * @param driverName the name of the upstream driver (e.g., "spring-data-redis"), must not be {@code null} and must follow + * Maven artifactId naming + * conventions + * @param driverVersion the version of the upstream driver (e.g., "3.2.0"), must not be {@code null} and must follow + * semantic versioning + * @throws IllegalArgumentException if the driver name or version format is invalid + * @since 6.5 + * @see CLIENT SETINFO + */ + public void addUpstreamDriver(String driverName, String driverVersion) { + + LettuceAssert.notNull(driverName, "Upstream driver name must not be null"); + LettuceAssert.notNull(driverVersion, "Upstream driver version must not be null"); + validateDriverName(driverName); + validateDriverVersion(driverVersion); + + String driver = driverName + "_v" + driverVersion; + this.upstreamDrivers = this.upstreamDrivers == null ? driver : driver + ";" + this.upstreamDrivers; + } + + /** + * Validates that the driver name follows Maven artifactId naming conventions: lowercase letters, digits, hyphens, and + * underscores only, starting with a lowercase letter (e.g., {@code spring-data-redis}, {@code lettuce-core}, + * {@code akka-redis_2.13}). + * + * @param driverName the driver name to validate + * @throws IllegalArgumentException if the driver name does not follow the expected naming conventions + * @see Maven Naming Conventions + */ + private static void validateDriverName(String driverName) { + if (!driverName.matches(DRIVER_NAME_PATTERN)) { + throw new IllegalArgumentException( + "Upstream driver name must follow Maven artifactId naming conventions: lowercase letters, digits, hyphens, and underscores only (e.g., 'spring-data-redis', 'lettuce-core')"); + } + } + + /** + * Validates that the driver version follows semantic versioning (semver.org). The version must be in the format + * {@code MAJOR.MINOR.PATCH} with optional pre-release and build metadata suffixes. + *

+ * Examples of valid versions: {@code 1.0.0}, {@code 2.1.3}, {@code 1.0.0-alpha}, {@code 1.0.0-alpha.1}, + * {@code 1.0.0-0.3.7}, {@code 1.0.0-x.7.z.92}, {@code 1.0.0+20130313144700}, {@code 1.0.0-beta+exp.sha.5114f85} + * + * @param driverVersion the driver version to validate + * @throws IllegalArgumentException if the driver version does not follow semantic versioning + * @see Semantic Versioning 2.0.0 + */ + private static void validateDriverVersion(String driverVersion) { + if (!driverVersion.matches(SEMVER_PATTERN)) { + throw new IllegalArgumentException( + "Upstream driver version must follow semantic versioning (e.g., '1.0.0', '2.1.3-beta', '1.0.0+build.123')"); + } + } + /** * Returns the library version. * @@ -1295,6 +1394,8 @@ public static class Builder { private String libraryVersion = LettuceVersion.getVersion(); + private String upstreamDrivers; + private RedisCredentialsProvider credentialsProvider; private boolean ssl = false; @@ -1630,6 +1731,50 @@ public Builder withLibraryName(String libraryName) { return this; } + /** + * Adds an upstream driver to be appended to the library name when sent via {@code CLIENT SETINFO}. + *

+ * This method allows upstream libraries (e.g., Spring Data Redis) that use Lettuce as their Redis driver to identify + * themselves. Multiple upstream drivers can be added and will be formatted according to the Redis CLIENT SETINFO + * format: {@code lettuce(driver1_v1.0.0;driver2_v2.0.0)}. + *

+ * Each newly added upstream driver is prepended to the list, so the most recently added driver appears first. For + * example, if you call {@code addUpstreamDriver("spring-data-redis", "3.2.0")} followed by + * {@code addUpstreamDriver("spring-boot", "3.3.0")}, the resulting library name will be + * {@code lettuce(spring-boot_v3.3.0;spring-data-redis_v3.2.0)}. + *

+ * The driver name must follow Maven + * artifactId naming conventions: lowercase letters, digits, hyphens, and underscores only, starting with a + * lowercase letter (e.g., {@code spring-data-redis}, {@code lettuce-core}, {@code akka-redis_2.13}). + *

+ * The driver version must follow semantic versioning (e.g., {@code 1.0.0}, + * {@code 2.1.3-beta}, {@code 1.0.0-alpha.1}, {@code 1.0.0+build.123}). + *

+ * Also sets upstream driver for already configured Redis Sentinel nodes. + * + * @param driverName the name of the upstream driver (e.g., "spring-data-redis"), must not be {@code null} and must + * follow Maven artifactId naming + * conventions + * @param driverVersion the version of the upstream driver (e.g., "3.2.0"), must not be {@code null} and must follow + * semantic versioning + * @return the builder + * @throws IllegalArgumentException if the driver name or version format is invalid + * @since 6.5 + * @see CLIENT SETINFO + */ + public Builder addUpstreamDriver(String driverName, String driverVersion) { + + LettuceAssert.notNull(driverName, "Upstream driver name must not be null"); + LettuceAssert.notNull(driverVersion, "Upstream driver version must not be null"); + validateDriverName(driverName); + validateDriverVersion(driverVersion); + + String driver = driverName + "_v" + driverVersion; + this.upstreamDrivers = this.upstreamDrivers == null ? driver : driver + ";" + this.upstreamDrivers; + this.sentinels.forEach(it -> it.addUpstreamDriver(driverName, driverVersion)); + return this; + } + /** * Configures a library version. Sets library version also for already configured Redis Sentinel nodes. * @@ -1790,6 +1935,7 @@ public RedisURI build() { redisURI.setClientName(clientName); redisURI.setLibraryName(libraryName); redisURI.setLibraryVersion(libraryVersion); + redisURI.upstreamDrivers = upstreamDrivers; redisURI.setSentinelMasterId(sentinelMasterId); diff --git a/src/test/java/io/lettuce/core/RedisURIBuilderUnitTests.java b/src/test/java/io/lettuce/core/RedisURIBuilderUnitTests.java index 0f52e63c78..9582802202 100644 --- a/src/test/java/io/lettuce/core/RedisURIBuilderUnitTests.java +++ b/src/test/java/io/lettuce/core/RedisURIBuilderUnitTests.java @@ -31,7 +31,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.OS; -import reactor.core.publisher.Mono; import reactor.test.StepVerifier; /** @@ -102,6 +101,76 @@ void redisWithLibrary() { assertThat(result.getLibraryVersion()).isEqualTo("1.foo"); } + @Test + void redisWithSingleUpstreamDriver() { + RedisURI result = RedisURI.Builder.redis("localhost").withLibraryName("lettuce") + .addUpstreamDriver("spring-data-redis", "3.2.0").build(); + + assertThat(result.getLibraryName()).isEqualTo("lettuce(spring-data-redis_v3.2.0)"); + } + + @Test + void redisWithMultipleUpstreamDrivers() { + RedisURI result = RedisURI.Builder.redis("localhost").withLibraryName("lettuce") + .addUpstreamDriver("spring-data-redis", "3.2.0").addUpstreamDriver("spring-boot", "3.3.0").build(); + + // Most recently added driver should appear first (prepend behavior) + assertThat(result.getLibraryName()).isEqualTo("lettuce(spring-boot_v3.3.0;spring-data-redis_v3.2.0)"); + } + + @Test + void redisWithUpstreamDriverNullNameShouldFail() { + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver(null, "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void redisWithUpstreamDriverNullVersionShouldFail() { + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("example-driver", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void redisWithUpstreamDriverNameWithSpacesShouldFail() { + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("spring data", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void redisWithUpstreamDriverVersionWithSpacesShouldFail() { + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("example-driver", "1.0 beta")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void redisWithUpstreamDriverInvalidNameFormatShouldFail() { + // Name starting with a number + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("123driver", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name with @ symbol + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("driver@name", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name starting with hyphen + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("-spring-data", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name with dots (not allowed in Maven artifactId) + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("com.example.driver", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name with uppercase letters + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("Spring-Data-Redis", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void redisWithUpstreamDriverInvalidVersionFormatShouldFail() { + // Version without patch number + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("example-driver", "1.0")) + .isInstanceOf(IllegalArgumentException.class); + // Version with leading zeros + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("example-driver", "01.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } + @Test void redisHostAndPortWithInvalidPort() { assertThatThrownBy(() -> RedisURI.Builder.redis("localhost", -1)).isInstanceOf(IllegalArgumentException.class); diff --git a/src/test/java/io/lettuce/core/RedisURIUnitTests.java b/src/test/java/io/lettuce/core/RedisURIUnitTests.java index 7c01ce07ff..be4c11ddce 100644 --- a/src/test/java/io/lettuce/core/RedisURIUnitTests.java +++ b/src/test/java/io/lettuce/core/RedisURIUnitTests.java @@ -31,8 +31,6 @@ import java.util.Set; import java.util.concurrent.TimeUnit; -import io.lettuce.core.cluster.ClusterTestSettings; -import io.lettuce.test.settings.TestSettings; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -384,4 +382,113 @@ void shouldApplyAuthentication() { assertThat(sourceCp.getCredentialsProvider()).isEqualTo(targetCp.getCredentialsProvider()); } + @Test + void addUpstreamDriverSingleDriver() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + redisURI.setLibraryName("lettuce"); + redisURI.addUpstreamDriver("spring-data-redis", "3.2.0"); + + assertThat(redisURI.getLibraryName()).isEqualTo("lettuce(spring-data-redis_v3.2.0)"); + } + + @Test + void addUpstreamDriverMultipleDrivers() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + redisURI.setLibraryName("lettuce"); + redisURI.addUpstreamDriver("spring-data-redis", "3.2.0"); + redisURI.addUpstreamDriver("spring-boot", "3.3.0"); + + // Most recently added driver should appear first (prepend behavior) + assertThat(redisURI.getLibraryName()).isEqualTo("lettuce(spring-boot_v3.3.0;spring-data-redis_v3.2.0)"); + } + + @Test + void addUpstreamDriverNullNameShouldFail() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + assertThatThrownBy(() -> redisURI.addUpstreamDriver(null, "1.0.0")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addUpstreamDriverNullVersionShouldFail() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addUpstreamDriverNameWithSpacesShouldFail() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + assertThatThrownBy(() -> redisURI.addUpstreamDriver("spring data", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addUpstreamDriverVersionWithSpacesShouldFail() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "1.0 beta")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addUpstreamDriverInvalidNameFormatShouldFail() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + // Name starting with a number + assertThatThrownBy(() -> redisURI.addUpstreamDriver("123driver", "1.0.0")).isInstanceOf(IllegalArgumentException.class); + // Name with @ symbol + assertThatThrownBy(() -> redisURI.addUpstreamDriver("driver@name", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name with dots (not allowed in Maven artifactId) + assertThatThrownBy(() -> redisURI.addUpstreamDriver("com.example.driver", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name starting with hyphen + assertThatThrownBy(() -> redisURI.addUpstreamDriver("-spring-data", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name with uppercase letters + assertThatThrownBy(() -> redisURI.addUpstreamDriver("Spring-Data-Redis", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addUpstreamDriverInvalidVersionFormatShouldFail() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + // Version without patch number + assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "1.0")) + .isInstanceOf(IllegalArgumentException.class); + // Version with leading zeros + assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "01.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Version with invalid characters + assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "1.0.0@beta")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addUpstreamDriverValidFormats() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + redisURI.setLibraryName("lettuce"); + + // Valid Maven artifactId formats (lowercase letters, digits, hyphens) + redisURI.addUpstreamDriver("spring-data-redis", "1.0.0"); + redisURI.addUpstreamDriver("lettuce-core", "2.0.0"); + redisURI.addUpstreamDriver("commons-math", "3.0.0"); + redisURI.addUpstreamDriver("guava", "4.0.0"); + redisURI.addUpstreamDriver("jedis", "5.0.0"); + + // Valid semantic versions with pre-release and build metadata + RedisURI redisURI2 = RedisURI.create("redis://localhost"); + redisURI2.addUpstreamDriver("example-lib", "1.0.0-alpha"); + redisURI2.addUpstreamDriver("example-lib", "1.0.0-beta.1"); + redisURI2.addUpstreamDriver("example-lib", "1.0.0+build.123"); + redisURI2.addUpstreamDriver("example-lib", "1.0.0-rc.1+build.456"); + } + + @Test + void getLibraryNameWithoutUpstreamDrivers() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + redisURI.setLibraryName("lettuce"); + + // Without upstream drivers, should return just the library name + assertThat(redisURI.getLibraryName()).isEqualTo("lettuce"); + } + } diff --git a/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java b/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java index 8278f20146..40b0435543 100644 --- a/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java +++ b/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java @@ -640,6 +640,15 @@ void clientSetinfo() { assertThat(redis.clientInfo().contains("lib-name=lettuce")).isTrue(); } + @Test + @EnabledOnCommand("WAITAOF") + // Redis 7.2 + void clientSetinfoWithUpstreamDriver() { + redis.clientSetinfo("lib-name", "lettuce(spring-data-redis_v1.0.0)"); + + assertThat(redis.clientInfo().contains("lib-name=lettuce(spring-data-redis_v1.0.0)")).isTrue(); + } + @Test void testReadOnlyCommands() { for (ProtocolKeyword readOnlyCommand : ClusterReadOnlyCommands.getReadOnlyCommands()) {