Skip to main content

camel_component_http/
static_endpoint.rs

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