Skip to main content

camel_component_redis/
lib.rs

1//! Redis component for rust-camel.
2//!
3//! Provides producer and consumer implementations for Redis, supporting:
4//! - String, Hash, List, Set, Sorted Set operations
5//! - Pub/Sub (SUBSCRIBE, PSUBSCRIBE, PUBLISH)
6//! - Queue operations (BLPOP, BRPOP)
7//! - Key management (EXPIRE, TTL, DEL, etc.)
8//!
9//! # Breaking Changes in v0.10.0
10//!
11//! - **`RedisConsumer::new()`** now takes `RedisEndpointConfig` directly instead of
12//!   separate `(config, mode)` parameters. The mode is inferred from the command type
13//!   in the config (SUBSCRIBE/PSUBSCRIBE → PubSub, BLPOP/BRPOP → Queue).
14//! - **`resolve_zstore_keys()`** signature changed to accept `&[String]` instead of
15//!   a single `&str` for ZUNIONSTORE/ZINTERSTORE key resolution.
16//! - Invalid consumer commands (e.g. SET, GET) now return an error instead of silently
17//!   falling back to BLPOP (REDIS-003).
18//!
19//! # Example
20//!
21//! ```no_run
22//! use camel_component_redis::{RedisComponent, RedisEndpointConfig};
23//!
24//! let config = RedisEndpointConfig::from_uri("redis://localhost:6379?command=GET").unwrap();
25//! let component = RedisComponent::new();
26//! ```
27
28pub mod bundle;
29pub mod commands;
30pub mod config;
31pub mod consumer;
32pub mod executor;
33pub mod health;
34pub mod producer;
35
36use camel_component_api::{BoxProcessor, CamelError};
37use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
38use std::sync::Arc;
39
40pub use bundle::RedisBundle;
41pub use config::{RedisCommand, RedisConfig, RedisEndpointConfig};
42pub use consumer::RedisConsumer;
43pub use health::RedisHealthCheck;
44pub use producer::RedisProducer;
45
46pub struct RedisComponent {
47    config: Option<RedisConfig>,
48}
49
50impl RedisComponent {
51    /// Create a new RedisComponent without global config defaults.
52    /// Endpoint configs will fall back to hardcoded defaults via `resolve_defaults()`.
53    pub fn new() -> Self {
54        Self { config: None }
55    }
56
57    /// Create a RedisComponent with global config defaults.
58    /// These will be applied to endpoint configs before `resolve_defaults()`.
59    pub fn with_config(config: RedisConfig) -> Self {
60        Self {
61            config: Some(config),
62        }
63    }
64
65    /// Create a RedisComponent with optional global config defaults.
66    /// If `None`, behaves like `new()` (uses hardcoded defaults only).
67    pub fn with_optional_config(config: Option<RedisConfig>) -> Self {
68        Self { config }
69    }
70}
71
72impl Default for RedisComponent {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Component for RedisComponent {
79    fn scheme(&self) -> &str {
80        "redis"
81    }
82
83    fn create_endpoint(
84        &self,
85        uri: &str,
86        ctx: &dyn camel_component_api::ComponentContext,
87    ) -> Result<Box<dyn Endpoint>, CamelError> {
88        let mut config = RedisEndpointConfig::from_uri(uri)?;
89        // Apply global config defaults if available
90        if let Some(ref global_cfg) = self.config {
91            config.apply_defaults(global_cfg);
92        }
93        // Resolve any remaining None fields to hardcoded defaults
94        config.resolve_defaults();
95
96        let health_check = RedisHealthCheck::new(&config)?;
97        ctx.register_current_route_health_check(Arc::new(health_check));
98
99        Ok(Box::new(RedisEndpoint {
100            uri: uri.to_string(),
101            config,
102        }))
103    }
104}
105
106pub struct RedisEndpoint {
107    uri: String,
108    config: RedisEndpointConfig,
109}
110
111impl Endpoint for RedisEndpoint {
112    fn uri(&self) -> &str {
113        &self.uri
114    }
115
116    fn create_producer(
117        &self,
118        _rt: Arc<dyn RuntimeObservability>,
119        _ctx: &ProducerContext,
120    ) -> Result<BoxProcessor, CamelError> {
121        Ok(BoxProcessor::new(RedisProducer::new(self.config.clone())))
122    }
123
124    fn create_consumer(
125        &self,
126        rt: Arc<dyn RuntimeObservability>,
127    ) -> Result<Box<dyn Consumer>, CamelError> {
128        Ok(Box::new(RedisConsumer::new(self.config.clone(), rt)?))
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use camel_component_api::test_support::PanicRuntimeObservability;
135    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
136        std::sync::Arc::new(PanicRuntimeObservability)
137    }
138
139    use super::*;
140    use camel_component_api::NoOpComponentContext;
141
142    #[test]
143    fn test_component_scheme() {
144        let component = RedisComponent::new();
145        assert_eq!(component.scheme(), "redis");
146    }
147
148    #[test]
149    fn test_component_creates_endpoint() {
150        let component = RedisComponent::new();
151        let ctx = NoOpComponentContext;
152        let endpoint = component
153            .create_endpoint("redis://localhost:6379?command=GET", &ctx)
154            .expect("endpoint should be created");
155        assert_eq!(endpoint.uri(), "redis://localhost:6379?command=GET");
156    }
157
158    #[test]
159    fn test_component_rejects_wrong_scheme() {
160        let component = RedisComponent::new();
161        let ctx = NoOpComponentContext;
162        let result = component.create_endpoint("kafka:topic?brokers=localhost:9092", &ctx);
163        assert!(result.is_err(), "wrong scheme should fail");
164        let err = result.err().expect("error must exist");
165        assert!(err.to_string().contains("expected scheme 'redis'"));
166    }
167
168    #[test]
169    fn test_component_applies_global_defaults() {
170        let global = RedisConfig::default()
171            .with_host("localhost")
172            .with_port(6380);
173        let component = RedisComponent::with_config(global);
174        let ctx = NoOpComponentContext;
175
176        let endpoint = component
177            .create_endpoint("redis://?command=GET", &ctx)
178            .expect("endpoint should be created with defaults");
179
180        let _producer = endpoint
181            .create_producer(rt(), &ProducerContext::default())
182            .expect("producer should be created");
183        // GET is not a valid consumer command (REDIS-003), so use BLPOP for consumer test
184        let endpoint2 = component
185            .create_endpoint("redis://?command=BLPOP&key=test", &ctx)
186            .expect("endpoint should be created");
187        let _consumer = endpoint2
188            .create_consumer(rt())
189            .expect("consumer should be created");
190    }
191
192    // REDIS-011: RedisEndpoint is now pub
193    #[test]
194    fn test_redis_endpoint_is_pub_accessible() {
195        let component = RedisComponent::new();
196        let ctx = NoOpComponentContext;
197        let endpoint = component
198            .create_endpoint("redis://localhost:6379?command=GET", &ctx)
199            .unwrap();
200        // Verify we can access the endpoint's URI
201        assert_eq!(endpoint.uri(), "redis://localhost:6379?command=GET");
202    }
203}
204
205// REDIS-009: Integration tests with live Redis (#[ignore] by default)
206#[cfg(test)]
207mod integration_tests {
208    use crate::{RedisComponent, RedisEndpointConfig, RedisProducer};
209    use camel_component_api::NoOpComponentContext;
210    use camel_component_api::test_support::PanicRuntimeObservability;
211    use camel_component_api::{Component, ProducerContext};
212    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
213        std::sync::Arc::new(PanicRuntimeObservability)
214    }
215    use std::time::Duration;
216
217    /// Helper to check if a local Redis is available.
218    async fn redis_available() -> bool {
219        tokio::net::TcpStream::connect("127.0.0.1:6379")
220            .await
221            .is_ok()
222    }
223
224    /// Integration test: PING command via producer.
225    /// Run with: `cargo test -p camel-component-redis -- --ignored test_integration_ping`
226    #[tokio::test]
227    #[ignore = "Requires live Redis at 127.0.0.1:6379"]
228    async fn test_integration_ping() {
229        if !redis_available().await {
230            eprintln!("Skipping: Redis not available at 127.0.0.1:6379");
231            return;
232        }
233        let config = RedisEndpointConfig::from_uri("redis://127.0.0.1:6379?command=PING").unwrap();
234        let producer = RedisProducer::new(config);
235        producer
236            .check_connection()
237            .await
238            .expect("PING should succeed");
239    }
240
241    /// Integration test: SET/GET round-trip via producer.
242    /// Run with: `cargo test -p camel-component-redis -- --ignored test_integration_set_get`
243    #[tokio::test]
244    #[ignore = "Requires live Redis at 127.0.0.1:6379"]
245    async fn test_integration_set_get() {
246        if !redis_available().await {
247            eprintln!("Skipping: Redis not available at 127.0.0.1:6379");
248            return;
249        }
250        let component = RedisComponent::new();
251        let ctx = NoOpComponentContext;
252        let endpoint = component
253            .create_endpoint("redis://127.0.0.1:6379?command=SET", &ctx)
254            .unwrap();
255
256        let _producer = endpoint
257            .create_producer(test_rt(), &ProducerContext::default())
258            .expect("producer should be created");
259        // NOTE: Full SET/GET round-trip requires tower::Service::call which needs
260        // an active runtime loop. This test scaffolding proves the endpoint
261        // config and producer creation work with a live Redis connection.
262    }
263
264    /// Integration test: PUBLISH/SUBSCRIBE via consumer.
265    /// Run with: `cargo test -p camel-component-redis -- --ignored test_integration_pubsub`
266    #[tokio::test]
267    #[ignore = "Requires live Redis at 127.0.0.1:6379"]
268    async fn test_integration_pubsub() {
269        if !redis_available().await {
270            eprintln!("Skipping: Redis not available at 127.0.0.1:6379");
271            return;
272        }
273        let component = RedisComponent::new();
274        let ctx = NoOpComponentContext;
275        let endpoint = component
276            .create_endpoint(
277                "redis://127.0.0.1:6379?command=SUBSCRIBE&channels=integration-test",
278                &ctx,
279            )
280            .unwrap();
281
282        let mut consumer = endpoint
283            .create_consumer(test_rt())
284            .expect("consumer should be created");
285
286        let (tx, _rx) = tokio::sync::mpsc::channel(16);
287        let cancel_token = tokio_util::sync::CancellationToken::new();
288        let consumer_ctx = camel_component_api::ConsumerContext::new(
289            tx,
290            cancel_token.clone(),
291            "redis-test-route".to_string(),
292        );
293
294        // Start subscriber, let it run briefly, then cancel
295        consumer
296            .start(consumer_ctx)
297            .await
298            .expect("start should succeed");
299        tokio::time::sleep(Duration::from_millis(200)).await;
300        cancel_token.cancel();
301        consumer.stop().await.expect("stop should succeed");
302    }
303}