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 #[allow(dead_code)]
120 runtime: Arc<dyn RuntimeObservability>,
121}
122
123impl HttpStaticConsumer {
124 pub fn new(config: HttpStaticConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
126 Self { config, runtime }
127 }
128}
129
130#[async_trait::async_trait]
131impl Consumer for HttpStaticConsumer {
132 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
133 let dir = std::fs::canonicalize(&self.config.dir).map_err(|e| {
135 CamelError::Config(format!(
136 "http-static directory not found: {}: {}",
137 self.config.dir.display(),
138 e
139 ))
140 })?;
141
142 let mut error_pages = std::collections::HashMap::new();
144 for (code, path) in &self.config.error_pages {
145 let resolved = if path.is_absolute() {
146 path.clone()
147 } else {
148 self.config.dir.join(path)
149 };
150 let canonical = std::fs::canonicalize(&resolved).map_err(|e| {
151 CamelError::Config(format!(
152 "http-static error page not found for status {}: {}: {}",
153 code,
154 resolved.display(),
155 e
156 ))
157 })?;
158 error_pages.insert(*code, canonical);
159 }
160
161 let serve_dir = ServeDir::new(&dir)
163 .precompressed_gzip()
164 .precompressed_br()
165 .append_index_html_on_directories(true);
166
167 let registry = ServerRegistry::global()
169 .get_or_spawn(
170 &self.config.host,
171 self.config.port,
172 2 * 1024 * 1024, 10 * 1024 * 1024, 1024, self.runtime.clone(),
176 ctx.route_id().to_string(),
177 None,
178 )
179 .await?;
180
181 let mode = if self.config.spa_fallback {
183 MountMode::Spa
184 } else {
185 MountMode::Static
186 };
187 let mount = StaticMount {
188 mount_path: self.config.mount_path.clone(),
189 mode,
190 dir: dir.clone(),
191 cache_control: self.config.cache_control.clone(),
192 error_pages,
193 serve_dir,
194 };
195
196 registry.register_static_mount(mount).await?;
197
198 ctx.mark_ready();
202
203 let mount_path_for_cleanup = self.config.mount_path.clone();
204 let registry_for_cleanup = registry.clone();
205
206 ctx.cancelled().await;
208
209 registry_for_cleanup
211 .unregister_static_mount(&mount_path_for_cleanup)
212 .await;
213
214 Ok(())
215 }
216
217 async fn stop(&mut self) -> Result<(), CamelError> {
218 Ok(())
219 }
220
221 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
222 camel_component_api::ConcurrencyModel::Sequential
223 }
224
225 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
229 camel_component_api::ConsumerStartupMode::Explicit
230 }
231}
232
233#[cfg(test)]
238mod tests {
239 use camel_component_api::test_support::PanicRuntimeObservability;
240 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
241 std::sync::Arc::new(PanicRuntimeObservability)
242 }
243 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
244 std::sync::Arc::new(PanicRuntimeObservability)
245 }
246
247 use super::*;
248 use crate::REGISTRY_TEST_MUTEX;
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 fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
257 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
258 let token = CancellationToken::new();
259 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 #[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 #[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 = REGISTRY_TEST_MUTEX.lock().unwrap();
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 = REGISTRY_TEST_MUTEX.lock().unwrap();
405 ServerRegistry::reset();
407
408 let dir = std::env::temp_dir();
409 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
410
411 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 let serve_dir = ServeDir::new(&canonical_dir)
425 .precompressed_gzip()
426 .precompressed_br()
427 .append_index_html_on_directories(true);
428
429 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 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 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 = REGISTRY_TEST_MUTEX.lock().unwrap();
470 ServerRegistry::reset();
472
473 let dir = std::env::temp_dir();
474 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
475
476 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 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 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 {
513 let inner = registry.inner.read().await;
514 assert_eq!(inner.mounts.len(), 1);
515 }
516
517 registry.unregister_static_mount("/").await;
519
520 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}