Skip to main content

camel_component_redis/
config.rs

1use camel_api::CamelError;
2use camel_endpoint::parse_uri;
3use std::str::FromStr;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum RedisCommand {
7    // String operations
8    Set,
9    Get,
10    Getset,
11    Setnx,
12    Setex,
13    Mget,
14    Mset,
15    Incr,
16    Incrby,
17    Decr,
18    Decrby,
19    Append,
20    Strlen,
21
22    // Key operations
23    Exists,
24    Del,
25    Expire,
26    Expireat,
27    Pexpire,
28    Pexpireat,
29    Ttl,
30    Keys,
31    Rename,
32    Renamenx,
33    Type,
34    Persist,
35    Move,
36    Sort,
37
38    // List operations
39    Lpush,
40    Rpush,
41    Lpushx,
42    Rpushx,
43    Lpop,
44    Rpop,
45    Blpop,
46    Brpop,
47    Llen,
48    Lrange,
49    Lindex,
50    Linsert,
51    Lset,
52    Lrem,
53    Ltrim,
54    Rpoplpush,
55
56    // Hash operations
57    Hset,
58    Hget,
59    Hsetnx,
60    Hmset,
61    Hmget,
62    Hdel,
63    Hexists,
64    Hlen,
65    Hkeys,
66    Hvals,
67    Hgetall,
68    Hincrby,
69
70    // Set operations
71    Sadd,
72    Srem,
73    Smembers,
74    Scard,
75    Sismember,
76    Spop,
77    Smove,
78    Sinter,
79    Sunion,
80    Sdiff,
81    Sinterstore,
82    Sunionstore,
83    Sdiffstore,
84    Srandmember,
85
86    // Sorted set operations
87    Zadd,
88    Zrem,
89    Zrange,
90    Zrevrange,
91    Zrank,
92    Zrevrank,
93    Zscore,
94    Zcard,
95    Zincrby,
96    Zcount,
97    Zrangebyscore,
98    Zrevrangebyscore,
99    Zremrangebyrank,
100    Zremrangebyscore,
101    Zunionstore,
102    Zinterstore,
103
104    // Pub/Sub operations
105    Publish,
106    Subscribe,
107    Psubscribe,
108
109    // Other operations
110    Ping,
111    Echo,
112}
113
114impl FromStr for RedisCommand {
115    type Err = CamelError;
116
117    fn from_str(s: &str) -> Result<Self, Self::Err> {
118        match s.to_uppercase().as_str() {
119            // String operations
120            "SET" => Ok(RedisCommand::Set),
121            "GET" => Ok(RedisCommand::Get),
122            "GETSET" => Ok(RedisCommand::Getset),
123            "SETNX" => Ok(RedisCommand::Setnx),
124            "SETEX" => Ok(RedisCommand::Setex),
125            "MGET" => Ok(RedisCommand::Mget),
126            "MSET" => Ok(RedisCommand::Mset),
127            "INCR" => Ok(RedisCommand::Incr),
128            "INCRBY" => Ok(RedisCommand::Incrby),
129            "DECR" => Ok(RedisCommand::Decr),
130            "DECRBY" => Ok(RedisCommand::Decrby),
131            "APPEND" => Ok(RedisCommand::Append),
132            "STRLEN" => Ok(RedisCommand::Strlen),
133
134            // Key operations
135            "EXISTS" => Ok(RedisCommand::Exists),
136            "DEL" => Ok(RedisCommand::Del),
137            "EXPIRE" => Ok(RedisCommand::Expire),
138            "EXPIREAT" => Ok(RedisCommand::Expireat),
139            "PEXPIRE" => Ok(RedisCommand::Pexpire),
140            "PEXPIREAT" => Ok(RedisCommand::Pexpireat),
141            "TTL" => Ok(RedisCommand::Ttl),
142            "KEYS" => Ok(RedisCommand::Keys),
143            "RENAME" => Ok(RedisCommand::Rename),
144            "RENAMENX" => Ok(RedisCommand::Renamenx),
145            "TYPE" => Ok(RedisCommand::Type),
146            "PERSIST" => Ok(RedisCommand::Persist),
147            "MOVE" => Ok(RedisCommand::Move),
148            "SORT" => Ok(RedisCommand::Sort),
149
150            // List operations
151            "LPUSH" => Ok(RedisCommand::Lpush),
152            "RPUSH" => Ok(RedisCommand::Rpush),
153            "LPUSHX" => Ok(RedisCommand::Lpushx),
154            "RPUSHX" => Ok(RedisCommand::Rpushx),
155            "LPOP" => Ok(RedisCommand::Lpop),
156            "RPOP" => Ok(RedisCommand::Rpop),
157            "BLPOP" => Ok(RedisCommand::Blpop),
158            "BRPOP" => Ok(RedisCommand::Brpop),
159            "LLEN" => Ok(RedisCommand::Llen),
160            "LRANGE" => Ok(RedisCommand::Lrange),
161            "LINDEX" => Ok(RedisCommand::Lindex),
162            "LINSERT" => Ok(RedisCommand::Linsert),
163            "LSET" => Ok(RedisCommand::Lset),
164            "LREM" => Ok(RedisCommand::Lrem),
165            "LTRIM" => Ok(RedisCommand::Ltrim),
166            "RPOPLPUSH" => Ok(RedisCommand::Rpoplpush),
167
168            // Hash operations
169            "HSET" => Ok(RedisCommand::Hset),
170            "HGET" => Ok(RedisCommand::Hget),
171            "HSETNX" => Ok(RedisCommand::Hsetnx),
172            "HMSET" => Ok(RedisCommand::Hmset),
173            "HMGET" => Ok(RedisCommand::Hmget),
174            "HDEL" => Ok(RedisCommand::Hdel),
175            "HEXISTS" => Ok(RedisCommand::Hexists),
176            "HLEN" => Ok(RedisCommand::Hlen),
177            "HKEYS" => Ok(RedisCommand::Hkeys),
178            "HVALS" => Ok(RedisCommand::Hvals),
179            "HGETALL" => Ok(RedisCommand::Hgetall),
180            "HINCRBY" => Ok(RedisCommand::Hincrby),
181
182            // Set operations
183            "SADD" => Ok(RedisCommand::Sadd),
184            "SREM" => Ok(RedisCommand::Srem),
185            "SMEMBERS" => Ok(RedisCommand::Smembers),
186            "SCARD" => Ok(RedisCommand::Scard),
187            "SISMEMBER" => Ok(RedisCommand::Sismember),
188            "SPOP" => Ok(RedisCommand::Spop),
189            "SMOVE" => Ok(RedisCommand::Smove),
190            "SINTER" => Ok(RedisCommand::Sinter),
191            "SUNION" => Ok(RedisCommand::Sunion),
192            "SDIFF" => Ok(RedisCommand::Sdiff),
193            "SINTERSTORE" => Ok(RedisCommand::Sinterstore),
194            "SUNIONSTORE" => Ok(RedisCommand::Sunionstore),
195            "SDIFFSTORE" => Ok(RedisCommand::Sdiffstore),
196            "SRANDMEMBER" => Ok(RedisCommand::Srandmember),
197
198            // Sorted set operations
199            "ZADD" => Ok(RedisCommand::Zadd),
200            "ZREM" => Ok(RedisCommand::Zrem),
201            "ZRANGE" => Ok(RedisCommand::Zrange),
202            "ZREVRANGE" => Ok(RedisCommand::Zrevrange),
203            "ZRANK" => Ok(RedisCommand::Zrank),
204            "ZREVRANK" => Ok(RedisCommand::Zrevrank),
205            "ZSCORE" => Ok(RedisCommand::Zscore),
206            "ZCARD" => Ok(RedisCommand::Zcard),
207            "ZINCRBY" => Ok(RedisCommand::Zincrby),
208            "ZCOUNT" => Ok(RedisCommand::Zcount),
209            "ZRANGEBYSCORE" => Ok(RedisCommand::Zrangebyscore),
210            "ZREVRANGEBYSCORE" => Ok(RedisCommand::Zrevrangebyscore),
211            "ZREMRANGEBYRANK" => Ok(RedisCommand::Zremrangebyrank),
212            "ZREMRANGEBYSCORE" => Ok(RedisCommand::Zremrangebyscore),
213            "ZUNIONSTORE" => Ok(RedisCommand::Zunionstore),
214            "ZINTERSTORE" => Ok(RedisCommand::Zinterstore),
215
216            // Pub/Sub operations
217            "PUBLISH" => Ok(RedisCommand::Publish),
218            "SUBSCRIBE" => Ok(RedisCommand::Subscribe),
219            "PSUBSCRIBE" => Ok(RedisCommand::Psubscribe),
220
221            // Other operations
222            "PING" => Ok(RedisCommand::Ping),
223            "ECHO" => Ok(RedisCommand::Echo),
224
225            _ => Err(CamelError::InvalidUri(format!(
226                "Unknown Redis command: {}",
227                s
228            ))),
229        }
230    }
231}
232
233// --- RedisConfig (global defaults, no serde) ---
234
235/// Global Redis configuration defaults.
236///
237/// This struct holds component-level defaults that can be set via YAML config
238/// and applied to endpoint configurations when specific values aren't provided.
239#[derive(Debug, Clone, PartialEq)]
240pub struct RedisConfig {
241    pub host: String,
242    pub port: u16,
243}
244
245impl Default for RedisConfig {
246    fn default() -> Self {
247        Self {
248            host: "localhost".to_string(),
249            port: 6379,
250        }
251    }
252}
253
254impl RedisConfig {
255    pub fn with_host(mut self, v: impl Into<String>) -> Self {
256        self.host = v.into();
257        self
258    }
259
260    pub fn with_port(mut self, v: u16) -> Self {
261        self.port = v;
262        self
263    }
264}
265
266// --- RedisEndpointConfig (parsed from URI) ---
267
268/// Configuration parsed from a Redis URI.
269///
270/// Format: `redis://host:port?command=GET&...` or `redis://?command=GET` (no host/port)
271///
272/// # Fields with Global Defaults (Option<T>)
273///
274/// These fields can be set via global defaults in `Camel.toml`. They are `Option<T>`
275/// to distinguish between "not set by URI" (`None`) and "explicitly set by URI" (`Some(v)`).
276/// After calling `apply_defaults()` + `resolve_defaults()`, all are guaranteed `Some`.
277///
278/// - `host` - Redis server hostname
279/// - `port` - Redis server port
280///
281/// # Fields Without Global Defaults
282///
283/// These fields are per-endpoint only and have no global defaults:
284///
285/// - `command` - Redis command to execute (default: SET)
286/// - `channels` - Channels for pub/sub operations (default: empty)
287/// - `key` - Key for operations that require it (default: None)
288/// - `timeout` - Timeout in seconds for blocking operations (default: 1)
289/// - `password` - Redis password for authentication (default: None)
290/// - `db` - Redis database number (default: 0)
291#[derive(Debug, Clone)]
292pub struct RedisEndpointConfig {
293    /// Redis server hostname. `None` if not set in URI.
294    /// Filled by `apply_defaults()` from global config, then `resolve_defaults()`.
295    pub host: Option<String>,
296
297    /// Redis server port. `None` if not set in URI.
298    /// Filled by `apply_defaults()` from global config, then `resolve_defaults()`.
299    pub port: Option<u16>,
300
301    /// Redis command to execute. Default: SET.
302    pub command: RedisCommand,
303
304    /// Channels for pub/sub operations. Default: empty.
305    pub channels: Vec<String>,
306
307    /// Key for operations that require it. Default: None.
308    pub key: Option<String>,
309
310    /// Timeout in seconds for blocking operations. Default: 1.
311    pub timeout: u64,
312
313    /// Redis password for authentication. Default: None.
314    pub password: Option<String>,
315
316    /// Redis database number. Default: 0.
317    pub db: u8,
318}
319
320impl RedisEndpointConfig {
321    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
322        let parts = parse_uri(uri)?;
323
324        if parts.scheme != "redis" {
325            return Err(CamelError::InvalidUri(format!(
326                "expected scheme 'redis', got '{}'",
327                parts.scheme
328            )));
329        }
330
331        // Parse host and port from path (format: //host:port or //host or empty)
332        // Use Option to distinguish "not set in URI" from "set in URI"
333        let (host, port) = if parts.path.starts_with("//") {
334            let path = &parts.path[2..]; // Remove leading //
335            if path.is_empty() {
336                // redis://?command=GET → no host, no port
337                (None, None)
338            } else {
339                let (host_part, port_part) = match path.split_once(':') {
340                    Some((h, p)) => (h, Some(p)),
341                    None => (path, None),
342                };
343                let host = Some(host_part.to_string());
344                let port = port_part.and_then(|p| p.parse().ok());
345                (host, port)
346            }
347        } else {
348            // No // prefix means no host/port in URI
349            (None, None)
350        };
351
352        // Parse command (default to SET)
353        let command = parts
354            .params
355            .get("command")
356            .map(|s| RedisCommand::from_str(s))
357            .transpose()?
358            .unwrap_or(RedisCommand::Set);
359
360        // Parse channels (comma-separated)
361        let channels = parts
362            .params
363            .get("channels")
364            .map(|s| s.split(',').map(String::from).collect())
365            .unwrap_or_default();
366
367        // Parse key
368        let key = parts.params.get("key").cloned();
369
370        // Parse timeout (default to 1 second)
371        let timeout = parts
372            .params
373            .get("timeout")
374            .and_then(|s| s.parse().ok())
375            .unwrap_or(1);
376
377        // Parse password
378        let password = parts.params.get("password").cloned();
379
380        // Parse db (default to 0)
381        let db = parts
382            .params
383            .get("db")
384            .and_then(|s| s.parse().ok())
385            .unwrap_or(0);
386
387        Ok(Self {
388            host,
389            port,
390            command,
391            channels,
392            key,
393            timeout,
394            password,
395            db,
396        })
397    }
398
399    /// Apply global defaults to any `None` fields.
400    ///
401    /// This method fills in default values from the provided `RedisConfig` for
402    /// fields that are `None` (not set in URI). It's intended to be called after
403    /// parsing a URI when global component defaults should be applied.
404    pub fn apply_defaults(&mut self, defaults: &RedisConfig) {
405        if self.host.is_none() {
406            self.host = Some(defaults.host.clone());
407        }
408        if self.port.is_none() {
409            self.port = Some(defaults.port);
410        }
411    }
412
413    /// Resolve any remaining `None` fields to hardcoded defaults.
414    ///
415    /// This should be called after `apply_defaults()` to ensure all fields
416    /// that can have global defaults are guaranteed to be `Some`.
417    pub fn resolve_defaults(&mut self) {
418        let defaults = RedisConfig::default();
419        if self.host.is_none() {
420            self.host = Some(defaults.host);
421        }
422        if self.port.is_none() {
423            self.port = Some(defaults.port);
424        }
425    }
426
427    /// Build the Redis connection URL.
428    ///
429    /// After `resolve_defaults()`, host and port are guaranteed `Some`.
430    pub fn redis_url(&self) -> String {
431        let host = self.host.as_deref().unwrap_or("localhost");
432        let port = self.port.unwrap_or(6379);
433
434        if let Some(password) = &self.password {
435            format!("redis://:{}@{}:{}/{}", password, host, port, self.db)
436        } else {
437            format!("redis://{}:{}/{}", host, port, self.db)
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_config_defaults() {
448        let c = RedisEndpointConfig::from_uri("redis://localhost:6379").unwrap();
449        assert_eq!(c.host, Some("localhost".to_string()));
450        assert_eq!(c.port, Some(6379));
451        assert_eq!(c.command, RedisCommand::Set);
452        assert!(c.channels.is_empty());
453        assert!(c.key.is_none());
454        assert_eq!(c.timeout, 1);
455        assert!(c.password.is_none());
456        assert_eq!(c.db, 0);
457    }
458
459    #[test]
460    fn test_config_no_host_port() {
461        // URI with no host/port in path
462        let c = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
463        assert_eq!(c.host, None);
464        assert_eq!(c.port, None);
465        assert_eq!(c.command, RedisCommand::Get);
466    }
467
468    #[test]
469    fn test_config_host_only() {
470        // URI with host but no port
471        let c = RedisEndpointConfig::from_uri("redis://redis-server?command=GET").unwrap();
472        assert_eq!(c.host, Some("redis-server".to_string()));
473        assert_eq!(c.port, None);
474    }
475
476    #[test]
477    fn test_config_host_and_port() {
478        let c = RedisEndpointConfig::from_uri("redis://localhost:6380?command=GET").unwrap();
479        assert_eq!(c.host, Some("localhost".to_string()));
480        assert_eq!(c.port, Some(6380));
481        assert_eq!(c.command, RedisCommand::Get);
482    }
483
484    #[test]
485    fn test_config_wrong_scheme() {
486        assert!(RedisEndpointConfig::from_uri("http://localhost:6379").is_err());
487    }
488
489    #[test]
490    fn test_config_command() {
491        let c = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET").unwrap();
492        assert_eq!(c.command, RedisCommand::Get);
493    }
494
495    #[test]
496    fn test_config_subscribe() {
497        let c = RedisEndpointConfig::from_uri(
498            "redis://localhost:6379?command=SUBSCRIBE&channels=foo,bar",
499        )
500        .unwrap();
501        assert_eq!(c.command, RedisCommand::Subscribe);
502        assert_eq!(c.channels, vec!["foo".to_string(), "bar".to_string()]);
503    }
504
505    #[test]
506    fn test_config_blpop() {
507        let c = RedisEndpointConfig::from_uri(
508            "redis://localhost:6379?command=BLPOP&key=jobs&timeout=5",
509        )
510        .unwrap();
511        assert_eq!(c.command, RedisCommand::Blpop);
512        assert_eq!(c.key, Some("jobs".to_string()));
513        assert_eq!(c.timeout, 5);
514    }
515
516    #[test]
517    fn test_config_auth_db() {
518        let c =
519            RedisEndpointConfig::from_uri("redis://localhost:6379?password=secret&db=2").unwrap();
520        assert_eq!(c.password, Some("secret".to_string()));
521        assert_eq!(c.db, 2);
522    }
523
524    #[test]
525    fn test_redis_url() {
526        let mut c =
527            RedisEndpointConfig::from_uri("redis://localhost:6379?password=secret&db=2").unwrap();
528        c.resolve_defaults();
529        assert_eq!(c.redis_url(), "redis://:secret@localhost:6379/2");
530    }
531
532    #[test]
533    fn test_redis_url_no_auth() {
534        let mut c = RedisEndpointConfig::from_uri("redis://localhost:6379").unwrap();
535        c.resolve_defaults();
536        assert_eq!(c.redis_url(), "redis://localhost:6379/0");
537    }
538
539    #[test]
540    fn test_command_from_str() {
541        assert!(RedisCommand::from_str("SET").is_ok());
542        assert_eq!(RedisCommand::from_str("SET").unwrap(), RedisCommand::Set);
543        assert!(RedisCommand::from_str("get").is_ok());
544        assert_eq!(RedisCommand::from_str("get").unwrap(), RedisCommand::Get);
545        assert!(RedisCommand::from_str("UNKNOWN").is_err());
546    }
547
548    // --- RedisConfig tests ---
549
550    #[test]
551    fn test_redis_config_defaults() {
552        let cfg = RedisConfig::default();
553        assert_eq!(cfg.host, "localhost");
554        assert_eq!(cfg.port, 6379);
555    }
556
557    #[test]
558    fn test_redis_config_builder() {
559        let cfg = RedisConfig::default()
560            .with_host("redis-prod")
561            .with_port(6380);
562        assert_eq!(cfg.host, "redis-prod");
563        assert_eq!(cfg.port, 6380);
564    }
565
566    // --- apply_defaults tests ---
567
568    #[test]
569    fn test_apply_defaults_fills_none_fields() {
570        let mut config = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
571        assert_eq!(config.host, None);
572        assert_eq!(config.port, None);
573
574        let defaults = RedisConfig::default()
575            .with_host("redis-server")
576            .with_port(6380);
577        config.apply_defaults(&defaults);
578
579        assert_eq!(config.host, Some("redis-server".to_string()));
580        assert_eq!(config.port, Some(6380));
581    }
582
583    #[test]
584    fn test_apply_defaults_preserves_values() {
585        let mut config = RedisEndpointConfig::from_uri("redis://custom:7000?command=GET").unwrap();
586        assert_eq!(config.host, Some("custom".to_string()));
587        assert_eq!(config.port, Some(7000));
588
589        let defaults = RedisConfig::default()
590            .with_host("should-not-override")
591            .with_port(9999);
592        config.apply_defaults(&defaults);
593
594        // Existing values should be preserved
595        assert_eq!(config.host, Some("custom".to_string()));
596        assert_eq!(config.port, Some(7000));
597    }
598
599    #[test]
600    fn test_apply_defaults_partial_none() {
601        // Host set, port not set
602        let mut config = RedisEndpointConfig::from_uri("redis://myhost?command=GET").unwrap();
603        assert_eq!(config.host, Some("myhost".to_string()));
604        assert_eq!(config.port, None);
605
606        let defaults = RedisConfig::default()
607            .with_host("default-host")
608            .with_port(6380);
609        config.apply_defaults(&defaults);
610
611        // Host preserved, port filled
612        assert_eq!(config.host, Some("myhost".to_string()));
613        assert_eq!(config.port, Some(6380));
614    }
615
616    // --- resolve_defaults tests ---
617
618    #[test]
619    fn test_resolve_defaults_fills_remaining_nones() {
620        let mut config = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
621        assert_eq!(config.host, None);
622        assert_eq!(config.port, None);
623
624        config.resolve_defaults();
625
626        assert_eq!(config.host, Some("localhost".to_string()));
627        assert_eq!(config.port, Some(6379));
628    }
629
630    #[test]
631    fn test_resolve_defaults_preserves_existing() {
632        let mut config = RedisEndpointConfig::from_uri("redis://custom:7000?command=GET").unwrap();
633        config.resolve_defaults();
634
635        // Already set values should be preserved
636        assert_eq!(config.host, Some("custom".to_string()));
637        assert_eq!(config.port, Some(7000));
638    }
639
640    // --- Critical test: catches the original defect where from_uri filled absent
641    //     params with hardcoded defaults, preventing apply_defaults from working.
642    #[test]
643    fn test_apply_defaults_with_from_uri_no_host_param() {
644        // Parse URI without host/port
645        let mut ep = RedisEndpointConfig::from_uri("redis://?command=GET").unwrap();
646        // At this point, ep.host and ep.port should be None
647        assert!(ep.host.is_none(), "host should be None when not in URI");
648        assert!(ep.port.is_none(), "port should be None when not in URI");
649
650        let defaults = RedisConfig::default().with_host("redis-prod");
651        ep.apply_defaults(&defaults);
652        // Custom default should be applied
653        assert_eq!(
654            ep.host,
655            Some("redis-prod".to_string()),
656            "custom default host should be applied"
657        );
658    }
659}