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
14pub 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
98pub 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
134pub struct HttpStaticConsumer {
150 config: HttpStaticConfig,
151 #[allow(dead_code)]
154 runtime: Arc<dyn RuntimeObservability>,
155}
156
157impl HttpStaticConsumer {
158 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 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 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 let serve_dir = ServeDir::new(&dir)
197 .precompressed_gzip()
198 .precompressed_br()
199 .append_index_html_on_directories(true);
200
201 let registry = ServerRegistry::global()
203 .get_or_spawn(
204 &self.config.host,
205 self.config.port,
206 2 * 1024 * 1024, 10 * 1024 * 1024, 1024, self.runtime.clone(),
210 ctx.route_id().to_string(),
211 None,
212 )
213 .await?;
214
215 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 ctx.mark_ready();
236
237 let mount_path_for_cleanup = self.config.mount_path.clone();
238 let registry_for_cleanup = registry.clone();
239
240 ctx.cancelled().await;
242
243 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 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
263 camel_component_api::ConsumerStartupMode::Explicit
264 }
265}
266
267#[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 fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
291 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
292 let token = CancellationToken::new();
293 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 #[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 #[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 ServerRegistry::reset();
440
441 let dir = std::env::temp_dir();
442 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
443
444 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 let serve_dir = ServeDir::new(&canonical_dir)
458 .precompressed_gzip()
459 .precompressed_br()
460 .append_index_html_on_directories(true);
461
462 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 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 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 ServerRegistry::reset();
505
506 let dir = std::env::temp_dir();
507 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
508
509 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 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 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 {
546 let inner = registry.inner.read().await;
547 assert_eq!(inner.mounts.len(), 1);
548 }
549
550 registry.unregister_static_mount("/").await;
552
553 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}