Skip to main content

redis_universal_client/
lib.rs

1use redis::{
2    Client, ErrorKind, RedisConnectionInfo, RedisError, RedisResult, cluster::ClusterClient,
3};
4
5/// A universal Redis client that works with both standalone Redis and Redis Cluster.
6///
7/// Wraps either a [`redis::Client`] or a [`redis::cluster::ClusterClient`], similar to
8/// go-redis's `UniversalClient`.
9///
10/// # Examples
11///
12/// ```no_run
13/// use redis::AsyncCommands;
14/// use redis_universal_client::UniversalClient;
15///
16/// # async fn example() -> redis::RedisResult<()> {
17/// // Standalone Redis
18/// let client = UniversalClient::open(vec!["redis://127.0.0.1:6379"])?;
19/// let mut conn = client.get_connection().await?;
20/// conn.set::<_, _, ()>("key", "value").await?;
21/// let val: String = conn.get("key").await?;
22///
23/// // Redis Cluster (multiple addresses)
24/// let client = UniversalClient::open(vec![
25///     "redis://127.0.0.1:7000",
26///     "redis://127.0.0.1:7001",
27/// ])?;
28/// let mut conn = client.get_connection().await?;
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Clone)]
33pub enum UniversalClient {
34    Client(Client),
35    Cluster(ClusterClient),
36}
37
38impl UniversalClient {
39    pub async fn get_connection(&self) -> RedisResult<UniversalConnection> {
40        match self {
41            Self::Client(cli) => cli
42                .get_multiplexed_async_connection()
43                .await
44                .map(UniversalConnection::Client),
45            Self::Cluster(cli) => cli
46                .get_async_connection()
47                .await
48                .map(|c| UniversalConnection::Cluster(Box::new(c))),
49        }
50    }
51
52    /// Creates a [`UniversalClient`] from a list of addresses.
53    ///
54    /// - 1 address: creates a standalone [`redis::Client`]
55    /// - Multiple addresses: creates a [`redis::cluster::ClusterClient`]
56    ///
57    /// To force cluster mode with a single address, use [`UniversalBuilder`] instead.
58    pub fn open<T: redis::IntoConnectionInfo + Clone>(
59        addrs: Vec<T>,
60    ) -> RedisResult<UniversalClient> {
61        let mut addrs = addrs;
62
63        if addrs.is_empty() {
64            return Err(RedisError::from((
65                ErrorKind::InvalidClientConfig,
66                "No address specified",
67            )));
68        }
69
70        if addrs.len() == 1 {
71            Client::open(addrs.remove(0)).map(Self::Client)
72        } else {
73            ClusterClient::new(addrs).map(Self::Cluster)
74        }
75    }
76}
77
78/// Builder for [`UniversalClient`] with explicit control over cluster mode and credentials.
79///
80/// Unlike [`UniversalClient::open`], the builder lets you force cluster mode
81/// regardless of the number of addresses, and set ACL username/password
82/// programmatically rather than embedding them in the URL.
83///
84/// # Examples
85///
86/// ```no_run
87/// use redis_universal_client::UniversalBuilder;
88///
89/// # fn example() -> redis::RedisResult<()> {
90/// // Force cluster mode with a single address
91/// let client = UniversalBuilder::new(vec!["redis://127.0.0.1:7000".to_string()])
92///     .cluster(true)
93///     .build()?;
94///
95/// // Standalone Redis with ACL credentials
96/// let client = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
97///     .username("alice")
98///     .password("secret")
99///     .build()?;
100/// # Ok(())
101/// # }
102/// ```
103pub struct UniversalBuilder<T> {
104    addrs: Vec<T>,
105    cluster: bool,
106    username: Option<String>,
107    password: Option<String>,
108}
109
110impl<T> UniversalBuilder<T> {
111    pub fn new(addrs: Vec<T>) -> UniversalBuilder<T> {
112        UniversalBuilder {
113            addrs,
114            cluster: false,
115            username: None,
116            password: None,
117        }
118    }
119
120    pub fn cluster(mut self, flag: bool) -> UniversalBuilder<T> {
121        self.cluster = flag;
122        self
123    }
124
125    /// Set the ACL username for authentication (Redis 6.0+).
126    pub fn username(mut self, username: impl Into<String>) -> UniversalBuilder<T> {
127        self.username = Some(username.into());
128        self
129    }
130
131    /// Set the password for authentication.
132    pub fn password(mut self, password: impl Into<String>) -> UniversalBuilder<T> {
133        self.password = Some(password.into());
134        self
135    }
136
137    pub fn build(self) -> RedisResult<UniversalClient>
138    where
139        T: redis::IntoConnectionInfo + Clone,
140    {
141        let UniversalBuilder {
142            mut addrs,
143            cluster,
144            username,
145            password,
146        } = self;
147
148        if addrs.is_empty() {
149            return Err(RedisError::from((
150                ErrorKind::InvalidClientConfig,
151                "No address specified",
152            )));
153        }
154
155        if cluster {
156            let mut builder = ClusterClient::builder(addrs);
157            if let Some(u) = username {
158                builder = builder.username(u);
159            }
160            if let Some(p) = password {
161                builder = builder.password(p);
162            }
163            builder.build().map(UniversalClient::Cluster)
164        } else if username.is_some() || password.is_some() {
165            let conn_info = addrs.remove(0).into_connection_info()?;
166            let orig = conn_info.redis_settings();
167            let mut redis_info = RedisConnectionInfo::default()
168                .set_db(orig.db())
169                .set_protocol(orig.protocol());
170            if let Some(u) = username {
171                redis_info = redis_info.set_username(u);
172            }
173            if let Some(p) = password {
174                redis_info = redis_info.set_password(p);
175            }
176            let conn_info = conn_info.set_redis_settings(redis_info);
177            Client::open(conn_info).map(UniversalClient::Client)
178        } else {
179            Client::open(addrs.remove(0)).map(UniversalClient::Client)
180        }
181    }
182}
183
184/// Async multiplexed connection for both standalone and cluster Redis.
185///
186/// Wraps either a [`redis::aio::MultiplexedConnection`] or a
187/// [`redis::cluster_async::ClusterConnection`]. Implements [`redis::aio::ConnectionLike`],
188/// so all [`redis::AsyncCommands`] work transparently.
189///
190/// Both variants are `Clone + Send + Sync`.
191#[derive(Clone)]
192pub enum UniversalConnection {
193    Client(redis::aio::MultiplexedConnection),
194    Cluster(Box<redis::cluster_async::ClusterConnection>),
195}
196
197#[cfg(test)]
198impl UniversalClient {
199    fn is_client(&self) -> bool {
200        matches!(self, Self::Client(_))
201    }
202
203    fn is_cluster(&self) -> bool {
204        matches!(self, Self::Cluster(_))
205    }
206}
207
208impl redis::aio::ConnectionLike for UniversalConnection {
209    fn req_packed_command<'a>(
210        &'a mut self,
211        cmd: &'a redis::Cmd,
212    ) -> redis::RedisFuture<'a, redis::Value> {
213        match self {
214            Self::Client(conn) => conn.req_packed_command(cmd),
215            Self::Cluster(conn) => conn.req_packed_command(cmd),
216        }
217    }
218
219    fn req_packed_commands<'a>(
220        &'a mut self,
221        cmd: &'a redis::Pipeline,
222        offset: usize,
223        count: usize,
224    ) -> redis::RedisFuture<'a, Vec<redis::Value>> {
225        match self {
226            Self::Client(conn) => conn.req_packed_commands(cmd, offset, count),
227            Self::Cluster(conn) => conn.req_packed_commands(cmd, offset, count),
228        }
229    }
230
231    fn get_db(&self) -> i64 {
232        match self {
233            Self::Client(conn) => conn.get_db(),
234            Self::Cluster(conn) => conn.get_db(),
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn open_empty_addrs_error() {
245        let result = UniversalClient::open(Vec::<String>::new());
246        assert!(result.is_err());
247    }
248
249    #[test]
250    fn open_single_addr_is_client() {
251        let result = UniversalClient::open(vec!["redis://127.0.0.1:6379"]);
252        assert!(result.unwrap().is_client());
253    }
254
255    #[test]
256    fn open_multiple_addrs_is_cluster() {
257        let result =
258            UniversalClient::open(vec!["redis://127.0.0.1:7000", "redis://127.0.0.1:7001"]);
259        assert!(result.unwrap().is_cluster());
260    }
261
262    #[test]
263    fn builder_empty_addrs_error() {
264        let result = UniversalBuilder::new(Vec::<String>::new()).build();
265        assert!(result.is_err());
266    }
267
268    #[test]
269    fn builder_cluster_true_forces_cluster() {
270        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
271            .cluster(true)
272            .build();
273        assert!(result.unwrap().is_cluster());
274    }
275
276    #[test]
277    fn builder_cluster_false_uses_first_addr() {
278        let result = UniversalBuilder::new(vec![
279            "redis://127.0.0.1:7000".to_string(),
280            "redis://127.0.0.1:7001".to_string(),
281        ])
282        .cluster(false)
283        .build();
284        assert!(result.unwrap().is_client());
285    }
286
287    #[test]
288    fn builder_with_password_is_client() {
289        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
290            .password("secret")
291            .build();
292        assert!(result.unwrap().is_client());
293    }
294
295    #[test]
296    fn builder_with_username_and_password_is_client() {
297        let result = UniversalBuilder::new(vec!["redis://127.0.0.1:6379".to_string()])
298            .username("alice")
299            .password("secret")
300            .build();
301        assert!(result.unwrap().is_client());
302    }
303
304    #[test]
305    fn builder_with_password_cluster_is_cluster() {
306        let result = UniversalBuilder::new(vec![
307            "redis://127.0.0.1:7000".to_string(),
308            "redis://127.0.0.1:7001".to_string(),
309        ])
310        .password("secret")
311        .cluster(true)
312        .build();
313        assert!(result.unwrap().is_cluster());
314    }
315}