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        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 = crate::lock_registry_test_mutex();
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 = crate::lock_registry_test_mutex();
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}