Skip to main content

camel_component_http/
static_endpoint.rs

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