Skip to main content

camel_component_http/
static_endpoint.rs

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