Skip to main content

camel_component_http/
static_endpoint.rs

1use camel_component_api::{CamelError, Component, Consumer, Endpoint, ProducerContext};
2use tower_http::services::ServeDir;
3
4use crate::registry::{MountMode, StaticMount};
5use crate::{HttpStaticConfig, ServerRegistry};
6
7// ---------------------------------------------------------------------------
8// HttpStaticComponent
9// ---------------------------------------------------------------------------
10
11/// Component factory for the `http-static:` scheme.
12///
13/// Creates [`HttpStaticEndpoint`] instances from URIs like
14/// `http-static:/path/to/dir?port=8080&spaFallback=true`.
15pub struct HttpStaticComponent {
16    config: HttpStaticConfig,
17}
18
19impl HttpStaticComponent {
20    pub fn new() -> Self {
21        Self {
22            config: HttpStaticConfig::default(),
23        }
24    }
25
26    pub fn with_config(config: HttpStaticConfig) -> Self {
27        Self { config }
28    }
29}
30
31impl Default for HttpStaticComponent {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl Component for HttpStaticComponent {
38    fn scheme(&self) -> &str {
39        "http-static"
40    }
41
42    fn create_endpoint(
43        &self,
44        uri: &str,
45        _ctx: &dyn camel_component_api::ComponentContext,
46    ) -> Result<Box<dyn Endpoint>, CamelError> {
47        let config = HttpStaticConfig::from_uri_with_defaults(uri, &self.config)?;
48        Ok(Box::new(HttpStaticEndpoint {
49            uri: uri.to_string(),
50            config,
51        }))
52    }
53}
54
55// ---------------------------------------------------------------------------
56// HttpStaticEndpoint
57// ---------------------------------------------------------------------------
58
59/// Endpoint for a static file serving route.
60///
61/// Holds the resolved [`HttpStaticConfig`] and creates [`HttpStaticConsumer`]
62/// instances when the route starts.
63pub struct HttpStaticEndpoint {
64    uri: String,
65    config: HttpStaticConfig,
66}
67
68impl Endpoint for HttpStaticEndpoint {
69    fn uri(&self) -> &str {
70        &self.uri
71    }
72
73    fn create_consumer(&self) -> Result<Box<dyn Consumer>, CamelError> {
74        Ok(Box::new(HttpStaticConsumer {
75            config: self.config.clone(),
76        }))
77    }
78
79    fn create_producer(
80        &self,
81        _ctx: &ProducerContext,
82    ) -> Result<camel_component_api::BoxProcessor, CamelError> {
83        Err(CamelError::Config(
84            "http-static endpoint does not support producers".to_string(),
85        ))
86    }
87}
88
89// ---------------------------------------------------------------------------
90// HttpStaticConsumer
91// ---------------------------------------------------------------------------
92
93/// Consumer that registers a static file mount into the shared
94/// [`HttpRouteRegistry`] and stays idle until cancelled.
95///
96/// On start:
97/// 1. Canonicalizes the configured `dir` (fails if not found).
98/// 2. Canonicalizes each `error_pages` path (fails if any don't exist).
99/// 3. Builds a `ServeDir` for the directory.
100/// 4. Registers a `StaticMount` into the registry.
101///
102/// On stop (cancellation):
103/// - Unregisters the mount from the registry.
104pub struct HttpStaticConsumer {
105    config: HttpStaticConfig,
106}
107
108impl HttpStaticConsumer {
109    /// Create a new `HttpStaticConsumer` from the given config.
110    pub fn new(config: HttpStaticConfig) -> Self {
111        Self { config }
112    }
113}
114
115#[async_trait::async_trait]
116impl Consumer for HttpStaticConsumer {
117    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
118        // 1. Canonicalize dir
119        let dir = std::fs::canonicalize(&self.config.dir).map_err(|e| {
120            CamelError::Config(format!(
121                "http-static directory not found: {}: {}",
122                self.config.dir.display(),
123                e
124            ))
125        })?;
126
127        // 2. Canonicalize error_pages paths (resolved relative to dir)
128        let mut error_pages = std::collections::HashMap::new();
129        for (code, path) in &self.config.error_pages {
130            let resolved = if path.is_absolute() {
131                path.clone()
132            } else {
133                self.config.dir.join(path)
134            };
135            let canonical = std::fs::canonicalize(&resolved).map_err(|e| {
136                CamelError::Config(format!(
137                    "http-static error page not found for status {}: {}: {}",
138                    code,
139                    resolved.display(),
140                    e
141                ))
142            })?;
143            error_pages.insert(*code, canonical);
144        }
145
146        // 3. Build ServeDir
147        let serve_dir = ServeDir::new(&dir)
148            .precompressed_gzip()
149            .precompressed_br()
150            .append_index_html_on_directories(true);
151
152        // 4. Get registry
153        let registry = ServerRegistry::global()
154            .get_or_spawn(
155                &self.config.host,
156                self.config.port,
157                2 * 1024 * 1024,  // max_request_body (not used for static)
158                10 * 1024 * 1024, // max_response_body (not used for static)
159                1024,             // max_inflight_requests
160            )
161            .await?;
162
163        // 5. Register mount
164        let mode = if self.config.spa_fallback {
165            MountMode::Spa
166        } else {
167            MountMode::Static
168        };
169        let mount = StaticMount {
170            mount_path: self.config.mount_path.clone(),
171            mode,
172            dir: dir.clone(),
173            cache_control: self.config.cache_control.clone(),
174            error_pages,
175            serve_dir,
176        };
177
178        registry.register_static_mount(mount).await?;
179
180        let mount_path_for_cleanup = self.config.mount_path.clone();
181        let registry_for_cleanup = registry.clone();
182
183        // 6. Wait on cancellation token
184        ctx.cancelled().await;
185
186        // 7. Unregister on stop (by mount_path identity)
187        registry_for_cleanup
188            .unregister_static_mount(&mount_path_for_cleanup)
189            .await;
190
191        Ok(())
192    }
193
194    async fn stop(&mut self) -> Result<(), CamelError> {
195        Ok(())
196    }
197
198    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
199        camel_component_api::ConcurrencyModel::Sequential
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Tests
205// ---------------------------------------------------------------------------
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::REGISTRY_TEST_MUTEX;
211    use camel_component_api::{ConsumerContext, ExchangeEnvelope};
212    use std::path::PathBuf;
213    use std::sync::Arc;
214    use tokio::sync::{Notify, mpsc};
215    use tokio_util::sync::CancellationToken;
216
217    /// Helper: create a test consumer context with a controllable cancellation.
218    fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
219        let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
220        let token = CancellationToken::new();
221        // Spawn a task that cancels when notified
222        let token_clone = token.clone();
223        tokio::spawn(async move {
224            notify.notified().await;
225            token_clone.cancel();
226        });
227        ConsumerContext::new(tx, token)
228    }
229
230    #[test]
231    fn test_component_scheme() {
232        let component = HttpStaticComponent::new();
233        assert_eq!(component.scheme(), "http-static");
234    }
235
236    #[test]
237    fn test_component_with_config() {
238        let config = HttpStaticConfig {
239            dir: PathBuf::from("/tmp"),
240            port: 9999,
241            ..HttpStaticConfig::default()
242        };
243        let component = HttpStaticComponent::with_config(config.clone());
244        assert_eq!(component.scheme(), "http-static");
245    }
246
247    #[test]
248    fn test_endpoint_creates_consumer() {
249        let config = HttpStaticConfig {
250            dir: PathBuf::from("/tmp"),
251            ..HttpStaticConfig::default()
252        };
253        let endpoint = HttpStaticEndpoint {
254            uri: "http-static:/tmp".to_string(),
255            config,
256        };
257        let consumer = endpoint.create_consumer();
258        assert!(consumer.is_ok());
259    }
260
261    #[test]
262    fn test_endpoint_producer_not_supported() {
263        let config = HttpStaticConfig {
264            dir: PathBuf::from("/tmp"),
265            ..HttpStaticConfig::default()
266        };
267        let endpoint = HttpStaticEndpoint {
268            uri: "http-static:/tmp".to_string(),
269            config,
270        };
271        let ctx = camel_component_api::ProducerContext::new();
272        let result = endpoint.create_producer(&ctx);
273        assert!(result.is_err());
274        if let Err(CamelError::Config(msg)) = result {
275            assert!(msg.contains("does not support producers"));
276        } else {
277            panic!("Expected Config error");
278        }
279    }
280
281    #[tokio::test]
282    async fn test_consumer_start_nonexistent_dir_returns_error() {
283        let config = HttpStaticConfig {
284            dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
285            port: 19900,
286            ..HttpStaticConfig::default()
287        };
288        let mut consumer = HttpStaticConsumer::new(config);
289        let notify = Arc::new(Notify::new());
290        let ctx = test_consumer_ctx(notify);
291
292        let result = consumer.start(ctx).await;
293        assert!(result.is_err());
294        if let Err(CamelError::Config(msg)) = result {
295            assert!(msg.contains("directory not found"));
296        } else {
297            panic!("Expected Config error for nonexistent dir");
298        }
299    }
300
301    #[allow(clippy::await_holding_lock)]
302    #[tokio::test]
303    async fn test_consumer_start_registers_mount_in_registry() {
304        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
305        // Reset registry for clean test
306        ServerRegistry::reset();
307
308        let dir = std::env::temp_dir();
309        let canonical_dir = std::fs::canonicalize(&dir).unwrap();
310
311        // Bind to a free port first
312        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
313        let port = listener.local_addr().unwrap().port();
314        drop(listener);
315
316        let config = HttpStaticConfig {
317            dir: dir.clone(),
318            port,
319            host: "127.0.0.1".to_string(),
320            ..HttpStaticConfig::default()
321        };
322
323        // Build ServeDir directly (same as consumer does)
324        let serve_dir = ServeDir::new(&canonical_dir)
325            .precompressed_gzip()
326            .precompressed_br()
327            .append_index_html_on_directories(true);
328
329        // Get registry (this spawns the axum server)
330        let registry = ServerRegistry::global()
331            .get_or_spawn("127.0.0.1", port, 2 * 1024 * 1024, 10 * 1024 * 1024, 1024)
332            .await
333            .unwrap();
334
335        // Register mount
336        let mount = StaticMount {
337            mount_path: "/".to_string(),
338            mode: MountMode::Static,
339            dir: canonical_dir.clone(),
340            cache_control: config.cache_control.clone(),
341            error_pages: std::collections::HashMap::new(),
342            serve_dir,
343        };
344        registry.register_static_mount(mount).await.unwrap();
345
346        // Verify registered
347        let inner = registry.inner.read().await;
348        assert_eq!(
349            inner.mounts.len(),
350            1,
351            "Expected one static mount registered"
352        );
353        assert_eq!(inner.mounts[0].dir, canonical_dir);
354        assert_eq!(inner.mounts[0].mount_path, "/");
355    }
356
357    #[allow(clippy::await_holding_lock)]
358    #[tokio::test]
359    async fn test_consumer_stop_unregisters_mount() {
360        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
361        // Reset registry for clean test
362        ServerRegistry::reset();
363
364        let dir = std::env::temp_dir();
365        let canonical_dir = std::fs::canonicalize(&dir).unwrap();
366
367        // Bind to a free port
368        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
369        let port = listener.local_addr().unwrap().port();
370        drop(listener);
371
372        // Get registry
373        let registry = ServerRegistry::global()
374            .get_or_spawn("127.0.0.1", port, 2 * 1024 * 1024, 10 * 1024 * 1024, 1024)
375            .await
376            .unwrap();
377
378        // Register mount
379        let serve_dir = ServeDir::new(&canonical_dir)
380            .precompressed_gzip()
381            .precompressed_br()
382            .append_index_html_on_directories(true);
383        let mount = StaticMount {
384            mount_path: "/".to_string(),
385            mode: MountMode::Static,
386            dir: canonical_dir.clone(),
387            cache_control: "public, max-age=0".to_string(),
388            error_pages: std::collections::HashMap::new(),
389            serve_dir,
390        };
391        registry.register_static_mount(mount).await.unwrap();
392
393        // Verify registered
394        {
395            let inner = registry.inner.read().await;
396            assert_eq!(inner.mounts.len(), 1);
397        }
398
399        // Unregister by mount_path
400        registry.unregister_static_mount("/").await;
401
402        // Verify unregistered
403        let inner = registry.inner.read().await;
404        assert_eq!(
405            inner.mounts.len(),
406            0,
407            "Expected static mount to be unregistered"
408        );
409    }
410}