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`](crate::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    /// Feeds `ServerRegistry::get_or_spawn` — the runtime source of the
118    /// wired `e:http:accept` / `e:http:accept-tls` /
119    /// `e:http:server-task-exited` metrics. Also held for
120    /// `rt.health().force_unhealthy_for_route(...)` calls per ADR-0012.
121    runtime: Arc<dyn RuntimeObservability>,
122}
123
124impl HttpStaticConsumer {
125    /// Create a new `HttpStaticConsumer` from the given config.
126    pub fn new(config: HttpStaticConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
127        Self { config, runtime }
128    }
129}
130
131#[async_trait::async_trait]
132impl Consumer for HttpStaticConsumer {
133    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
134        // 1. Canonicalize dir
135        let dir = std::fs::canonicalize(&self.config.dir).map_err(|e| {
136            CamelError::Config(format!(
137                "http-static directory not found: {}: {}",
138                self.config.dir.display(),
139                e
140            ))
141        })?;
142
143        // 2. Canonicalize error_pages paths (resolved relative to dir)
144        let mut error_pages = std::collections::HashMap::new();
145        for (code, path) in &self.config.error_pages {
146            let resolved = if path.is_absolute() {
147                path.clone()
148            } else {
149                self.config.dir.join(path)
150            };
151            let canonical = std::fs::canonicalize(&resolved).map_err(|e| {
152                CamelError::Config(format!(
153                    "http-static error page not found for status {}: {}: {}",
154                    code,
155                    resolved.display(),
156                    e
157                ))
158            })?;
159            error_pages.insert(*code, canonical);
160        }
161
162        // 3. Build ServeDir
163        let serve_dir = ServeDir::new(&dir)
164            .precompressed_gzip()
165            .precompressed_br()
166            .append_index_html_on_directories(true);
167
168        // 4. Get registry
169        let registry = ServerRegistry::global()
170            .get_or_spawn(
171                &self.config.host,
172                self.config.port,
173                2 * 1024 * 1024,  // max_request_body (not used for static)
174                10 * 1024 * 1024, // max_response_body (not used for static)
175                1024,             // max_inflight_requests
176                self.runtime.clone(),
177                ctx.route_id().to_string(),
178                None,
179            )
180            .await?;
181
182        // 5. Register mount
183        let mode = if self.config.spa_fallback {
184            MountMode::Spa
185        } else {
186            MountMode::Static
187        };
188        let mount = StaticMount {
189            mount_path: self.config.mount_path.clone(),
190            mode,
191            dir: dir.clone(),
192            cache_control: self.config.cache_control.clone(),
193            error_pages,
194            serve_dir,
195        };
196
197        registry.register_static_mount(mount).await?;
198
199        // rc-w1u9: Signal readiness AFTER bind + spawn + static mount
200        // registration. Same contract as HttpConsumer: the listener is
201        // accepting and this mount's path will be served (not 404'd).
202        ctx.mark_ready();
203
204        let mount_path_for_cleanup = self.config.mount_path.clone();
205        let registry_for_cleanup = registry.clone();
206
207        // 6. Wait on cancellation token
208        ctx.cancelled().await;
209
210        // 7. Unregister on stop (by mount_path identity)
211        registry_for_cleanup
212            .unregister_static_mount(&mount_path_for_cleanup)
213            .await;
214
215        Ok(())
216    }
217
218    async fn stop(&mut self) -> Result<(), CamelError> {
219        Ok(())
220    }
221
222    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
223        camel_component_api::ConcurrencyModel::Sequential
224    }
225
226    // rc-w1u9: HttpStaticConsumer also binds a TcpListener inside start()
227    // (via ServerRegistry::get_or_spawn). Same Explicit contract as
228    // HttpConsumer — bind failures now surface as route-start errors.
229    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
230        camel_component_api::ConsumerStartupMode::Explicit
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Tests
236// ---------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use camel_component_api::test_support::PanicRuntimeObservability;
241    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
242        std::sync::Arc::new(PanicRuntimeObservability)
243    }
244    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
245        std::sync::Arc::new(PanicRuntimeObservability)
246    }
247
248    use super::*;
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 = crate::lock_registry_test_mutex();
346        ServerRegistry::reset();
347
348        let dir = std::env::temp_dir();
349        // ADR-0070 staged-listener law: stage the pre-bound listener; the
350        // consumer's get_or_spawn consumes it, so the port never returns to
351        // the ephemeral pool between probe and serve (no bind-read-drop race).
352        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
353        let port = listener.local_addr().unwrap().port();
354        ServerRegistry::global()
355            .stage_listener(listener)
356            .await
357            .expect("stage consumer test listener");
358
359        let config = HttpStaticConfig {
360            dir,
361            port,
362            host: "127.0.0.1".to_string(),
363            ..HttpStaticConfig::default()
364        };
365        let mut consumer = HttpStaticConsumer::new(config, test_rt());
366
367        let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
368        let token = CancellationToken::new();
369        let ctx = ConsumerContext::new(tx, token.clone(), "static-ready-probe".to_string());
370
371        let (signal, startup_rx) = StartupSignal::pair();
372        let ctx = ctx.with_startup(signal);
373
374        tokio::spawn(async move {
375            let _ = consumer.start(ctx).await;
376        });
377
378        let result =
379            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
380                .await
381                .expect("HttpStaticConsumer must call ctx.mark_ready() after register (rc-w1u9)");
382        assert!(result.is_ok(), "mark_ready must resolve Ok after register");
383
384        token.cancel();
385    }
386
387    #[tokio::test]
388    async fn test_consumer_start_nonexistent_dir_returns_error() {
389        let config = HttpStaticConfig {
390            dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
391            port: 19900,
392            ..HttpStaticConfig::default()
393        };
394        let mut consumer = HttpStaticConsumer::new(config, test_rt());
395        let notify = Arc::new(Notify::new());
396        let ctx = test_consumer_ctx(notify);
397
398        let result = consumer.start(ctx).await;
399        assert!(result.is_err());
400        if let Err(CamelError::Config(msg)) = result {
401            assert!(msg.contains("directory not found"));
402        } else {
403            panic!("Expected Config error for nonexistent dir");
404        }
405    }
406
407    #[allow(clippy::await_holding_lock)]
408    #[tokio::test]
409    async fn test_consumer_start_registers_mount_in_registry() {
410        let _guard = crate::lock_registry_test_mutex();
411        // Reset registry for clean test
412        ServerRegistry::reset();
413
414        let dir = std::env::temp_dir();
415        let canonical_dir = std::fs::canonicalize(&dir).unwrap();
416
417        // ADR-0070 staged-listener law: stage the pre-bound listener; the
418        // consumer's get_or_spawn consumes it, so the port never returns to
419        // the ephemeral pool between probe and serve (no bind-read-drop race).
420        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
421        let port = listener.local_addr().unwrap().port();
422        ServerRegistry::global()
423            .stage_listener(listener)
424            .await
425            .expect("stage consumer test listener");
426
427        let config = HttpStaticConfig {
428            dir: dir.clone(),
429            port,
430            host: "127.0.0.1".to_string(),
431            ..HttpStaticConfig::default()
432        };
433
434        // Build ServeDir directly (same as consumer does)
435        let serve_dir = ServeDir::new(&canonical_dir)
436            .precompressed_gzip()
437            .precompressed_br()
438            .append_index_html_on_directories(true);
439
440        // Get registry (this spawns the axum server)
441        let registry = ServerRegistry::global()
442            .get_or_spawn(
443                "127.0.0.1",
444                port,
445                2 * 1024 * 1024,
446                10 * 1024 * 1024,
447                1024,
448                test_rt(),
449                "test-static".into(),
450                None,
451            )
452            .await
453            .unwrap();
454
455        // Register mount
456        let mount = StaticMount {
457            mount_path: "/".to_string(),
458            mode: MountMode::Static,
459            dir: canonical_dir.clone(),
460            cache_control: config.cache_control.clone(),
461            error_pages: std::collections::HashMap::new(),
462            serve_dir,
463        };
464        registry.register_static_mount(mount).await.unwrap();
465
466        // Verify registered
467        let inner = registry.inner.read().await;
468        assert_eq!(
469            inner.mounts.len(),
470            1,
471            "Expected one static mount registered"
472        );
473        assert_eq!(inner.mounts[0].dir, canonical_dir);
474        assert_eq!(inner.mounts[0].mount_path, "/");
475    }
476
477    #[allow(clippy::await_holding_lock)]
478    #[tokio::test]
479    async fn test_consumer_stop_unregisters_mount() {
480        let _guard = crate::lock_registry_test_mutex();
481        // Reset registry for clean test
482        ServerRegistry::reset();
483
484        let dir = std::env::temp_dir();
485        let canonical_dir = std::fs::canonicalize(&dir).unwrap();
486
487        // ADR-0070 staged-listener law: stage the pre-bound listener; the
488        // consumer's get_or_spawn consumes it, so the port never returns to
489        // the ephemeral pool between probe and serve (no bind-read-drop race).
490        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
491        let port = listener.local_addr().unwrap().port();
492        ServerRegistry::global()
493            .stage_listener(listener)
494            .await
495            .expect("stage consumer test listener");
496
497        // Get registry
498        let registry = ServerRegistry::global()
499            .get_or_spawn(
500                "127.0.0.1",
501                port,
502                2 * 1024 * 1024,
503                10 * 1024 * 1024,
504                1024,
505                test_rt(),
506                "test-static".into(),
507                None,
508            )
509            .await
510            .unwrap();
511
512        // Register mount
513        let serve_dir = ServeDir::new(&canonical_dir)
514            .precompressed_gzip()
515            .precompressed_br()
516            .append_index_html_on_directories(true);
517        let mount = StaticMount {
518            mount_path: "/".to_string(),
519            mode: MountMode::Static,
520            dir: canonical_dir.clone(),
521            cache_control: "public, max-age=0".to_string(),
522            error_pages: std::collections::HashMap::new(),
523            serve_dir,
524        };
525        registry.register_static_mount(mount).await.unwrap();
526
527        // Verify registered
528        {
529            let inner = registry.inner.read().await;
530            assert_eq!(inner.mounts.len(), 1);
531        }
532
533        // Unregister by mount_path
534        registry.unregister_static_mount("/").await;
535
536        // Verify unregistered
537        let inner = registry.inner.read().await;
538        assert_eq!(
539            inner.mounts.len(),
540            0,
541            "Expected static mount to be unregistered"
542        );
543    }
544}