Skip to main content

redis_universal_client/
lib.rs

1#![doc = include_str!("../README.md")]
2
3#[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
4use redis::TlsMode;
5use redis::{
6    Client, ErrorKind, RedisConnectionInfo, RedisError, RedisResult, cluster::ClusterClient,
7};
8
9/// A universal Redis client that works with both standalone Redis and Redis Cluster.
10///
11/// Wraps either a [`redis::Client`] or a [`redis::cluster::ClusterClient`], similar to
12/// go-redis's `UniversalClient`.
13///
14/// # Examples
15///
16/// ```no_run
17/// use redis::AsyncCommands;
18/// use redis_universal_client::UniversalClient;
19///
20/// # async fn example() -> redis::RedisResult<()> {
21/// // Standalone Redis
22/// let client = UniversalClient::open(vec!["redis://127.0.0.1:6379"])?;
23/// let mut conn = client.get_connection().await?;
24/// conn.set::<_, _, ()>("key", "value").await?;
25/// let val: String = conn.get("key").await?;
26///
27/// // Redis Cluster (multiple addresses)
28/// let client = UniversalClient::open(vec![
29///     "redis://127.0.0.1:7000",
30///     "redis://127.0.0.1:7001",
31/// ])?;
32/// let mut conn = client.get_connection().await?;
33/// # Ok(())
34/// # }
35/// ```
36#[derive(Clone)]
37pub enum UniversalClient {
38    Client(Client),
39    Cluster(ClusterClient),
40}
41
42impl UniversalClient {
43    pub async fn get_connection(&self) -> RedisResult<UniversalConnection> {
44        match self {
45            Self::Client(cli) => cli
46                .get_multiplexed_async_connection()
47                .await
48                .map(UniversalConnection::Client),
49            Self::Cluster(cli) => cli
50                .get_async_connection()
51                .await
52                .map(|c| UniversalConnection::Cluster(Box::new(c))),
53        }
54    }
55
56    /// Creates a [`UniversalClient`] from a list of addresses.
57    ///
58    /// - 1 address: creates a standalone [`redis::Client`]
59    /// - Multiple addresses: creates a [`redis::cluster::ClusterClient`]
60    ///
61    /// To force cluster mode with a single address, use [`UniversalBuilder`] instead.
62    pub fn open<T: redis::IntoConnectionInfo + Clone>(
63        addrs: Vec<T>,
64    ) -> RedisResult<UniversalClient> {
65        let mut addrs = addrs;
66
67        if addrs.is_empty() {
68            return Err(RedisError::from((
69                ErrorKind::InvalidClientConfig,
70                "No address specified",
71            )));
72        }
73
74        if addrs.len() == 1 {
75            Client::open(addrs.remove(0)).map(Self::Client)
76        } else {
77            ClusterClient::new(addrs).map(Self::Cluster)
78        }
79    }
80}
81
82/// Builder for [`UniversalClient`] with explicit control over cluster mode and credentials.
83///
84/// Unlike [`UniversalClient::open`], the builder lets you force cluster mode
85/// regardless of the number of addresses, and set ACL username/password
86/// programmatically rather than embedding them in the URL.
87///
88/// # Examples
89///
90/// ```no_run
91/// use redis_universal_client::UniversalBuilder;
92///
93/// # fn example() -> redis::RedisResult<()> {
94/// // Force cluster mode with a single address
95/// let client = UniversalBuilder::new(vec!["redis://127.0.0.1:7000".to_string()])
96///     .cluster(true)
97///     .build()?;
98///
99/// // Standalone Redis with ACL credentials
100/// let client = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
101///     .username("alice")
102///     .password("secret")
103///     .build()?;
104/// # Ok(())
105/// # }
106/// ```
107pub struct UniversalBuilder<T> {
108    addrs: Vec<T>,
109    cluster: bool,
110    username: Option<String>,
111    password: Option<String>,
112    #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
113    tls: Option<TlsMode>,
114}
115
116impl<T> UniversalBuilder<T> {
117    pub fn new(addrs: Vec<T>) -> UniversalBuilder<T> {
118        UniversalBuilder {
119            addrs,
120            cluster: false,
121            username: None,
122            password: None,
123            #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
124            tls: None,
125        }
126    }
127
128    pub fn cluster(mut self, flag: bool) -> UniversalBuilder<T> {
129        self.cluster = flag;
130        self
131    }
132
133    /// Set the ACL username for authentication (Redis 6.0+).
134    pub fn username(mut self, username: impl Into<String>) -> UniversalBuilder<T> {
135        self.username = Some(username.into());
136        self
137    }
138
139    /// Set the password for authentication.
140    pub fn password(mut self, password: impl Into<String>) -> UniversalBuilder<T> {
141        self.password = Some(password.into());
142        self
143    }
144
145    /// Enable TLS. Use [`TlsMode::Secure`] to verify certificates (recommended)
146    /// or [`TlsMode::Insecure`] to skip verification.
147    ///
148    /// Requires the `tls-native-tls` or `tls-rustls` feature.
149    #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
150    pub fn tls(mut self, mode: TlsMode) -> UniversalBuilder<T> {
151        self.tls = Some(mode);
152        self
153    }
154
155    pub fn build(self) -> RedisResult<UniversalClient>
156    where
157        T: redis::IntoConnectionInfo + Clone,
158    {
159        let UniversalBuilder {
160            mut addrs,
161            cluster,
162            username,
163            password,
164            #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
165            tls,
166        } = self;
167
168        if addrs.is_empty() {
169            return Err(RedisError::from((
170                ErrorKind::InvalidClientConfig,
171                "No address specified",
172            )));
173        }
174
175        if cluster {
176            let mut builder = ClusterClient::builder(addrs);
177            if let Some(u) = username {
178                builder = builder.username(u);
179            }
180            if let Some(p) = password {
181                builder = builder.password(p);
182            }
183            #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
184            if let Some(mode) = tls {
185                builder = builder.tls(mode);
186            }
187            builder.build().map(UniversalClient::Cluster)
188        } else if username.is_some() || password.is_some() || {
189            #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
190            {
191                tls.is_some()
192            }
193            #[cfg(not(any(feature = "tls-native-tls", feature = "tls-rustls")))]
194            {
195                false
196            }
197        } {
198            let conn_info = addrs.remove(0).into_connection_info()?;
199            let orig = conn_info.redis_settings();
200            let mut redis_info = RedisConnectionInfo::default()
201                .set_db(orig.db())
202                .set_protocol(orig.protocol());
203            if let Some(u) = username {
204                redis_info = redis_info.set_username(u);
205            }
206            if let Some(p) = password {
207                redis_info = redis_info.set_password(p);
208            }
209            let conn_info = conn_info.set_redis_settings(redis_info);
210            #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
211            let conn_info = if let Some(mode) = tls {
212                apply_tls_to_conn_info(conn_info, mode)?
213            } else {
214                conn_info
215            };
216            Client::open(conn_info).map(UniversalClient::Client)
217        } else {
218            Client::open(addrs.remove(0)).map(UniversalClient::Client)
219        }
220    }
221}
222
223/// Converts a `ConnectionInfo` with a plain TCP address to TLS by replacing
224/// `ConnectionAddr::Tcp` with `ConnectionAddr::TcpTls`.
225///
226/// If the address is already TLS or is a Unix socket, it is left unchanged.
227#[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
228fn apply_tls_to_conn_info(
229    conn_info: redis::ConnectionInfo,
230    mode: TlsMode,
231) -> RedisResult<redis::ConnectionInfo> {
232    let insecure = mode == TlsMode::Insecure;
233    let new_addr = match conn_info.addr() {
234        redis::ConnectionAddr::Tcp(host, port) => redis::ConnectionAddr::TcpTls {
235            host: host.clone(),
236            port: *port,
237            insecure,
238            tls_params: None,
239        },
240        // Already TLS or Unix socket — leave as-is
241        other => other.clone(),
242    };
243    Ok(conn_info.set_addr(new_addr))
244}
245
246/// Async multiplexed connection for both standalone and cluster Redis.
247///
248/// Wraps either a [`redis::aio::MultiplexedConnection`] or a
249/// [`redis::cluster_async::ClusterConnection`]. Implements [`redis::aio::ConnectionLike`],
250/// so all [`redis::AsyncCommands`] work transparently.
251///
252/// Both variants are `Clone + Send + Sync`.
253#[derive(Clone)]
254pub enum UniversalConnection {
255    Client(redis::aio::MultiplexedConnection),
256    Cluster(Box<redis::cluster_async::ClusterConnection>),
257}
258
259#[cfg(test)]
260impl UniversalClient {
261    fn is_client(&self) -> bool {
262        matches!(self, Self::Client(_))
263    }
264
265    fn is_cluster(&self) -> bool {
266        matches!(self, Self::Cluster(_))
267    }
268}
269
270impl redis::aio::ConnectionLike for UniversalConnection {
271    fn req_packed_command<'a>(
272        &'a mut self,
273        cmd: &'a redis::Cmd,
274    ) -> redis::RedisFuture<'a, redis::Value> {
275        match self {
276            Self::Client(conn) => conn.req_packed_command(cmd),
277            Self::Cluster(conn) => conn.req_packed_command(cmd),
278        }
279    }
280
281    fn req_packed_commands<'a>(
282        &'a mut self,
283        cmd: &'a redis::Pipeline,
284        offset: usize,
285        count: usize,
286    ) -> redis::RedisFuture<'a, Vec<redis::Value>> {
287        match self {
288            Self::Client(conn) => conn.req_packed_commands(cmd, offset, count),
289            Self::Cluster(conn) => conn.req_packed_commands(cmd, offset, count),
290        }
291    }
292
293    fn get_db(&self) -> i64 {
294        match self {
295            Self::Client(conn) => conn.get_db(),
296            Self::Cluster(conn) => conn.get_db(),
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn open_empty_addrs_error() {
307        let result = UniversalClient::open(Vec::<String>::new());
308        assert!(result.is_err());
309    }
310
311    #[test]
312    fn open_single_addr_is_client() {
313        let result = UniversalClient::open(vec!["redis://127.0.0.1:6379"]);
314        assert!(result.unwrap().is_client());
315    }
316
317    #[test]
318    fn open_multiple_addrs_is_cluster() {
319        let result =
320            UniversalClient::open(vec!["redis://127.0.0.1:7000", "redis://127.0.0.1:7001"]);
321        assert!(result.unwrap().is_cluster());
322    }
323
324    #[test]
325    fn builder_empty_addrs_error() {
326        let result = UniversalBuilder::new(Vec::<String>::new()).build();
327        assert!(result.is_err());
328    }
329
330    #[test]
331    fn builder_cluster_true_forces_cluster() {
332        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
333            .cluster(true)
334            .build();
335        assert!(result.unwrap().is_cluster());
336    }
337
338    #[test]
339    fn builder_cluster_false_uses_first_addr() {
340        let result = UniversalBuilder::new(vec![
341            "redis://127.0.0.1:7000".to_string(),
342            "redis://127.0.0.1:7001".to_string(),
343        ])
344        .cluster(false)
345        .build();
346        assert!(result.unwrap().is_client());
347    }
348
349    #[test]
350    fn builder_with_password_is_client() {
351        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
352            .password("secret")
353            .build();
354        assert!(result.unwrap().is_client());
355    }
356
357    #[test]
358    fn builder_with_username_and_password_is_client() {
359        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
360            .username("alice")
361            .password("secret")
362            .build();
363        assert!(result.unwrap().is_client());
364    }
365
366    #[test]
367    fn builder_with_password_cluster_is_cluster() {
368        let result = UniversalBuilder::new(vec![
369            "redis://127.0.0.1:7000".to_string(),
370            "redis://127.0.0.1:7001".to_string(),
371        ])
372        .password("secret")
373        .cluster(true)
374        .build();
375        assert!(result.unwrap().is_cluster());
376    }
377
378    #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
379    #[test]
380    fn builder_tls_secure_is_client() {
381        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6380".to_string()])
382            .tls(redis::TlsMode::Secure)
383            .build();
384        assert!(result.unwrap().is_client());
385    }
386
387    #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
388    #[test]
389    fn builder_tls_insecure_is_client() {
390        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6380".to_string()])
391            .tls(redis::TlsMode::Insecure)
392            .build();
393        assert!(result.unwrap().is_client());
394    }
395
396    #[cfg(any(feature = "tls-native-tls", feature = "tls-rustls"))]
397    #[test]
398    fn builder_tls_cluster_is_cluster() {
399        let result = UniversalBuilder::new(vec![
400            "redis://127.0.0.1:7000".to_string(),
401            "redis://127.0.0.1:7001".to_string(),
402        ])
403        .tls(redis::TlsMode::Secure)
404        .cluster(true)
405        .build();
406        assert!(result.unwrap().is_cluster());
407    }
408}