From cfa5e3e574ac485ec27a6db36a7c01225cb9556b Mon Sep 17 00:00:00 2001 From: "viktoriya.kutsarova" Date: Mon, 1 Dec 2025 14:54:21 +0200 Subject: [PATCH 1/4] 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()) { From 3b6b3d483ec3c874890a2d9ec664ebbc872e865a Mon Sep 17 00:00:00 2001 From: "viktoriya.kutsarova" Date: Wed, 3 Dec 2025 16:10:57 +0200 Subject: [PATCH 2/4] Create a separate class to hold driver name and upstream drivers information --- src/main/java/io/lettuce/core/DriverInfo.java | 223 ++++++++++++++++++ src/main/java/io/lettuce/core/RedisURI.java | 184 ++++----------- .../core/RedisURIBuilderUnitTests.java | 73 +++--- .../io/lettuce/core/RedisURIUnitTests.java | 115 ++++----- 4 files changed, 362 insertions(+), 233 deletions(-) create mode 100644 src/main/java/io/lettuce/core/DriverInfo.java diff --git a/src/main/java/io/lettuce/core/DriverInfo.java b/src/main/java/io/lettuce/core/DriverInfo.java new file mode 100644 index 0000000000..a97ccf9f56 --- /dev/null +++ b/src/main/java/io/lettuce/core/DriverInfo.java @@ -0,0 +1,223 @@ +package io.lettuce.core; + +import io.lettuce.core.internal.LettuceAssert; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Immutable class representing driver information for Redis client identification. + *

+ * This class is used to identify the client library and any upstream drivers (such as Spring Data Redis or Spring Session) + * when connecting to Redis. The information is sent via the {@code CLIENT SETINFO} command. + *

+ * The formatted name follows the pattern: {@code name(driver1_vVersion1;driver2_vVersion2)} + * + * @author Viktoriya Kutsarova + * @since 6.5 + * @see RedisURI#setDriverInfo(DriverInfo) + * @see RedisURI#getDriverInfo() + * @see CLIENT SETINFO + */ +public final class DriverInfo implements Serializable { + + /** + * 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 final String name; + + private final List upstreamDrivers; + + private DriverInfo(String name, List upstreamDrivers) { + this.name = name; + this.upstreamDrivers = Collections.unmodifiableList(upstreamDrivers); + } + + /** + * Creates a new {@link Builder} with default values. + *

+ * The default name is "Lettuce" (from {@link LettuceVersion#getName()}). + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a new {@link Builder} initialized with values from an existing {@link DriverInfo}. + * + * @param driverInfo the existing driver info to copy from, must not be {@code null} + * @return a new builder instance initialized with the existing values + * @throws IllegalArgumentException if driverInfo is {@code null} + */ + public static Builder builder(DriverInfo driverInfo) { + LettuceAssert.notNull(driverInfo, "DriverInfo must not be null"); + return new Builder(driverInfo); + } + + /** + * Returns the formatted name including upstream drivers. + *

+ * If no upstream drivers are present, returns just the name. Otherwise, returns the name followed by upstream drivers in + * parentheses, separated by semicolons. + *

+ * Examples: + *

+ * + * @return the formatted name for use in CLIENT SETINFO + */ + public String getFormattedName() { + if (upstreamDrivers.isEmpty()) { + return name; + } + return name + "(" + String.join(";", upstreamDrivers) + ")"; + } + + /** + * Returns the base library name without upstream driver information. + * + * @return the library name + */ + public String getName() { + return name; + } + + @Override + public String toString() { + return getFormattedName(); + } + + /** + * Builder for creating {@link DriverInfo} instances. + */ + public static class Builder { + + private String name; + + private final List upstreamDrivers; + + private Builder() { + this.name = LettuceVersion.getName(); + this.upstreamDrivers = new ArrayList<>(); + } + + private Builder(DriverInfo driverInfo) { + this.name = driverInfo.name; + this.upstreamDrivers = new ArrayList<>(driverInfo.upstreamDrivers); + } + + /** + * Sets the base library name. + *

+ * This overrides the default name ("Lettuce"). Use this when you want to completely customise the library + * identification. + * + * @param name the library name, must not be {@code null} + * @return this builder + * @throws IllegalArgumentException if name is {@code null} + */ + public Builder name(String name) { + LettuceAssert.notNull(name, "Name must not be null"); + this.name = name; + return this; + } + + /** + * Adds an upstream driver to the driver information. + *

+ * Upstream drivers are prepended to the list, so the most recently added driver appears first in the formatted output. + *

+ * The driver name must follow Maven artifactId naming conventions: lowercase letters, digits, hyphens, and underscores + * only, starting with a lowercase letter. + *

+ * The driver version must follow semantic versioning (semver.org). + * + * @param driverName the name of the upstream driver (e.g., "spring-data-redis"), must not be {@code null} + * @param driverVersion the version of the upstream driver (e.g., "3.2.0"), must not be {@code null} + * @return this builder + * @throws IllegalArgumentException if the driver name or version is {@code null} or has invalid format + * @see Maven Naming Conventions + * @see Semantic Versioning + */ + public Builder addUpstreamDriver(String driverName, String driverVersion) { + LettuceAssert.notNull(driverName, "Driver name must not be null"); + LettuceAssert.notNull(driverVersion, "Driver version must not be null"); + validateDriverName(driverName); + validateDriverVersion(driverVersion); + String formattedDriverInfo = formatDriverInfo(driverName, driverVersion); + this.upstreamDrivers.add(0, formattedDriverInfo); + return this; + } + + /** + * Builds and returns a new immutable {@link DriverInfo} instance. + * + * @return a new DriverInfo instance + */ + public DriverInfo build() { + return new DriverInfo(name, 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')"); + } + } + + private static String formatDriverInfo(String driverName, String driverVersion) { + return driverName + "_v" + driverVersion; + } + +} diff --git a/src/main/java/io/lettuce/core/RedisURI.java b/src/main/java/io/lettuce/core/RedisURI.java index ff9452158e..d5c6f09e38 100644 --- a/src/main/java/io/lettuce/core/RedisURI.java +++ b/src/main/java/io/lettuce/core/RedisURI.java @@ -225,24 +225,6 @@ 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; @@ -255,9 +237,7 @@ public class RedisURI implements Serializable, ConnectionPoint { private String clientName; - private String libraryName = LettuceVersion.getName(); - - private String upstreamDrivers; + private DriverInfo driverInfo = DriverInfo.builder().build(); private String libraryVersion = LettuceVersion.getVersion(); @@ -628,113 +608,69 @@ public void setClientName(String clientName) { /** * 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. + * If upstream drivers have been added via {@link DriverInfo}, 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, potentially including upstream driver information. * @since 6.3 + * @see #getDriverInfo() + * @see DriverInfo#getFormattedName() */ public String getLibraryName() { - if (upstreamDrivers == null) { - return libraryName; - } - return libraryName + "(" + upstreamDrivers + ")"; + return driverInfo.getFormattedName(); } /** * Sets the library name to be applied on Redis connections. + *

+ * This method creates a new {@link DriverInfo} with only the specified name, clearing any previously set upstream drivers. + * If you want to preserve upstream drivers, use {@link #setDriverInfo(DriverInfo)} instead. * - * @param libraryName the library name. + * @param libraryName the library name, must not contain spaces. * @since 6.3 + * @see #setDriverInfo(DriverInfo) */ public void setLibraryName(String libraryName) { if (libraryName != null && libraryName.indexOf(' ') != -1) { throw new IllegalArgumentException("Library name must not contain spaces"); } - this.libraryName = libraryName; + this.driverInfo = DriverInfo.builder().name(libraryName).build(); } /** - * 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}). + * Returns the library version. * - * @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 + * @return the library version. + * @since 6.3 */ - 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; + public String getLibraryVersion() { + return libraryVersion; } /** - * 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}). + * Returns the driver information containing the library name and upstream drivers. * - * @param driverName the driver name to validate - * @throws IllegalArgumentException if the driver name does not follow the expected naming conventions - * @see Maven Naming Conventions + * @return the driver information, never {@code null} + * @since 6.5 + * @see DriverInfo */ - 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')"); - } + public DriverInfo getDriverInfo() { + return driverInfo; } /** - * 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. + * Sets the driver information containing the library name and upstream drivers. *

- * 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. + * This method replaces any previously set driver information or library name. Use this method when you want structured + * control over the library identification sent via {@code CLIENT SETINFO}. * - * @return the library version. - * @since 6.3 + * @param driverInfo the driver information to set + * @since 6.5 + * @see DriverInfo + * @see #setLibraryName(String) */ - public String getLibraryVersion() { - return libraryVersion; + public void setDriverInfo(DriverInfo driverInfo) { + this.driverInfo = driverInfo; } /** @@ -1046,8 +982,8 @@ private String getQueryString() { queryPairs.add(PARAMETER_NAME_CLIENT_NAME + "=" + urlEncode(clientName)); } - if (libraryName != null && !libraryName.equals(LettuceVersion.getName())) { - queryPairs.add(PARAMETER_NAME_LIBRARY_NAME + "=" + urlEncode(libraryName)); + if (driverInfo.getName() != null && !driverInfo.getName().equals(LettuceVersion.getName())) { + queryPairs.add(PARAMETER_NAME_LIBRARY_NAME + "=" + urlEncode(driverInfo.getName())); } if (libraryVersion != null && !libraryVersion.equals(LettuceVersion.getVersion())) { @@ -1390,11 +1326,9 @@ public static class Builder { private String clientName; - private String libraryName = LettuceVersion.getName(); - private String libraryVersion = LettuceVersion.getVersion(); - private String upstreamDrivers; + private DriverInfo driverInfo = DriverInfo.builder().build(); private RedisCredentialsProvider credentialsProvider; @@ -1726,52 +1660,35 @@ public Builder withLibraryName(String libraryName) { throw new IllegalArgumentException("Library name must not contain spaces"); } - this.libraryName = libraryName; + this.driverInfo = DriverInfo.builder().name(libraryName).build(); this.sentinels.forEach(it -> it.setLibraryName(libraryName)); return this; } /** - * Adds an upstream driver to be appended to the library name when sent via {@code CLIENT SETINFO}. + * Configures driver information containing the library name and upstream drivers. *

* 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}). + * themselves. The driver information is sent via the {@code CLIENT SETINFO} command. *

- * 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}). + * If both {@link #withLibraryName(String)} and this method are called, the last call wins. When {@code driverInfo} is + * set, it takes precedence over {@code libraryName} in the built {@link RedisURI}. *

- * Also sets upstream driver for already configured Redis Sentinel nodes. + * Also sets driver info 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 + * @param driverInfo the driver information, must not be {@code null} * @return the builder - * @throws IllegalArgumentException if the driver name or version format is invalid - * @since 6.5 + * @throws IllegalArgumentException if driverInfo is {@code null} + * @see DriverInfo * @see CLIENT SETINFO + * @since 6.5 */ - public Builder addUpstreamDriver(String driverName, String driverVersion) { + public Builder withDriverInfo(DriverInfo driverInfo) { - LettuceAssert.notNull(driverName, "Upstream driver name must not be null"); - LettuceAssert.notNull(driverVersion, "Upstream driver version must not be null"); - validateDriverName(driverName); - validateDriverVersion(driverVersion); + LettuceAssert.notNull(driverInfo, "DriverInfo must not be null"); - String driver = driverName + "_v" + driverVersion; - this.upstreamDrivers = this.upstreamDrivers == null ? driver : driver + ";" + this.upstreamDrivers; - this.sentinels.forEach(it -> it.addUpstreamDriver(driverName, driverVersion)); + this.driverInfo = driverInfo; + this.sentinels.forEach(it -> it.setDriverInfo(driverInfo)); return this; } @@ -1933,9 +1850,8 @@ public RedisURI build() { redisURI.setDatabase(database); redisURI.setClientName(clientName); - redisURI.setLibraryName(libraryName); + redisURI.setDriverInfo(driverInfo); 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 9582802202..95333b4415 100644 --- a/src/test/java/io/lettuce/core/RedisURIBuilderUnitTests.java +++ b/src/test/java/io/lettuce/core/RedisURIBuilderUnitTests.java @@ -102,73 +102,54 @@ void redisWithLibrary() { } @Test - void redisWithSingleUpstreamDriver() { - RedisURI result = RedisURI.Builder.redis("localhost").withLibraryName("lettuce") - .addUpstreamDriver("spring-data-redis", "3.2.0").build(); + void redisWithDriverInfo() { + DriverInfo driverInfo = DriverInfo.builder().addUpstreamDriver("spring-data-redis", "3.2.0").build(); + RedisURI result = RedisURI.Builder.redis("localhost").withDriverInfo(driverInfo).build(); - assertThat(result.getLibraryName()).isEqualTo("lettuce(spring-data-redis_v3.2.0)"); + 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(); + void redisWithDriverInfoMultipleDrivers() { + DriverInfo driverInfo = DriverInfo.builder().addUpstreamDriver("spring-data-redis", "3.2.0") + .addUpstreamDriver("spring-boot", "3.3.0").build(); + RedisURI result = RedisURI.Builder.redis("localhost").withDriverInfo(driverInfo).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)"); + 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); - } + void redisWithDriverInfoCustomName() { + DriverInfo driverInfo = DriverInfo.builder().name("my-custom-lib").addUpstreamDriver("spring-data-redis", "3.2.0") + .build(); + RedisURI result = RedisURI.Builder.redis("localhost").withDriverInfo(driverInfo).build(); - @Test - void redisWithUpstreamDriverNullVersionShouldFail() { - assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("example-driver", null)) - .isInstanceOf(IllegalArgumentException.class); + assertThat(result.getLibraryName()).isEqualTo("my-custom-lib(spring-data-redis_v3.2.0)"); } @Test - void redisWithUpstreamDriverNameWithSpacesShouldFail() { - assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("spring data", "1.0.0")) + void redisWithDriverInfoNullShouldFail() { + assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").withDriverInfo(null)) .isInstanceOf(IllegalArgumentException.class); } @Test - void redisWithUpstreamDriverVersionWithSpacesShouldFail() { - assertThatThrownBy(() -> RedisURI.Builder.redis("localhost").addUpstreamDriver("example-driver", "1.0 beta")) - .isInstanceOf(IllegalArgumentException.class); - } + void redisWithLibraryNameThenDriverInfo() { + // Last call wins: withDriverInfo overwrites previous withLibraryName + DriverInfo driverInfo = DriverInfo.builder().addUpstreamDriver("spring-data-redis", "3.2.0").build(); + RedisURI result = RedisURI.Builder.redis("localhost").withLibraryName("my-lib").withDriverInfo(driverInfo).build(); - @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); + assertThat(result.getLibraryName()).isEqualTo("Lettuce(spring-data-redis_v3.2.0)"); } @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); + void redisWithDriverInfoThenLibraryName() { + // Last call wins: withLibraryName overwrites previous withDriverInfo + DriverInfo driverInfo = DriverInfo.builder().addUpstreamDriver("spring-data-redis", "3.2.0").build(); + RedisURI result = RedisURI.Builder.redis("localhost").withDriverInfo(driverInfo).withLibraryName("my-lib").build(); + + assertThat(result.getLibraryName()).isEqualTo("my-lib"); } @Test diff --git a/src/test/java/io/lettuce/core/RedisURIUnitTests.java b/src/test/java/io/lettuce/core/RedisURIUnitTests.java index be4c11ddce..2644f43a59 100644 --- a/src/test/java/io/lettuce/core/RedisURIUnitTests.java +++ b/src/test/java/io/lettuce/core/RedisURIUnitTests.java @@ -383,103 +383,97 @@ void shouldApplyAuthentication() { } @Test - void addUpstreamDriverSingleDriver() { + void setDriverInfoSingleDriver() { RedisURI redisURI = RedisURI.create("redis://localhost"); - redisURI.setLibraryName("lettuce"); - redisURI.addUpstreamDriver("spring-data-redis", "3.2.0"); + DriverInfo driverInfo = DriverInfo.builder().name("lettuce").addUpstreamDriver("spring-data-redis", "3.2.0").build(); + redisURI.setDriverInfo(driverInfo); assertThat(redisURI.getLibraryName()).isEqualTo("lettuce(spring-data-redis_v3.2.0)"); } @Test - void addUpstreamDriverMultipleDrivers() { + void setDriverInfoMultipleDrivers() { RedisURI redisURI = RedisURI.create("redis://localhost"); - redisURI.setLibraryName("lettuce"); - redisURI.addUpstreamDriver("spring-data-redis", "3.2.0"); - redisURI.addUpstreamDriver("spring-boot", "3.3.0"); + DriverInfo driverInfo = DriverInfo.builder().name("lettuce").addUpstreamDriver("spring-data-redis", "3.2.0") + .addUpstreamDriver("spring-boot", "3.3.0").build(); + redisURI.setDriverInfo(driverInfo); // 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() { + void driverInfoChaining() { 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); - } + // Spring Data Redis adds itself + DriverInfo springDataInfo = DriverInfo.builder().addUpstreamDriver("spring-data-redis", "3.2.0").build(); + redisURI.setDriverInfo(springDataInfo); - @Test - void addUpstreamDriverNameWithSpacesShouldFail() { - RedisURI redisURI = RedisURI.create("redis://localhost"); - assertThatThrownBy(() -> redisURI.addUpstreamDriver("spring data", "1.0.0")) - .isInstanceOf(IllegalArgumentException.class); - } + // Spring Session chains onto it + DriverInfo existing = redisURI.getDriverInfo(); + DriverInfo withSession = DriverInfo.builder(existing).addUpstreamDriver("spring-session", "3.3.0").build(); + redisURI.setDriverInfo(withSession); - @Test - void addUpstreamDriverVersionWithSpacesShouldFail() { - RedisURI redisURI = RedisURI.create("redis://localhost"); - assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "1.0 beta")) - .isInstanceOf(IllegalArgumentException.class); + assertThat(redisURI.getLibraryName()).isEqualTo("Lettuce(spring-session_v3.3.0;spring-data-redis_v3.2.0)"); } @Test - void addUpstreamDriverInvalidNameFormatShouldFail() { - RedisURI redisURI = RedisURI.create("redis://localhost"); + void driverInfoBuilderInvalidNameShouldFail() { // Name starting with a number - assertThatThrownBy(() -> redisURI.addUpstreamDriver("123driver", "1.0.0")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("123driver", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); // Name with @ symbol - assertThatThrownBy(() -> redisURI.addUpstreamDriver("driver@name", "1.0.0")) + assertThatThrownBy(() -> DriverInfo.builder().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")) + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("com.example.driver", "1.0.0")) .isInstanceOf(IllegalArgumentException.class); // Name starting with hyphen - assertThatThrownBy(() -> redisURI.addUpstreamDriver("-spring-data", "1.0.0")) + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("-spring-data", "1.0.0")) .isInstanceOf(IllegalArgumentException.class); // Name with uppercase letters - assertThatThrownBy(() -> redisURI.addUpstreamDriver("Spring-Data-Redis", "1.0.0")) + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("Spring-Data-Redis", "1.0.0")) + .isInstanceOf(IllegalArgumentException.class); + // Name with spaces + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("spring data", "1.0.0")) .isInstanceOf(IllegalArgumentException.class); } @Test - void addUpstreamDriverInvalidVersionFormatShouldFail() { - RedisURI redisURI = RedisURI.create("redis://localhost"); + void driverInfoBuilderInvalidVersionShouldFail() { // Version without patch number - assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "1.0")) + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "1.0")) .isInstanceOf(IllegalArgumentException.class); // Version with leading zeros - assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "01.0.0")) + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "01.0.0")) .isInstanceOf(IllegalArgumentException.class); // Version with invalid characters - assertThatThrownBy(() -> redisURI.addUpstreamDriver("example-driver", "1.0.0@beta")) + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "1.0.0@beta")) + .isInstanceOf(IllegalArgumentException.class); + // Version with spaces + assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "1.0 beta")) .isInstanceOf(IllegalArgumentException.class); } @Test - void addUpstreamDriverValidFormats() { - RedisURI redisURI = RedisURI.create("redis://localhost"); - redisURI.setLibraryName("lettuce"); - + void driverInfoBuilderValidFormats() { // 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"); + DriverInfo.builder().addUpstreamDriver("spring-data-redis", "1.0.0").addUpstreamDriver("lettuce-core", "2.0.0") + .addUpstreamDriver("commons-math", "3.0.0").addUpstreamDriver("guava", "4.0.0") + .addUpstreamDriver("jedis", "5.0.0").build(); // 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"); + DriverInfo.builder().addUpstreamDriver("example-lib", "1.0.0-alpha").addUpstreamDriver("example-lib", "1.0.0-beta.1") + .addUpstreamDriver("example-lib", "1.0.0+build.123").addUpstreamDriver("example-lib", "1.0.0-rc.1+build.456") + .build(); + } + + @Test + void defaultLibraryName() { + // Requirement 4: Default behavior - library name defaults to "Lettuce" + RedisURI redisURI = RedisURI.create("redis://localhost"); + assertThat(redisURI.getLibraryName()).isEqualTo("Lettuce"); } @Test @@ -491,4 +485,19 @@ void getLibraryNameWithoutUpstreamDrivers() { assertThat(redisURI.getLibraryName()).isEqualTo("lettuce"); } + @Test + void setLibraryNameOverridesDriverInfo() { + RedisURI redisURI = RedisURI.create("redis://localhost"); + + // Set driver info first + DriverInfo driverInfo = DriverInfo.builder().addUpstreamDriver("spring-data-redis", "3.2.0").build(); + redisURI.setDriverInfo(driverInfo); + + // Then override with setLibraryName + redisURI.setLibraryName("my-custom-lib"); + + // setLibraryName should completely replace (no drivers) + assertThat(redisURI.getLibraryName()).isEqualTo("my-custom-lib"); + } + } From 91f1f8c82d474dc72536a5d20d382ac864797bf2 Mon Sep 17 00:00:00 2001 From: "viktoriya.kutsarova" Date: Thu, 4 Dec 2025 13:59:40 +0200 Subject: [PATCH 3/4] Fix PR comments --- src/main/java/io/lettuce/core/DriverInfo.java | 71 +++++++++++++------ .../io/lettuce/core/RedisURIUnitTests.java | 16 ----- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/src/main/java/io/lettuce/core/DriverInfo.java b/src/main/java/io/lettuce/core/DriverInfo.java index a97ccf9f56..afa3bc0926 100644 --- a/src/main/java/io/lettuce/core/DriverInfo.java +++ b/src/main/java/io/lettuce/core/DriverInfo.java @@ -1,22 +1,31 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + package io.lettuce.core; import io.lettuce.core.internal.LettuceAssert; +import io.lettuce.core.internal.LettuceSets; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; /** * Immutable class representing driver information for Redis client identification. *

- * This class is used to identify the client library and any upstream drivers (such as Spring Data Redis or Spring Session) - * when connecting to Redis. The information is sent via the {@code CLIENT SETINFO} command. + * This class is used to identify the client library and any upstream drivers (such as Spring Data Redis or Spring Session) when + * connecting to Redis. The information is sent via the {@code CLIENT SETINFO} command. *

* The formatted name follows the pattern: {@code name(driver1_vVersion1;driver2_vVersion2)} * * @author Viktoriya Kutsarova - * @since 6.5 + * @since 7.3 * @see RedisURI#setDriverInfo(DriverInfo) * @see RedisURI#getDriverInfo() * @see CLIENT SETINFO @@ -32,14 +41,10 @@ public final class DriverInfo implements Serializable { 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 + * Set of brace characters that are not allowed in driver names or versions. These characters are used to delimit the driver + * information in the formatted output and would break parsing. */ - 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 static final Set BRACES = LettuceSets.unmodifiableSet('(', ')', '[', ']', '{', '}'); private final String name; @@ -92,7 +97,7 @@ public String getFormattedName() { if (upstreamDrivers.isEmpty()) { return name; } - return name + "(" + String.join(";", upstreamDrivers) + ")"; + return String.format("%s(%s)", name, String.join(";", upstreamDrivers)); } /** @@ -152,14 +157,15 @@ public Builder name(String name) { * The driver name must follow Maven artifactId naming conventions: lowercase letters, digits, hyphens, and underscores * only, starting with a lowercase letter. *

- * The driver version must follow semantic versioning (semver.org). + * Both values must not contain spaces, newlines, non-printable characters, or brace characters as these would violate + * the format of the Redis CLIENT LIST reply. * * @param driverName the name of the upstream driver (e.g., "spring-data-redis"), must not be {@code null} * @param driverVersion the version of the upstream driver (e.g., "3.2.0"), must not be {@code null} * @return this builder * @throws IllegalArgumentException if the driver name or version is {@code null} or has invalid format * @see Maven Naming Conventions - * @see Semantic Versioning + * @see CLIENT SETINFO */ public Builder addUpstreamDriver(String driverName, String driverVersion) { LettuceAssert.notNull(driverName, "Driver name must not be null"); @@ -186,12 +192,16 @@ public DriverInfo build() { * 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}). + *

+ * Additionally validates Redis CLIENT LIST constraints: no spaces, newlines, non-printable characters, or braces. * * @param driverName the driver name to validate * @throws IllegalArgumentException if the driver name does not follow the expected naming conventions * @see Maven Naming Conventions + * @see CLIENT SETINFO */ private static void validateDriverName(String driverName) { + validateNoInvalidCharacters(driverName, "Driver name"); 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')"); @@ -199,20 +209,35 @@ private static void validateDriverName(String driverName) { } /** - * 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} + * Validates that the driver version does not contain characters that would violate the format of the Redis CLIENT LIST + * reply: spaces, newlines, non-printable characters, or brace characters. * * @param driverVersion the driver version to validate - * @throws IllegalArgumentException if the driver version does not follow semantic versioning - * @see Semantic Versioning 2.0.0 + * @throws IllegalArgumentException if the driver version contains invalid characters + * @see CLIENT SETINFO */ 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')"); + validateNoInvalidCharacters(driverVersion, "Driver version"); + } + + /** + * Validates that the value does not contain characters that would violate the format of the Redis CLIENT LIST reply: + * non-printable characters, spaces, or brace characters. + *

+ * Only printable ASCII characters (0x21-0x7E, i.e., '!' to '~') are allowed, excluding braces. + * + * @param value the value to validate + * @param fieldName the name of the field for error messages + * @throws IllegalArgumentException if the value contains invalid characters + * @see CLIENT SETINFO + */ + private static void validateNoInvalidCharacters(String value, String fieldName) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '!' || c > '~' || BRACES.contains(c)) { + throw new IllegalArgumentException( + fieldName + " must not contain spaces, newlines, non-printable characters, or braces"); + } } } diff --git a/src/test/java/io/lettuce/core/RedisURIUnitTests.java b/src/test/java/io/lettuce/core/RedisURIUnitTests.java index 2644f43a59..e47e38fad6 100644 --- a/src/test/java/io/lettuce/core/RedisURIUnitTests.java +++ b/src/test/java/io/lettuce/core/RedisURIUnitTests.java @@ -440,22 +440,6 @@ void driverInfoBuilderInvalidNameShouldFail() { .isInstanceOf(IllegalArgumentException.class); } - @Test - void driverInfoBuilderInvalidVersionShouldFail() { - // Version without patch number - assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "1.0")) - .isInstanceOf(IllegalArgumentException.class); - // Version with leading zeros - assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "01.0.0")) - .isInstanceOf(IllegalArgumentException.class); - // Version with invalid characters - assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "1.0.0@beta")) - .isInstanceOf(IllegalArgumentException.class); - // Version with spaces - assertThatThrownBy(() -> DriverInfo.builder().addUpstreamDriver("example-driver", "1.0 beta")) - .isInstanceOf(IllegalArgumentException.class); - } - @Test void driverInfoBuilderValidFormats() { // Valid Maven artifactId formats (lowercase letters, digits, hyphens) From 289e840fb4c1e1573c8201fda38fde3490291720 Mon Sep 17 00:00:00 2001 From: "viktoriya.kutsarova" Date: Thu, 4 Dec 2025 14:59:44 +0200 Subject: [PATCH 4/4] Update since tag --- src/main/java/io/lettuce/core/DriverInfo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/lettuce/core/DriverInfo.java b/src/main/java/io/lettuce/core/DriverInfo.java index afa3bc0926..87ef85a585 100644 --- a/src/main/java/io/lettuce/core/DriverInfo.java +++ b/src/main/java/io/lettuce/core/DriverInfo.java @@ -25,7 +25,7 @@ * The formatted name follows the pattern: {@code name(driver1_vVersion1;driver2_vVersion2)} * * @author Viktoriya Kutsarova - * @since 7.3 + * @since 7.2 * @see RedisURI#setDriverInfo(DriverInfo) * @see RedisURI#getDriverInfo() * @see CLIENT SETINFO