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
12pub 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
64pub 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
100pub struct HttpStaticConsumer {
116 config: HttpStaticConfig,
117 runtime: Arc<dyn RuntimeObservability>,
122}
123
124impl HttpStaticConsumer {
125 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 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 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 let serve_dir = ServeDir::new(&dir)
164 .precompressed_gzip()
165 .precompressed_br()
166 .append_index_html_on_directories(true);
167
168 let registry = ServerRegistry::global()
170 .get_or_spawn(
171 &self.config.host,
172 self.config.port,
173 2 * 1024 * 1024, 10 * 1024 * 1024, 1024, self.runtime.clone(),
177 ctx.route_id().to_string(),
178 None,
179 )
180 .await?;
181
182 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 ctx.mark_ready();
203
204 let mount_path_for_cleanup = self.config.mount_path.clone();
205 let registry_for_cleanup = registry.clone();
206
207 ctx.cancelled().await;
209
210 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 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
230 camel_component_api::ConsumerStartupMode::Explicit
231 }
232}
233
234#[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 crate::REGISTRY_TEST_MUTEX;
250 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
251 use std::path::PathBuf;
252 use std::sync::Arc;
253 use tokio::sync::{Notify, mpsc};
254 use tokio_util::sync::CancellationToken;
255
256 fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
258 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
259 let token = CancellationToken::new();
260 let token_clone = token.clone();
262 tokio::spawn(async move {
263 notify.notified().await;
264 token_clone.cancel();
265 });
266 ConsumerContext::new(tx, token, "http-static-test-route".to_string())
267 }
268
269 #[test]
270 fn test_component_scheme() {
271 let component = HttpStaticComponent::new();
272 assert_eq!(component.scheme(), "http-static");
273 }
274
275 #[test]
276 fn test_component_with_config() {
277 let config = HttpStaticConfig {
278 dir: PathBuf::from("/tmp"),
279 port: 9999,
280 ..HttpStaticConfig::default()
281 };
282 let component = HttpStaticComponent::with_config(config.clone());
283 assert_eq!(component.scheme(), "http-static");
284 }
285
286 #[test]
287 fn test_endpoint_creates_consumer() {
288 let config = HttpStaticConfig {
289 dir: PathBuf::from("/tmp"),
290 ..HttpStaticConfig::default()
291 };
292 let endpoint = HttpStaticEndpoint {
293 uri: "http-static:/tmp".to_string(),
294 config,
295 };
296 let consumer = endpoint.create_consumer(rt());
297 assert!(consumer.is_ok());
298 }
299
300 #[test]
301 fn test_endpoint_producer_not_supported() {
302 let config = HttpStaticConfig {
303 dir: PathBuf::from("/tmp"),
304 ..HttpStaticConfig::default()
305 };
306 let endpoint = HttpStaticEndpoint {
307 uri: "http-static:/tmp".to_string(),
308 config,
309 };
310 let ctx = camel_component_api::ProducerContext::new();
311 let result = endpoint.create_producer(rt(), &ctx);
312 assert!(result.is_err());
313 if let Err(CamelError::Config(msg)) = result {
314 assert!(msg.contains("does not support producers"));
315 } else {
316 panic!("Expected Config error");
317 }
318 }
319
320 #[test]
323 fn test_static_consumer_startup_mode_is_explicit() {
324 use camel_component_api::ConsumerStartupMode;
325 let config = HttpStaticConfig {
326 dir: PathBuf::from("/tmp"),
327 port: 0,
328 ..HttpStaticConfig::default()
329 };
330 let consumer = HttpStaticConsumer::new(config, test_rt());
331 assert_eq!(
332 consumer.startup_mode(),
333 ConsumerStartupMode::Explicit,
334 "HttpStaticConsumer must opt into Explicit startup"
335 );
336 }
337
338 #[allow(clippy::await_holding_lock)]
342 #[tokio::test]
343 async fn test_static_consumer_emits_mark_ready_after_register() {
344 use camel_component_api::{ConsumerContext, StartupSignal};
345
346 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
347 ServerRegistry::reset();
348
349 let dir = std::env::temp_dir();
350 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
351 let port = listener.local_addr().unwrap().port();
352 drop(listener);
353
354 let config = HttpStaticConfig {
355 dir,
356 port,
357 host: "127.0.0.1".to_string(),
358 ..HttpStaticConfig::default()
359 };
360 let mut consumer = HttpStaticConsumer::new(config, test_rt());
361
362 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
363 let token = CancellationToken::new();
364 let ctx = ConsumerContext::new(tx, token.clone(), "static-ready-probe".to_string());
365
366 let (signal, startup_rx) = StartupSignal::pair();
367 let ctx = ctx.with_startup(signal);
368
369 tokio::spawn(async move {
370 let _ = consumer.start(ctx).await;
371 });
372
373 let result =
374 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
375 .await
376 .expect("HttpStaticConsumer must call ctx.mark_ready() after register (rc-w1u9)");
377 assert!(result.is_ok(), "mark_ready must resolve Ok after register");
378
379 token.cancel();
380 }
381
382 #[tokio::test]
383 async fn test_consumer_start_nonexistent_dir_returns_error() {
384 let config = HttpStaticConfig {
385 dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
386 port: 19900,
387 ..HttpStaticConfig::default()
388 };
389 let mut consumer = HttpStaticConsumer::new(config, test_rt());
390 let notify = Arc::new(Notify::new());
391 let ctx = test_consumer_ctx(notify);
392
393 let result = consumer.start(ctx).await;
394 assert!(result.is_err());
395 if let Err(CamelError::Config(msg)) = result {
396 assert!(msg.contains("directory not found"));
397 } else {
398 panic!("Expected Config error for nonexistent dir");
399 }
400 }
401
402 #[allow(clippy::await_holding_lock)]
403 #[tokio::test]
404 async fn test_consumer_start_registers_mount_in_registry() {
405 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
406 ServerRegistry::reset();
408
409 let dir = std::env::temp_dir();
410 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
411
412 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
414 let port = listener.local_addr().unwrap().port();
415 drop(listener);
416
417 let config = HttpStaticConfig {
418 dir: dir.clone(),
419 port,
420 host: "127.0.0.1".to_string(),
421 ..HttpStaticConfig::default()
422 };
423
424 let serve_dir = ServeDir::new(&canonical_dir)
426 .precompressed_gzip()
427 .precompressed_br()
428 .append_index_html_on_directories(true);
429
430 let registry = ServerRegistry::global()
432 .get_or_spawn(
433 "127.0.0.1",
434 port,
435 2 * 1024 * 1024,
436 10 * 1024 * 1024,
437 1024,
438 test_rt(),
439 "test-static".into(),
440 None,
441 )
442 .await
443 .unwrap();
444
445 let mount = StaticMount {
447 mount_path: "/".to_string(),
448 mode: MountMode::Static,
449 dir: canonical_dir.clone(),
450 cache_control: config.cache_control.clone(),
451 error_pages: std::collections::HashMap::new(),
452 serve_dir,
453 };
454 registry.register_static_mount(mount).await.unwrap();
455
456 let inner = registry.inner.read().await;
458 assert_eq!(
459 inner.mounts.len(),
460 1,
461 "Expected one static mount registered"
462 );
463 assert_eq!(inner.mounts[0].dir, canonical_dir);
464 assert_eq!(inner.mounts[0].mount_path, "/");
465 }
466
467 #[allow(clippy::await_holding_lock)]
468 #[tokio::test]
469 async fn test_consumer_stop_unregisters_mount() {
470 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
471 ServerRegistry::reset();
473
474 let dir = std::env::temp_dir();
475 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
476
477 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
479 let port = listener.local_addr().unwrap().port();
480 drop(listener);
481
482 let registry = ServerRegistry::global()
484 .get_or_spawn(
485 "127.0.0.1",
486 port,
487 2 * 1024 * 1024,
488 10 * 1024 * 1024,
489 1024,
490 test_rt(),
491 "test-static".into(),
492 None,
493 )
494 .await
495 .unwrap();
496
497 let serve_dir = ServeDir::new(&canonical_dir)
499 .precompressed_gzip()
500 .precompressed_br()
501 .append_index_html_on_directories(true);
502 let mount = StaticMount {
503 mount_path: "/".to_string(),
504 mode: MountMode::Static,
505 dir: canonical_dir.clone(),
506 cache_control: "public, max-age=0".to_string(),
507 error_pages: std::collections::HashMap::new(),
508 serve_dir,
509 };
510 registry.register_static_mount(mount).await.unwrap();
511
512 {
514 let inner = registry.inner.read().await;
515 assert_eq!(inner.mounts.len(), 1);
516 }
517
518 registry.unregister_static_mount("/").await;
520
521 let inner = registry.inner.read().await;
523 assert_eq!(
524 inner.mounts.len(),
525 0,
526 "Expected static mount to be unregistered"
527 );
528 }
529}