1pub mod auth;
2pub mod bundle;
3pub mod config;
4pub mod health;
5pub mod registry;
6pub(crate) mod rest_match;
7pub(crate) mod ssrf;
8pub mod static_config;
9pub mod static_dispatch;
10pub mod static_endpoint;
11pub(crate) mod tls_reload;
12use crate::config::parse_ok_status_code_range;
13pub use bundle::HttpBundle;
14pub use bundle::HttpStaticBundle;
15pub use config::HttpConfig;
16pub use health::HttpHealthCheck;
17pub use registry::HttpRouteRegistry;
18pub use static_config::HttpStaticConfig;
19pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
20
21use std::collections::HashMap;
22use std::future::Future;
23use std::pin::Pin;
24
25use std::sync::{Arc, Mutex, OnceLock};
26use std::task::{Context, Poll};
27use std::time::Duration;
28
29use tokio::sync::OnceCell;
30use tower::Layer;
31use tower::Service;
32use tracing::debug;
33
34use axum::body::BodyDataStream;
35use camel_api::component_metadata::{
36 ComponentCapabilities, ComponentMetadata, OptionKind, UriOption,
37};
38use camel_auth::bearer_token_layer::BearerTokenLayer;
39use camel_auth::oauth2::TokenProvider;
40use camel_component_api::tls_source::ServerTlsSource;
41use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
42use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
43use camel_component_api::{UriComponents, UriConfig, parse_uri};
44use futures::TryStreamExt;
45use futures::stream::BoxStream;
46
47#[derive(Debug, Clone)]
108pub struct HttpEndpointConfig {
109 pub base_url: String,
110 pub http_method: Option<String>,
111 pub throw_exception_on_failure: bool,
112 pub ok_status_code_range: (u16, u16),
113 pub response_timeout: Option<Duration>,
114 pub query_params: HashMap<String, String>,
115 pub allow_internal: bool,
116 pub blocked_hosts: Vec<String>,
117 pub max_body_size: usize,
118 pub read_timeout_ms: u64,
119 pub max_response_bytes: usize,
120 pub auth: HttpAuth,
121 pub token_provider: Option<Arc<dyn TokenProvider>>,
122 pub user_agent: Option<String>,
123 pub cookie_handling: CookieHandling,
124 pub bridge_endpoint: bool,
125 pub connection_close: bool,
126 pub skip_request_headers: Vec<String>,
127 pub skip_response_headers: Vec<String>,
128 pub follow_redirects: bool,
129 pub max_redirects: usize,
130}
131
132#[derive(Clone, PartialEq)]
133pub enum HttpAuth {
134 None,
135 Basic { username: String, password: String },
136 Bearer { token: String },
137}
138
139impl std::fmt::Debug for HttpAuth {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 HttpAuth::None => f.write_str("None"),
143 HttpAuth::Basic { username, .. } => f
144 .debug_struct("Basic")
145 .field("username", username)
146 .field("password", &"***")
147 .finish(),
148 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum CookieHandling {
155 Disabled,
156 InMemory,
157}
158
159const HTTP_CAMEL_OPTIONS: &[&str] = &[
161 "httpMethod",
162 "throwExceptionOnFailure",
163 "okStatusCodeRange",
164 "followRedirects",
165 "maxRedirects",
166 "connectTimeout",
167 "responseTimeout",
168 "allowInternal",
169 "blockedHosts",
170 "maxBodySize",
171 "readTimeout",
172 "maxResponseBytes",
173 "authMethod",
174 "authUsername",
175 "authPassword",
176 "authBearerToken",
177 "userAgent",
178 "cookieHandling",
179 "bridgeEndpoint",
180 "connectionClose",
181 "skipRequestHeaders",
182 "skipResponseHeaders",
183];
184
185impl UriConfig for HttpEndpointConfig {
186 fn scheme() -> &'static str {
188 "http"
189 }
190
191 fn from_uri(uri: &str) -> Result<Self, CamelError> {
192 let parts = parse_uri(uri)?;
193 Self::from_components(parts)
194 }
195
196 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
197 if parts.scheme != "http" && parts.scheme != "https" {
199 return Err(CamelError::InvalidUri(format!(
200 "expected scheme 'http' or 'https', got '{}'",
201 parts.scheme
202 )));
203 }
204
205 let base_url = format!("{}:{}", parts.scheme, parts.path);
208
209 let http_method = parts.params.get("httpMethod").cloned();
210
211 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
212 Some(v) => parse_bool_param_http(v).map_err(|e| {
213 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
214 })?,
215 None => true,
216 };
217
218 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
220 Some(v) => parse_ok_status_code_range(v)?,
221 None => (200, 299),
222 };
223
224 let response_timeout = match parts.params.get("responseTimeout") {
225 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
226 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
227 })?),
228 None => None,
229 };
230
231 let allow_internal = match parts.params.get("allowInternal") {
233 Some(v) => parse_bool_param_http(v).map_err(|e| {
234 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
235 })?,
236 None => false, };
238
239 let blocked_hosts = parts
241 .params
242 .get("blockedHosts")
243 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
244 .unwrap_or_default();
245
246 let max_body_size = match parts.params.get("maxBodySize") {
247 Some(v) => v.parse::<usize>().map_err(|e| {
248 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
249 })?,
250 None => 10 * 1024 * 1024, };
252
253 let read_timeout_ms = match parts.params.get("readTimeout") {
254 Some(v) => v.parse::<u64>().map_err(|e| {
255 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
256 })?,
257 None => 30_000, };
259
260 let max_response_bytes = match parts.params.get("maxResponseBytes") {
261 Some(v) => v.parse::<usize>().map_err(|e| {
262 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
263 })?,
264 None => 10 * 1024 * 1024, };
266
267 let auth = parse_auth_from_params(&parts.params)?;
268
269 let user_agent = parts.params.get("userAgent").cloned();
270
271 let cookie_handling = match parts.params.get("cookieHandling") {
272 Some(v) if v.eq_ignore_ascii_case("inmemory") => CookieHandling::InMemory,
273 Some(v) if v.eq_ignore_ascii_case("disabled") => CookieHandling::Disabled,
274 Some(v) => {
275 return Err(CamelError::InvalidUri(format!(
276 "invalid value for cookieHandling: {v} (expected Disabled or InMemory)"
277 )));
278 }
279 None => CookieHandling::Disabled,
280 };
281
282 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
283 Some(v) => parse_bool_param_http(v).map_err(|e| {
284 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
285 })?,
286 None => false,
287 };
288
289 let connection_close = match parts.params.get("connectionClose") {
290 Some(v) => parse_bool_param_http(v).map_err(|e| {
291 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
292 })?,
293 None => false,
294 };
295
296 let skip_request_headers = parts
297 .params
298 .get("skipRequestHeaders")
299 .map(|v| {
300 v.split(',')
301 .map(str::trim)
302 .filter(|s| !s.is_empty())
303 .map(|s| s.to_ascii_lowercase())
304 .collect::<Vec<_>>()
305 })
306 .unwrap_or_default();
307
308 let skip_response_headers = parts
309 .params
310 .get("skipResponseHeaders")
311 .map(|v| {
312 v.split(',')
313 .map(str::trim)
314 .filter(|s| !s.is_empty())
315 .map(|s| s.to_ascii_lowercase())
316 .collect::<Vec<_>>()
317 })
318 .unwrap_or_default();
319
320 let follow_redirects = match parts.params.get("followRedirects") {
321 Some(v) => parse_bool_param_http(v).map_err(|e| {
322 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
323 })?,
324 None => false,
325 };
326
327 let max_redirects = match parts.params.get("maxRedirects") {
328 Some(v) => v.parse::<usize>().map_err(|e| {
329 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
330 })?,
331 None => 10,
332 };
333
334 let query_params: HashMap<String, String> = parts
336 .params
337 .into_iter()
338 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
339 .collect();
340
341 Ok(Self {
342 base_url,
343 http_method,
344 throw_exception_on_failure,
345 ok_status_code_range,
346 response_timeout,
347 query_params,
348 allow_internal,
349 blocked_hosts,
350 max_body_size,
351 read_timeout_ms,
352 max_response_bytes,
353 auth,
354 token_provider: None,
355 user_agent,
356 cookie_handling,
357 bridge_endpoint,
358 connection_close,
359 skip_request_headers,
360 skip_response_headers,
361 follow_redirects,
362 max_redirects,
363 })
364 }
365}
366
367fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
368 let Some(method) = params.get("authMethod") else {
369 return Ok(HttpAuth::None);
370 };
371
372 if method.eq_ignore_ascii_case("none") {
373 return Ok(HttpAuth::None);
374 }
375
376 if method.eq_ignore_ascii_case("basic") {
377 let username = params.get("authUsername").cloned().ok_or_else(|| {
378 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
379 })?;
380 let password = params.get("authPassword").cloned().ok_or_else(|| {
381 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
382 })?;
383 return Ok(HttpAuth::Basic { username, password });
384 }
385
386 if method.eq_ignore_ascii_case("bearer") {
387 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
388 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
389 })?;
390 return Ok(HttpAuth::Bearer { token });
391 }
392
393 Err(CamelError::InvalidUri(format!(
394 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
395 )))
396}
397
398fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
399 match value.to_ascii_lowercase().as_str() {
400 "true" | "1" | "yes" => Ok(true),
401 "false" | "0" | "no" => Ok(false),
402 _ => Err(CamelError::InvalidUri(format!(
403 "invalid boolean value: '{value}'"
404 ))),
405 }
406}
407
408impl HttpEndpointConfig {
409 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
410 let parts = parse_uri(uri)?;
411 let mut endpoint = Self::from_components(parts.clone())?;
412 if endpoint.response_timeout.is_none() {
413 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
414 }
415 if !parts.params.contains_key("allowInternal") {
416 endpoint.allow_internal = config.allow_internal;
417 }
418 if !parts.params.contains_key("blockedHosts") {
419 endpoint.blocked_hosts = config.blocked_hosts.clone();
420 }
421 if !parts.params.contains_key("maxBodySize") {
422 endpoint.max_body_size = config.max_body_size;
423 }
424 if !parts.params.contains_key("readTimeout") {
425 endpoint.read_timeout_ms = config.read_timeout_ms;
426 }
427 if !parts.params.contains_key("maxResponseBytes") {
428 endpoint.max_response_bytes = config.max_response_bytes;
429 }
430 if !parts.params.contains_key("okStatusCodeRange")
431 && let Some(range) = &config.ok_status_code_range
432 {
433 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
434 }
435 if !parts.params.contains_key("followRedirects") {
436 endpoint.follow_redirects = config.follow_redirects;
437 }
438 if !parts.params.contains_key("maxRedirects") {
439 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
440 }
441
442 Ok(endpoint)
443 }
444}
445
446#[derive(Debug, Clone)]
452pub struct HttpServerConfig {
453 pub scheme: String,
455 pub host: String,
457 pub port: u16,
459 pub path: String,
461 pub max_request_body: usize,
463 pub max_response_body: usize,
465 pub max_inflight_requests: usize,
467 pub method: Option<String>,
474 pub tls_config: Option<crate::config::ServerTlsConfig>,
477}
478
479impl UriConfig for HttpServerConfig {
480 fn scheme() -> &'static str {
482 "http"
483 }
484
485 fn from_uri(uri: &str) -> Result<Self, CamelError> {
486 let parts = parse_uri(uri)?;
487 Self::from_components(parts)
488 }
489
490 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
491 if parts.scheme != "http" && parts.scheme != "https" {
493 return Err(CamelError::InvalidUri(format!(
494 "expected scheme 'http' or 'https', got '{}'",
495 parts.scheme
496 )));
497 }
498
499 let authority_and_path = parts.path.trim_start_matches('/');
502
503 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
505 (&authority_and_path[..idx], &authority_and_path[idx..])
506 } else {
507 (authority_and_path, "/")
508 };
509
510 let path = if path_suffix.is_empty() {
511 "/"
512 } else {
513 path_suffix
514 }
515 .to_string();
516
517 let (host, port) = if let Some(colon) = authority.rfind(':') {
519 let port_str = &authority[colon + 1..];
520 match port_str.parse::<u16>() {
521 Ok(p) => (authority[..colon].to_string(), p),
522 Err(_) => {
523 return Err(CamelError::InvalidUri(format!(
524 "invalid port '{}' in authority",
525 port_str
526 )));
527 }
528 }
529 } else {
530 let default_port = if parts.scheme == "https" { 443 } else { 80 };
532 (authority.to_string(), default_port)
533 };
534
535 let max_request_body = parts
536 .params
537 .get("maxRequestBody")
538 .and_then(|v| v.parse::<usize>().ok())
539 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
542 .params
543 .get("maxResponseBody")
544 .and_then(|v| v.parse::<usize>().ok())
545 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
548 .params
549 .get("maxInflightRequests")
550 .and_then(|v| v.parse::<usize>().ok())
551 .unwrap_or(1024);
552
553 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
559
560 Ok(Self {
561 scheme: parts.scheme,
562 host,
563 port,
564 path,
565 max_request_body,
566 max_response_body,
567 max_inflight_requests,
568 method,
569 tls_config: {
570 let cert = parts.params.get("tlsCert").cloned();
571 let key = parts.params.get("tlsKey").cloned();
572 match (cert, key) {
573 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
574 cert_path: c,
575 key_path: k,
576 }),
577 (None, None) => None,
578 _ => None, }
580 },
581 })
582 }
583}
584
585impl HttpServerConfig {
586 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
587 let parts = parse_uri(uri)?;
588 let mut server = Self::from_components(parts.clone())?;
589 if !parts.params.contains_key("maxRequestBody") {
590 server.max_request_body = config.max_request_body;
591 }
592 if !parts.params.contains_key("maxResponseBody") {
593 server.max_response_body = config.max_body_size;
595 }
596 Ok(server)
597 }
598}
599
600pub enum HttpReplyBody {
608 Bytes(bytes::Bytes),
609 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
610}
611
612pub struct RequestEnvelope {
617 pub method: String,
618 pub path: String,
619 pub query: String,
620 pub headers: http::HeaderMap,
621 pub body: StreamBody,
622 pub path_params: std::collections::HashMap<String, String>,
628 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
629}
630
631pub struct HttpReply {
635 pub status: u16,
636 pub headers: Vec<(String, String)>,
637 pub body: HttpReplyBody,
638}
639
640type ServerKey = (String, u16);
645
646struct ServerHandle {
648 registry: HttpRouteRegistry,
649 max_request_body: usize,
650 max_response_body: usize,
651 max_inflight_requests: usize,
652 is_tls: bool,
653 tls_cert_path: Option<String>,
654 tls_key_path: Option<String>,
655 monitor_task: tokio::task::JoinHandle<()>,
658 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
661 tls_source: Option<ServerTlsSource>,
662}
663
664pub struct ServerRegistry {
666 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
667}
668
669impl ServerRegistry {
670 pub fn global() -> &'static Self {
672 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
673 INSTANCE.get_or_init(|| ServerRegistry {
674 inner: Mutex::new(HashMap::new()),
675 })
676 }
677
678 #[allow(clippy::too_many_arguments)]
681 pub async fn get_or_spawn(
682 &'static self,
683 host: &str,
684 port: u16,
685 max_request_body: usize,
686 max_response_body: usize,
687 max_inflight_requests: usize,
688 runtime: Arc<dyn RuntimeObservability>,
689 route_id: String,
690 tls_config: Option<crate::config::ServerTlsConfig>,
691 ) -> Result<HttpRouteRegistry, CamelError> {
692 let host_owned = host.to_string();
693
694 let cell = {
695 let mut guard = self.inner.lock().map_err(|_| {
696 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
697 })?;
698 let key = (host.to_string(), port);
699 if let Some(existing) = guard.get(&key)
703 && let Some(handle) = existing.get()
704 && handle.monitor_task.is_finished()
705 {
706 if handle.is_tls {
709 let scheme = if handle.is_tls { "https" } else { "http" };
710 camel_component_api::tls_source::TlsReloadRegistry::global()
711 .unregister(scheme, host, port);
712 }
713 guard.remove(&key);
714 }
715 guard
716 .entry(key)
717 .or_insert_with(|| Arc::new(OnceCell::new()))
718 .clone()
719 };
720
721 if let Some(existing) = cell.get()
722 && existing.max_request_body != max_request_body
723 {
724 return Err(CamelError::EndpointCreationFailed(format!(
725 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
726 existing.max_request_body, max_request_body
727 )));
728 }
729
730 if let Some(existing) = cell.get()
731 && existing.max_response_body != max_response_body
732 {
733 return Err(CamelError::EndpointCreationFailed(format!(
734 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
735 existing.max_response_body, max_response_body
736 )));
737 }
738
739 if let Some(existing) = cell.get()
740 && existing.max_inflight_requests != max_inflight_requests
741 {
742 return Err(CamelError::EndpointCreationFailed(format!(
743 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
744 existing.max_inflight_requests, max_inflight_requests
745 )));
746 }
747
748 if let Some(existing) = cell.get()
750 && existing.is_tls != tls_config.is_some()
751 {
752 return Err(CamelError::EndpointCreationFailed(format!(
753 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
754 existing.is_tls,
755 tls_config.is_some()
756 )));
757 }
758
759 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
761 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
762 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
763 {
764 return Err(CamelError::EndpointCreationFailed(format!(
765 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
766 )));
767 }
768
769 let handle = cell
770 .get_or_try_init(|| {
771 let rt = Arc::clone(&runtime);
772 let rid = route_id.clone();
773 async move {
774 let addr = format!("{host_owned}:{port}");
775 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
776 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
777 })?;
778 let registry = HttpRouteRegistry::new();
779 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
780 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
783 let tls_source: Option<ServerTlsSource>;
784 let server_task = if let Some(ref tls) = tls_config {
785 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
786 let source = ServerTlsSource {
787 cert_path: std::path::PathBuf::from(&tls.cert_path),
788 key_path: std::path::PathBuf::from(&tls.key_path),
789 client_ca_path: None,
790 };
791 let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
795 std::sync::Arc::new(rustls_config),
796 );
797 tls_rustls_cfg = Some(rustls_cfg.clone());
798 tls_source = Some(source);
799 let std_listener = listener.into_std().map_err(|e| {
801 CamelError::EndpointCreationFailed(format!(
802 "TLS listener conversion: {e}"
803 ))
804 })?;
805 tokio::spawn(run_axum_server_tls(
806 std_listener,
807 rustls_cfg,
808 registry.clone(),
809 max_request_body,
810 max_response_body,
811 Arc::clone(&inflight),
812 Arc::clone(&rt),
813 rid.clone(),
814 ))
815 } else {
816 tls_rustls_cfg = None;
817 tls_source = None;
818 tokio::spawn(run_axum_server(
819 listener,
820 registry.clone(),
821 max_request_body,
822 max_response_body,
823 Arc::clone(&inflight),
824 Arc::clone(&rt),
825 rid.clone(),
826 ))
827 };
828 let addr_for_monitor = format!("{host_owned}:{port}");
829 let monitor_task = tokio::spawn(monitor_axum_task(
830 server_task,
831 addr_for_monitor,
832 Arc::clone(&rt),
833 rid,
834 ));
835 let handle = ServerHandle {
836 registry,
837 max_request_body,
838 max_response_body,
839 max_inflight_requests,
840 is_tls: tls_config.is_some(),
841 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
842 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
843 monitor_task,
844 tls_config: tls_rustls_cfg,
845 tls_source,
846 };
847 if let (Some(tls_cfg), Some(source)) =
852 (handle.tls_config.as_ref(), handle.tls_source.as_ref())
853 {
854 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
855 tls_cfg.clone(),
856 source.clone(),
857 host_owned.clone(),
858 port,
859 ));
860 camel_component_api::tls_source::TlsReloadRegistry::global()
861 .register(handler);
862 }
863 Ok::<ServerHandle, CamelError>(handle)
864 }
865 })
866 .await?;
867
868 Ok(handle.registry.clone())
869 }
870
871 pub async fn unregister(&self, host: &str, port: u16) {
875 debug!(
876 host = host,
877 port = port,
878 "consumer unregistered from HTTP server"
879 );
880 }
881
882 #[cfg(test)]
889 pub fn reset() {
890 let instance = Self::global();
891 let mut guard = instance
892 .inner
893 .lock()
894 .expect("ServerRegistry lock poisoned during test reset");
895 guard.clear();
896 }
897}
898
899use axum::{
904 Router,
905 body::Body as AxumBody,
906 extract::{Request, State},
907 http::{Response, StatusCode},
908 response::IntoResponse,
909};
910
911#[derive(Clone)]
912pub(crate) struct AppState {
913 registry: HttpRouteRegistry,
914 max_request_body: usize,
915 max_response_body: usize,
916 inflight: Arc<tokio::sync::Semaphore>,
917}
918
919async fn run_axum_server(
920 listener: tokio::net::TcpListener,
921 registry: HttpRouteRegistry,
922 max_request_body: usize,
923 max_response_body: usize,
924 inflight: Arc<tokio::sync::Semaphore>,
925 runtime: Arc<dyn RuntimeObservability>,
926 route_id: String,
927) {
928 let state = AppState {
929 registry,
930 max_request_body,
931 max_response_body,
932 inflight,
933 };
934 let app = Router::new().fallback(dispatch_handler).with_state(state);
935
936 axum::serve(listener, app).await.unwrap_or_else(|e| {
937 runtime
938 .metrics()
939 .increment_errors(&route_id, "e:http:accept");
940 tracing::error!(error = %e, "Axum server error");
942 });
943}
944
945#[allow(clippy::too_many_arguments)]
946async fn run_axum_server_tls(
947 listener: std::net::TcpListener,
948 tls_cfg: axum_server::tls_rustls::RustlsConfig,
949 registry: HttpRouteRegistry,
950 max_request_body: usize,
951 max_response_body: usize,
952 inflight: Arc<tokio::sync::Semaphore>,
953 runtime: Arc<dyn RuntimeObservability>,
954 route_id: String,
955) {
956 let state = AppState {
957 registry,
958 max_request_body,
959 max_response_body,
960 inflight,
961 };
962 let app = Router::new().fallback(dispatch_handler).with_state(state);
963
964 axum_server::from_tcp_rustls(listener, tls_cfg)
968 .serve(app.into_make_service())
969 .await
970 .unwrap_or_else(|e| {
971 runtime
972 .metrics()
973 .increment_errors(&route_id, "e:http:accept-tls");
974 tracing::error!(error = %e, "Axum TLS server error");
976 });
977}
978
979async fn monitor_axum_task(
987 handle: tokio::task::JoinHandle<()>,
988 addr: String,
989 runtime: Arc<dyn RuntimeObservability>,
990 route_id: String,
991) {
992 match handle.await {
993 Ok(()) => {
994 }
996 Err(join_err) => {
997 runtime
998 .metrics()
999 .increment_errors(&route_id, "e:http:server-task-exited");
1000 tracing::error!(
1002 addr = %addr,
1003 error = %join_err,
1004 "Axum server task exited unexpectedly — all routes on this port are now dead"
1005 );
1006 }
1007 }
1008}
1009
1010fn load_tls_config(
1013 cert_path: &str,
1014 key_path: &str,
1015) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1016 use std::fs::File;
1017 use std::io::BufReader;
1018
1019 let cert_file = File::open(cert_path)
1020 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1021 let key_file = File::open(key_path)
1022 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1023
1024 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1025 .collect::<Result<Vec<_>, _>>()
1026 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1027
1028 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1029 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1030 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1031
1032 tokio_rustls::rustls::ServerConfig::builder()
1033 .with_no_client_auth()
1034 .with_single_cert(certs, key)
1035 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1036}
1037
1038async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1039 let path = req.uri().path().to_owned();
1040 let method = req.method().to_string();
1041
1042 let api_sender = {
1059 let inner = state.registry.inner.read().await;
1060 inner.api_routes.get(&path).cloned()
1061 }; let (rest_sender, path_params) = if api_sender.is_some() {
1064 (None, Default::default())
1066 } else {
1067 let inner = state.registry.inner.read().await;
1068 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1069 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1070 rest_match::MatchOutcome::Ambiguous => {
1071 tracing::warn!(
1077 method = %method,
1078 path = %path,
1079 "ambiguous REST template match — returning 500"
1080 );
1081 return Response::builder()
1082 .status(StatusCode::INTERNAL_SERVER_ERROR)
1083 .body(AxumBody::from("Internal Server Error"))
1084 .expect("infallible"); }
1086 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1087 }
1088 }; let sender = api_sender.or(rest_sender);
1091
1092 if let Some(sender) = sender {
1093 let query = req.uri().query().unwrap_or("").to_string();
1094 let headers = req.headers().clone();
1095
1096 let content_length: Option<u64> = headers
1098 .get(http::header::CONTENT_LENGTH)
1099 .and_then(|v| v.to_str().ok())
1100 .and_then(|s| s.parse().ok());
1101
1102 if let Some(len) = content_length
1103 && len > state.max_request_body as u64
1104 {
1105 return Response::builder()
1106 .status(StatusCode::PAYLOAD_TOO_LARGE)
1107 .body(AxumBody::from("Request body exceeds configured limit"))
1108 .expect("infallible"); }
1110
1111 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1112 Ok(permit) => permit,
1113 Err(_) => {
1114 return Response::builder()
1115 .status(StatusCode::SERVICE_UNAVAILABLE)
1116 .body(AxumBody::from("Service Unavailable"))
1117 .expect("infallible"); }
1119 };
1120
1121 let content_type = headers
1123 .get(http::header::CONTENT_TYPE)
1124 .and_then(|v| v.to_str().ok())
1125 .map(|s| s.to_string());
1126
1127 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1128 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1129 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1130
1131 let stream_body = StreamBody {
1132 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1133 metadata: StreamMetadata {
1134 size_hint: content_length,
1135 content_type,
1136 origin: None,
1137 },
1138 };
1139
1140 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1141 let envelope = RequestEnvelope {
1142 method,
1143 path,
1144 query,
1145 headers,
1146 body: stream_body,
1147 path_params,
1148 reply_tx,
1149 };
1150
1151 if sender.send(envelope).await.is_err() {
1152 return Response::builder()
1153 .status(StatusCode::SERVICE_UNAVAILABLE)
1154 .body(AxumBody::from("Consumer unavailable"))
1155 .expect("infallible"); }
1157
1158 match reply_rx.await {
1159 Ok(reply) => {
1160 let reply = match reply.body {
1161 HttpReplyBody::Bytes(b)
1162 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1163 {
1164 HttpReply {
1165 status: 500,
1166 headers: vec![],
1167 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1168 "Response body exceeds configured limit",
1169 )),
1170 }
1171 }
1172 _ => reply,
1173 };
1174
1175 let status =
1176 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1177 let mut builder = Response::builder().status(status);
1178 for (k, v) in &reply.headers {
1179 builder = builder.header(k.as_str(), v.as_str());
1180 }
1181 match reply.body {
1182 HttpReplyBody::Bytes(b) => {
1183 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1184 Response::builder()
1185 .status(StatusCode::INTERNAL_SERVER_ERROR)
1186 .body(AxumBody::from("Invalid response headers from consumer"))
1187 .expect("infallible") })
1189 }
1190 HttpReplyBody::Stream(stream) => builder
1191 .body(AxumBody::from_stream(stream))
1192 .unwrap_or_else(|_| {
1193 Response::builder()
1194 .status(StatusCode::INTERNAL_SERVER_ERROR)
1195 .body(AxumBody::from("Invalid response headers from consumer"))
1196 .expect("infallible") }),
1198 }
1199 }
1200 Err(_) => Response::builder()
1201 .status(StatusCode::INTERNAL_SERVER_ERROR)
1202 .body(AxumBody::from("Pipeline error"))
1203 .expect("infallible"), }
1205 } else {
1206 static_dispatch::dispatch_static(&state, req, &path).await
1208 }
1209}
1210
1211fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1212 len > max
1213}
1214
1215fn title_case_header(name: &str) -> String {
1216 name.split('-')
1217 .map(|part| {
1218 let mut chars = part.chars();
1219 match chars.next() {
1220 None => String::new(),
1221 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1222 }
1223 })
1224 .collect::<Vec<_>>()
1225 .join("-")
1226}
1227
1228pub struct HttpConsumer {
1233 config: HttpServerConfig,
1234 runtime: Arc<dyn RuntimeObservability>,
1236}
1237
1238impl HttpConsumer {
1239 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1240 Self { config, runtime }
1241 }
1242}
1243
1244#[async_trait::async_trait]
1245impl Consumer for HttpConsumer {
1246 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1247 use camel_component_api::{Body, Exchange, Message};
1248
1249 let registry = ServerRegistry::global()
1250 .get_or_spawn(
1251 &self.config.host,
1252 self.config.port,
1253 self.config.max_request_body,
1254 self.config.max_response_body,
1255 self.config.max_inflight_requests,
1256 self.runtime.clone(),
1257 ctx.route_id().to_string(),
1258 self.config.tls_config.clone(),
1259 )
1260 .await?;
1261
1262 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
1264 if let Some(method) = self.config.method.clone() {
1271 let segments = rest_match::parse_path_template(&self.config.path);
1272 registry
1273 .register_rest_endpoint(method, segments, env_tx)
1274 .await;
1275 } else {
1276 registry
1277 .register_api_route(self.config.path.clone(), env_tx)
1278 .await;
1279 }
1280
1281 ctx.mark_ready();
1290
1291 let path = self.config.path.clone();
1292 let registry_for_cleanup = registry.clone();
1293 let cancel_token = ctx.cancel_token();
1294 loop {
1295 tokio::select! {
1296 _ = ctx.cancelled() => {
1297 break;
1298 }
1299 envelope = env_rx.recv() => {
1300 let Some(envelope) = envelope else { break; };
1301
1302 let mut msg = Message::default();
1304
1305 msg.set_header("CamelHttpMethod",
1307 serde_json::Value::String(envelope.method.clone()));
1308 msg.set_header("CamelHttpPath",
1309 serde_json::Value::String(envelope.path.clone()));
1310 msg.set_header("CamelHttpQuery",
1311 serde_json::Value::String(envelope.query.clone()));
1312
1313 for (param_name, param_value) in &envelope.path_params {
1320 msg.set_header(
1321 format!("CamelHttpPath_{param_name}"),
1322 serde_json::Value::String(param_value.clone()),
1323 );
1324 }
1325
1326 for (k, v) in &envelope.headers {
1328 if let Ok(val_str) = v.to_str() {
1329 msg.set_header(
1330 title_case_header(k.as_str()),
1331 serde_json::Value::String(val_str.to_string()),
1332 );
1333 }
1334 }
1335
1336 msg.body = Body::Stream(envelope.body);
1339
1340 #[allow(unused_mut)]
1341 let mut exchange = Exchange::new(msg);
1342
1343 #[cfg(feature = "otel")]
1345 {
1346 let headers: HashMap<String, String> = envelope
1347 .headers
1348 .iter()
1349 .filter_map(|(k, v)| {
1350 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1351 })
1352 .collect();
1353 camel_otel::extract_into_exchange(&mut exchange, &headers);
1354 }
1355
1356 let reply_tx = envelope.reply_tx;
1357 let sender = ctx.sender().clone();
1358 let path_clone = path.clone();
1359 let cancel = cancel_token.clone();
1360
1361 tokio::spawn(async move {
1381 if cancel.is_cancelled() {
1389 let _ = reply_tx.send(HttpReply {
1390 status: 503,
1391 headers: vec![],
1392 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1393 });
1394 return;
1395 }
1396
1397 let (tx, rx) = tokio::sync::oneshot::channel();
1399 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1400 exchange,
1401 reply_tx: Some(tx),
1402 };
1403
1404 let result = match sender.send(envelope).await {
1405 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1406 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1407 }
1408 .and_then(|r| r);
1409
1410 let reply = match result {
1411 Ok(out) => {
1412 let status = out
1413 .input
1414 .header("CamelHttpResponseCode")
1415 .and_then(|v| {
1416 let raw = v.as_u64()
1417 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1418 let code = raw as u16;
1419 (100..1000).contains(&code).then_some(code)
1420 })
1421 .unwrap_or(200);
1422
1423 let user_content_type = out
1424 .input
1425 .header("Content-Type")
1426 .and_then(|v| v.as_str().map(|s| s.to_string()));
1427
1428 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1429 Body::Empty => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1430 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1431 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1432 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1433 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1434 v.to_string().into_bytes(),
1435 )), Some("application/json".to_string())),
1436 Body::Stream(s) => {
1437 let ct = s.metadata.content_type.clone();
1438 match s.stream.lock().await.take() {
1439 Some(stream) => (
1440 HttpReplyBody::Stream(stream),
1441 ct,
1442 ),
1443 None => {
1444 tracing::error!(
1446 "Body::Stream already consumed before HTTP reply — returning 500"
1447 );
1448 let error_reply = HttpReply {
1449 status: 500,
1450 headers: vec![],
1451 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1452 };
1453 if reply_tx.send(error_reply).is_err() {
1454 debug!("reply_tx dropped before error reply could be sent");
1455 }
1456 return;
1457 }
1458 }
1459 }
1460 };
1461
1462 let mut resp_headers: Vec<(String, String)> = out
1463 .input
1464 .headers
1465 .iter()
1466 .filter(|(k, _)| !k.starts_with("Camel"))
1467 .filter(|(k, _)| {
1468 !matches!(
1469 k.to_lowercase().as_str(),
1470 "content-length"
1471 | "content-type"
1472 | "transfer-encoding"
1473 | "connection"
1474 | "cache-control"
1475 | "date"
1476 | "pragma"
1477 | "trailer"
1478 | "upgrade"
1479 | "via"
1480 | "warning"
1481 | "host"
1482 | "user-agent"
1483 | "accept"
1484 | "accept-encoding"
1485 | "accept-language"
1486 | "accept-charset"
1487 | "authorization"
1488 | "proxy-authorization"
1489 | "cookie"
1490 | "expect"
1491 | "from"
1492 | "if-match"
1493 | "if-modified-since"
1494 | "if-none-match"
1495 | "if-range"
1496 | "if-unmodified-since"
1497 | "max-forwards"
1498 | "proxy-connection"
1499 | "range"
1500 | "referer"
1501 | "te"
1502 )
1503 })
1504 .filter_map(|(k, v)| {
1505 v.as_str().map(|s| (k.clone(), s.to_string()))
1506 })
1507 .collect();
1508
1509 let content_type = user_content_type
1510 .or(inferred_content_type);
1511 if let Some(ct) = content_type {
1512 resp_headers.push(("Content-Type".to_string(), ct));
1513 }
1514
1515 HttpReply {
1516 status,
1517 headers: resp_headers,
1518 body: reply_body,
1519 }
1520 }
1521 Err(e) => {
1522 pipeline_error_to_reply(e, &path_clone)
1523 }
1524 };
1525
1526 let _ = reply_tx.send(reply);
1528 });
1529 }
1530 }
1531 }
1532
1533 if let Some(method) = &self.config.method {
1538 registry_for_cleanup
1539 .unregister_rest_endpoint(method, &path)
1540 .await;
1541 } else {
1542 registry_for_cleanup.unregister_api_route(&path).await;
1543 }
1544
1545 ServerRegistry::global()
1549 .unregister(&self.config.host, self.config.port)
1550 .await;
1551
1552 Ok(())
1553 }
1554
1555 async fn stop(&mut self) -> Result<(), CamelError> {
1556 Ok(())
1557 }
1558
1559 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1560 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1561 }
1562
1563 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1570 camel_component_api::ConsumerStartupMode::Explicit
1571 }
1572}
1573
1574pub struct HttpComponent {
1579 config: HttpConfig,
1580}
1581
1582pub(crate) fn build_client(
1583 config: &HttpConfig,
1584 cookie_handling: CookieHandling,
1585 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1586) -> reqwest::Client {
1587 let mut builder = reqwest::Client::builder()
1588 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1590 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1591 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1592
1593 builder = builder.redirect(reqwest::redirect::Policy::none());
1597
1598 if let Some((host, addrs)) = resolve_override {
1599 builder = builder.resolve_to_addrs(host, addrs);
1600 }
1601
1602 if matches!(cookie_handling, CookieHandling::InMemory) {
1603 }
1605
1606 if let Some(tls) = &config.tls
1607 && tls.enabled
1608 {
1609 if tls.insecure || !tls.verify_peer {
1610 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1612 builder = builder.danger_accept_invalid_certs(true);
1613 }
1614
1615 if let Some(ca_path) = &tls.ca_cert_path
1616 && let Ok(ca_bytes) = std::fs::read(ca_path)
1617 {
1618 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1619 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1620 if let Ok(ca_cert) = cert {
1621 builder = builder.add_root_certificate(ca_cert);
1622 }
1623 }
1624
1625 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1626 && let (Ok(cert_bytes), Ok(key_bytes)) =
1627 (std::fs::read(cert_path), std::fs::read(key_path))
1628 {
1629 let mut identity_pem = cert_bytes;
1630 identity_pem.extend_from_slice(&key_bytes);
1631 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1632 builder = builder.identity(identity);
1633 }
1634 }
1635 }
1636
1637 builder
1638 .build()
1639 .expect("reqwest::Client::build() with valid config should not fail") }
1641
1642impl HttpComponent {
1643 pub fn new() -> Self {
1644 let config = HttpConfig::default();
1645 Self { config }
1646 }
1647
1648 pub fn with_config(config: HttpConfig) -> Self {
1649 Self { config }
1650 }
1651
1652 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1653 match config {
1654 Some(cfg) => Self::with_config(cfg),
1655 None => Self::new(),
1656 }
1657 }
1658}
1659
1660impl Default for HttpComponent {
1661 fn default() -> Self {
1662 Self::new()
1663 }
1664}
1665
1666impl Component for HttpComponent {
1667 fn scheme(&self) -> &str {
1668 "http"
1669 }
1670
1671 fn metadata(&self) -> ComponentMetadata {
1672 ComponentMetadata {
1673 scheme: "http".to_string(),
1674 version: env!("CARGO_PKG_VERSION").to_string(),
1675 description: "HTTP client and server component".to_string(),
1676 uri_syntax: "http://host:port/path?options".to_string(),
1677 capabilities: ComponentCapabilities {
1678 supports_consumer: true,
1679 supports_producer: true,
1680 supports_streaming: true,
1681 ..Default::default()
1682 },
1683 uri_options: vec![
1684 UriOption::new(
1685 "httpMethod",
1686 "HTTP method. Defaults to CamelHttpMethod header or POST/GET",
1687 OptionKind::String,
1688 ),
1689 UriOption::new(
1690 "throwExceptionOnFailure",
1691 "Throw on non-2xx status",
1692 OptionKind::Bool,
1693 )
1694 .with_default("true"),
1695 UriOption::new(
1696 "okStatusCodeRange",
1697 "Success status code range",
1698 OptionKind::String,
1699 )
1700 .with_default("200-299"),
1701 UriOption::new(
1702 "responseTimeout",
1703 "Response timeout in milliseconds",
1704 OptionKind::Int,
1705 ),
1706 UriOption::new(
1707 "maxBodySize",
1708 "Max request/response body bytes",
1709 OptionKind::Int,
1710 )
1711 .with_default("10485760"),
1712 UriOption::new(
1713 "authMethod",
1714 "Authentication method",
1715 OptionKind::Enum(vec!["Basic".to_string(), "Bearer".to_string()]),
1716 ),
1717 UriOption::new("authUsername", "Basic auth username", OptionKind::String).secret(),
1718 UriOption::new("authPassword", "Basic auth password", OptionKind::String).secret(),
1719 UriOption::new("authBearerToken", "Bearer auth token", OptionKind::String).secret(),
1720 UriOption::new("userAgent", "User-Agent header", OptionKind::String),
1721 UriOption::new("followRedirects", "Follow HTTP redirects", OptionKind::Bool)
1722 .with_default("false"),
1723 UriOption::new("maxRedirects", "Max redirect hops", OptionKind::Int)
1724 .with_default("10"),
1725 ],
1726 ..ComponentMetadata::minimal("http")
1727 }
1728 }
1729
1730 fn create_endpoint(
1731 &self,
1732 uri: &str,
1733 ctx: &dyn camel_component_api::ComponentContext,
1734 ) -> Result<Box<dyn Endpoint>, CamelError> {
1735 self.config.validate()?;
1736 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1737 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1738 let client = build_client(&self.config, config.cookie_handling, None);
1739 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1740 server_config.host.clone(),
1741 server_config.port,
1742 )));
1743 Ok(Box::new(HttpEndpoint {
1744 uri: uri.to_string(),
1745 config,
1746 server_config,
1747 client,
1748 http_config: self.config.clone(),
1749 }))
1750 }
1751}
1752
1753pub struct HttpsComponent {
1754 config: HttpConfig,
1755}
1756
1757impl HttpsComponent {
1758 pub fn new() -> Self {
1759 let config = HttpConfig::default();
1760 Self { config }
1761 }
1762
1763 pub fn with_config(config: HttpConfig) -> Self {
1764 Self { config }
1765 }
1766
1767 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1768 match config {
1769 Some(cfg) => Self::with_config(cfg),
1770 None => Self::new(),
1771 }
1772 }
1773}
1774
1775impl Default for HttpsComponent {
1776 fn default() -> Self {
1777 Self::new()
1778 }
1779}
1780
1781impl Component for HttpsComponent {
1782 fn scheme(&self) -> &str {
1783 "https"
1784 }
1785
1786 fn metadata(&self) -> ComponentMetadata {
1787 ComponentMetadata {
1791 scheme: "https".to_string(),
1792 version: env!("CARGO_PKG_VERSION").to_string(),
1793 description: "HTTPS client and server component (TLS over HTTP)".to_string(),
1794 uri_syntax: "https://host:port/path?options".to_string(),
1795 capabilities: ComponentCapabilities {
1796 supports_consumer: true,
1797 supports_producer: true,
1798 supports_streaming: true,
1799 ..Default::default()
1800 },
1801 uri_options: vec![
1802 UriOption::new(
1803 "httpMethod",
1804 "HTTP method. Defaults to CamelHttpMethod header or POST/GET",
1805 OptionKind::String,
1806 ),
1807 UriOption::new(
1808 "throwExceptionOnFailure",
1809 "Throw on non-2xx status",
1810 OptionKind::Bool,
1811 )
1812 .with_default("true"),
1813 UriOption::new(
1814 "okStatusCodeRange",
1815 "Success status code range",
1816 OptionKind::String,
1817 )
1818 .with_default("200-299"),
1819 UriOption::new(
1820 "responseTimeout",
1821 "Response timeout in milliseconds",
1822 OptionKind::Int,
1823 ),
1824 UriOption::new(
1825 "maxBodySize",
1826 "Max request/response body bytes",
1827 OptionKind::Int,
1828 )
1829 .with_default("10485760"),
1830 UriOption::new(
1831 "authMethod",
1832 "Authentication method",
1833 OptionKind::Enum(vec!["Basic".to_string(), "Bearer".to_string()]),
1834 ),
1835 UriOption::new("authUsername", "Basic auth username", OptionKind::String).secret(),
1836 UriOption::new("authPassword", "Basic auth password", OptionKind::String).secret(),
1837 UriOption::new("authBearerToken", "Bearer auth token", OptionKind::String).secret(),
1838 UriOption::new("userAgent", "User-Agent header", OptionKind::String),
1839 UriOption::new("followRedirects", "Follow HTTP redirects", OptionKind::Bool)
1840 .with_default("false"),
1841 UriOption::new("maxRedirects", "Max redirect hops", OptionKind::Int)
1842 .with_default("10"),
1843 ],
1844 ..ComponentMetadata::minimal("https")
1845 }
1846 }
1847
1848 fn create_endpoint(
1849 &self,
1850 uri: &str,
1851 ctx: &dyn camel_component_api::ComponentContext,
1852 ) -> Result<Box<dyn Endpoint>, CamelError> {
1853 self.config.validate()?;
1854 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1855 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1856 let client = build_client(&self.config, config.cookie_handling, None);
1857 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1858 server_config.host.clone(),
1859 server_config.port,
1860 )));
1861 Ok(Box::new(HttpEndpoint {
1862 uri: uri.to_string(),
1863 config,
1864 server_config,
1865 client,
1866 http_config: self.config.clone(),
1867 }))
1868 }
1869}
1870
1871struct HttpEndpoint {
1876 uri: String,
1877 config: HttpEndpointConfig,
1878 server_config: HttpServerConfig,
1879 client: reqwest::Client,
1880 http_config: HttpConfig,
1881}
1882
1883impl Endpoint for HttpEndpoint {
1884 fn uri(&self) -> &str {
1885 &self.uri
1886 }
1887
1888 fn create_consumer(
1889 &self,
1890 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1891 ) -> Result<Box<dyn Consumer>, CamelError> {
1892 let scheme_is_https = self.server_config.scheme == "https";
1895 let has_tls = self.server_config.tls_config.is_some();
1896
1897 if scheme_is_https && !has_tls {
1898 return Err(CamelError::EndpointCreationFailed(
1899 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1900 ));
1901 }
1902 if !scheme_is_https && has_tls {
1903 return Err(CamelError::EndpointCreationFailed(
1904 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1905 ));
1906 }
1907 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1908 }
1909
1910 fn create_producer(
1911 &self,
1912 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1913 _ctx: &ProducerContext,
1914 ) -> Result<BoxProcessor, CamelError> {
1915 let producer = HttpProducer {
1916 config: Arc::new(self.config.clone()),
1917 client: self.client.clone(),
1918 http_config: Arc::new(self.http_config.clone()),
1919 };
1920 if let Some(ref provider) = self.config.token_provider {
1921 let layer = BearerTokenLayer::new(Arc::clone(provider));
1922 Ok(BoxProcessor::new(layer.layer(producer)))
1923 } else {
1924 Ok(BoxProcessor::new(producer))
1925 }
1926 }
1927}
1928
1929#[derive(Clone)]
1934struct HttpProducer {
1935 config: Arc<HttpEndpointConfig>,
1936 client: reqwest::Client,
1937 http_config: Arc<HttpConfig>,
1938}
1939
1940impl HttpProducer {
1941 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1942 if let Some(ref method) = config.http_method {
1943 return method.to_uppercase();
1944 }
1945 if let Some(method) = exchange
1946 .input
1947 .header("CamelHttpMethod")
1948 .and_then(|v| v.as_str())
1949 {
1950 return method.to_uppercase();
1951 }
1952 if !exchange.input.body.is_empty() {
1953 return "POST".to_string();
1954 }
1955 "GET".to_string()
1956 }
1957
1958 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1959 if let Some(uri) = exchange
1960 .input
1961 .header("CamelHttpUri")
1962 .and_then(|v| v.as_str())
1963 {
1964 let mut url = uri.to_string();
1965 if let Some(path) = exchange
1966 .input
1967 .header("CamelHttpPath")
1968 .and_then(|v| v.as_str())
1969 {
1970 if !url.ends_with('/') && !path.starts_with('/') {
1971 url.push('/');
1972 }
1973 url.push_str(path);
1974 }
1975 if let Some(query) = exchange
1976 .input
1977 .header("CamelHttpQuery")
1978 .and_then(|v| v.as_str())
1979 {
1980 url.push('?');
1981 url.push_str(query);
1982 }
1983 return url;
1984 }
1985
1986 let mut url = config.base_url.clone();
1987
1988 if let Some(path) = exchange
1989 .input
1990 .header("CamelHttpPath")
1991 .and_then(|v| v.as_str())
1992 {
1993 if !url.ends_with('/') && !path.starts_with('/') {
1994 url.push('/');
1995 }
1996 url.push_str(path);
1997 }
1998
1999 if let Some(query) = exchange
2000 .input
2001 .header("CamelHttpQuery")
2002 .and_then(|v| v.as_str())
2003 {
2004 url.push('?');
2005 url.push_str(query);
2006 } else if !config.query_params.is_empty() {
2007 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2009 parsed.query_pairs_mut().append_pair(k, v);
2010 }
2011 url = parsed.to_string();
2012 }
2013
2014 url
2015 }
2016
2017 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2018 status >= range.0 && status <= range.1
2019 }
2020}
2021
2022impl Service<Exchange> for HttpProducer {
2023 type Response = Exchange;
2024 type Error = CamelError;
2025 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2026
2027 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2028 Poll::Ready(Ok(()))
2029 }
2030
2031 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2032 let config = self.config.clone();
2033 let client = self.client.clone();
2034 let http_config = self.http_config.clone();
2035
2036 Box::pin(async move {
2037 let method_str = HttpProducer::resolve_method(&exchange, &config);
2038 let url = HttpProducer::resolve_url(&exchange, &config);
2039
2040 ssrf::validate_url_for_ssrf(&url, &config)?;
2042
2043 let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2048 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2049 build_client(
2050 &http_config,
2051 config.cookie_handling,
2052 Some((host.as_str(), addrs)),
2053 )
2054 } else {
2055 client
2056 };
2057
2058 debug!(
2059 correlation_id = %exchange.correlation_id(),
2060 method = %method_str,
2061 url = %url,
2062 "HTTP request"
2063 );
2064
2065 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2066 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2067 })?;
2068
2069 let mut collected_headers: Vec<(
2071 reqwest::header::HeaderName,
2072 reqwest::header::HeaderValue,
2073 )> = Vec::new();
2074
2075 if let Some(user_agent) = &config.user_agent
2076 && !config.bridge_endpoint
2077 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2078 {
2079 collected_headers.push((reqwest::header::USER_AGENT, val));
2080 }
2081
2082 #[cfg(feature = "otel")]
2084 let should_inject_otel = !config.bridge_endpoint;
2085 #[cfg(feature = "otel")]
2086 if should_inject_otel {
2087 let mut otel_headers = HashMap::new();
2088 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2089 for (k, v) in otel_headers {
2090 if let (Ok(name), Ok(val)) = (
2091 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2092 reqwest::header::HeaderValue::from_str(&v),
2093 ) {
2094 collected_headers.push((name, val));
2095 }
2096 }
2097 }
2098
2099 for (key, value) in &exchange.input.headers {
2100 if !key.starts_with("Camel")
2101 && !config
2102 .skip_request_headers
2103 .iter()
2104 .any(|h| h.eq_ignore_ascii_case(key))
2105 && let Some(val_str) = value.as_str()
2106 && let (Ok(name), Ok(val)) = (
2107 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2108 reqwest::header::HeaderValue::from_str(val_str),
2109 )
2110 {
2111 collected_headers.push((name, val));
2112 }
2113 }
2114
2115 if !config.bridge_endpoint {
2117 match &config.auth {
2118 HttpAuth::None => {}
2119 HttpAuth::Basic { username, password } => {
2120 use base64::Engine;
2121 let credentials = format!("{username}:{password}");
2123 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2124 if let Ok(val) =
2125 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2126 {
2127 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2128 }
2129 }
2130 HttpAuth::Bearer { token } => {
2131 let bearer = format!("Bearer {token}");
2133 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2134 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2135 }
2136 }
2137 }
2138
2139 if config.connection_close
2140 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2141 {
2142 collected_headers.push((reqwest::header::CONNECTION, val));
2143 }
2144 }
2145
2146 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2148 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2149 None } else {
2151 let body = std::mem::take(&mut exchange.input.body);
2152 let bytes = body.into_bytes(config.max_body_size).await?;
2153 if bytes.is_empty() {
2154 None
2155 } else {
2156 Some(bytes.to_vec())
2157 }
2158 };
2159
2160 let response = if config.follow_redirects && !is_stream_body {
2161 ssrf::send_with_ssrf_safe_redirects(
2163 &client,
2164 &http_config,
2165 &config,
2166 method,
2167 &url,
2168 collected_headers,
2169 materialized_body,
2170 config.max_redirects,
2171 config.response_timeout,
2172 )
2173 .await?
2174 } else {
2175 let mut request = client.request(method, &url);
2177
2178 if let Some(timeout) = config.response_timeout {
2179 request = request.timeout(timeout);
2180 }
2181
2182 for (name, value) in &collected_headers {
2183 request = request.header(name, value);
2184 }
2185
2186 if is_stream_body {
2187 if let Body::Stream(ref s) = exchange.input.body {
2188 let mut stream_lock = s.stream.lock().await;
2189 if let Some(stream) = stream_lock.take() {
2190 request = request.body(reqwest::Body::wrap_stream(stream));
2191 } else {
2192 return Err(CamelError::AlreadyConsumed);
2193 }
2194 }
2195 } else if let Some(ref body_bytes) = materialized_body {
2196 request = request.body(body_bytes.clone());
2197 }
2198
2199 request
2200 .send()
2201 .await
2202 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2203 };
2204
2205 let status_code = response.status().as_u16();
2206 let status_text = response
2207 .status()
2208 .canonical_reason()
2209 .unwrap_or("Unknown")
2210 .to_string();
2211
2212 for (key, value) in response.headers() {
2213 if config
2214 .skip_response_headers
2215 .iter()
2216 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2217 {
2218 continue;
2219 }
2220 if let Ok(val_str) = value.to_str() {
2221 exchange.input.set_header(
2222 title_case_header(key.as_str()),
2223 serde_json::Value::String(val_str.to_string()),
2224 );
2225 }
2226 }
2227
2228 exchange.input.set_header(
2229 "CamelHttpResponseCode",
2230 serde_json::Value::Number(status_code.into()),
2231 );
2232 exchange.input.set_header(
2233 "CamelHttpResponseText",
2234 serde_json::Value::String(status_text.clone()),
2235 );
2236
2237 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2239 let response_body = tokio::time::timeout(read_timeout, async {
2240 if let Some(content_len) = response.content_length()
2242 && content_len > config.max_response_bytes as u64
2243 {
2244 return Err(CamelError::ProcessorError(format!(
2245 "Response body too large: {} bytes exceeds limit of {} bytes",
2246 content_len, config.max_response_bytes
2247 )));
2248 }
2249 use futures::TryStreamExt;
2251 let mut stream = response.bytes_stream();
2252 let mut total: usize = 0;
2253 let mut collected = Vec::new();
2254 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2255 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2256 })? {
2257 total += chunk.len();
2258 if total > config.max_response_bytes {
2259 return Err(CamelError::ProcessorError(format!(
2260 "Response body too large: {} bytes exceeds limit of {} bytes",
2261 total, config.max_response_bytes
2262 )));
2263 }
2264 collected.push(chunk);
2265 }
2266 let mut result = bytes::BytesMut::with_capacity(total);
2267 for chunk in collected {
2268 result.extend_from_slice(&chunk);
2269 }
2270 Ok::<bytes::Bytes, CamelError>(result.freeze())
2271 })
2272 .await
2273 .map_err(|_| {
2274 CamelError::ProcessorError(format!(
2275 "Read timeout after {}ms",
2276 config.read_timeout_ms
2277 ))
2278 })??;
2279
2280 if config.throw_exception_on_failure
2281 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2282 {
2283 return Err(CamelError::HttpOperationFailed {
2284 method: method_str,
2285 url,
2286 status_code,
2287 status_text,
2288 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2289 });
2290 }
2291
2292 if !response_body.is_empty() {
2293 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2294 }
2295
2296 debug!(
2297 correlation_id = %exchange.correlation_id(),
2298 status = status_code,
2299 url = %url,
2300 "HTTP response"
2301 );
2302 Ok(exchange)
2303 })
2304 }
2305}
2306
2307#[cfg(test)]
2317pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2318
2319fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2327 match e {
2328 CamelError::Unauthenticated(msg) => {
2329 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2330 HttpReply {
2331 status: 401,
2332 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2333 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2334 }
2335 }
2336 CamelError::Unauthorized(msg) => {
2337 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2338 HttpReply {
2339 status: 403,
2340 headers: vec![],
2341 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2342 }
2343 }
2344 CamelError::TypeConversionFailed(msg) => {
2345 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2346 let body = serde_json::to_string(&serde_json::json!({
2347 "error": "bad_request",
2348 "message": msg,
2349 }))
2350 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2352 status: 400,
2353 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2354 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2355 }
2356 }
2357 CamelError::ValidationError(msg) => {
2358 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2359 let body = serde_json::to_string(&serde_json::json!({
2360 "error": "validation_error",
2361 "message": msg,
2362 }))
2363 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2365 status: 400,
2366 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2367 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2368 }
2369 }
2370 CamelError::ConsumerStopping => {
2371 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2372 HttpReply {
2373 status: 503,
2374 headers: vec![],
2375 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2376 }
2377 }
2378 e => {
2379 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2381 HttpReply {
2382 status: 500,
2383 headers: vec![],
2384 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2385 }
2386 }
2387 }
2388}
2389
2390#[cfg(test)]
2391mod tests {
2392 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2393 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2394 std::sync::Arc::new(PanicRuntimeObservability)
2395 }
2396 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2397 std::sync::Arc::new(PanicRuntimeObservability)
2398 }
2399 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2400 std::sync::Arc::new(NoopRuntimeObservability)
2401 }
2402
2403 use super::*;
2404 use crate::rest_match::PathSegment;
2405 use camel_component_api::{Message, NoOpComponentContext};
2406 use std::sync::Arc;
2407 use std::time::Duration;
2408
2409 fn test_producer_ctx() -> ProducerContext {
2410 ProducerContext::new()
2411 }
2412
2413 #[test]
2414 fn test_http_config_defaults() {
2415 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2416 assert_eq!(config.base_url, "http://localhost:8080/api");
2417 assert!(config.http_method.is_none());
2418 assert!(config.throw_exception_on_failure);
2419 assert_eq!(config.ok_status_code_range, (200, 299));
2420 assert!(config.response_timeout.is_none());
2421 assert!(matches!(config.auth, HttpAuth::None));
2422 assert!(matches!(config.cookie_handling, CookieHandling::Disabled));
2423 assert!(!config.bridge_endpoint);
2424 assert!(!config.connection_close);
2425 }
2426
2427 #[test]
2428 fn test_http_config_scheme() {
2429 assert_eq!(HttpEndpointConfig::scheme(), "http");
2431 }
2432
2433 #[test]
2434 fn test_http_config_from_components() {
2435 let components = camel_component_api::UriComponents {
2437 scheme: "https".to_string(),
2438 path: "//api.example.com/v1".to_string(),
2439 params: std::collections::HashMap::from([(
2440 "httpMethod".to_string(),
2441 "POST".to_string(),
2442 )]),
2443 };
2444 let config = HttpEndpointConfig::from_components(components).unwrap();
2445 assert_eq!(config.base_url, "https://api.example.com/v1");
2446 assert_eq!(config.http_method, Some("POST".to_string()));
2447 }
2448
2449 #[test]
2450 fn test_http_config_with_options() {
2451 let config = HttpEndpointConfig::from_uri(
2452 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2453 ).unwrap();
2454 assert_eq!(config.base_url, "https://api.example.com/v1");
2455 assert_eq!(config.http_method, Some("PUT".to_string()));
2456 assert!(!config.throw_exception_on_failure);
2457 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2458 }
2459
2460 #[test]
2461 fn test_http_endpoint_config_auth_and_headers_options() {
2462 let config = HttpEndpointConfig::from_uri(
2463 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie&cookieHandling=InMemory",
2464 )
2465 .unwrap();
2466
2467 assert!(matches!(
2468 config.auth,
2469 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2470 ));
2471 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2472 assert!(matches!(config.cookie_handling, CookieHandling::InMemory));
2473 assert!(config.bridge_endpoint);
2474 assert!(config.connection_close);
2475 assert_eq!(
2476 config.skip_request_headers,
2477 vec!["authorization".to_string(), "x-secret".to_string()]
2478 );
2479 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2480 }
2481
2482 #[test]
2483 fn test_http_endpoint_config_bearer_auth() {
2484 let config = HttpEndpointConfig::from_uri(
2485 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2486 )
2487 .unwrap();
2488 assert!(matches!(
2489 config.auth,
2490 HttpAuth::Bearer { token } if token == "t"
2491 ));
2492 }
2493
2494 #[test]
2495 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2496 let config = HttpConfig::default()
2497 .with_response_timeout_ms(999)
2498 .with_allow_internal(true)
2499 .with_blocked_hosts(vec!["evil.com".to_string()])
2500 .with_max_body_size(12345);
2501 let endpoint =
2502 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2503 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2504 assert!(endpoint.allow_internal);
2505 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2506 assert_eq!(endpoint.max_body_size, 12345);
2507 }
2508
2509 #[test]
2510 fn test_from_uri_with_defaults_uri_overrides_config() {
2511 let config = HttpConfig::default()
2512 .with_response_timeout_ms(999)
2513 .with_allow_internal(true)
2514 .with_blocked_hosts(vec!["evil.com".to_string()])
2515 .with_max_body_size(12345);
2516 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2517 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2518 &config,
2519 )
2520 .unwrap();
2521 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2522 assert!(!endpoint.allow_internal);
2523 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2524 assert_eq!(endpoint.max_body_size, 99);
2525 }
2526
2527 #[test]
2528 fn test_http_config_ok_status_range() {
2529 let config =
2530 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2531 assert_eq!(config.ok_status_code_range, (200, 204));
2532 }
2533
2534 #[test]
2535 fn test_http_config_wrong_scheme() {
2536 let result = HttpEndpointConfig::from_uri("file:/tmp");
2537 assert!(result.is_err());
2538 }
2539
2540 #[test]
2541 fn test_http_component_scheme() {
2542 let component = HttpComponent::new();
2543 assert_eq!(component.scheme(), "http");
2544 }
2545
2546 #[test]
2547 fn test_https_component_scheme() {
2548 let component = HttpsComponent::new();
2549 assert_eq!(component.scheme(), "https");
2550 }
2551
2552 #[test]
2553 fn test_http_endpoint_creates_consumer() {
2554 let component = HttpComponent::new();
2555 let ctx = NoOpComponentContext;
2556 let endpoint = component
2557 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2558 .unwrap();
2559 assert!(endpoint.create_consumer(rt()).is_ok());
2560 }
2561
2562 #[test]
2563 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2564 let component = HttpsComponent::new();
2565 let ctx = NoOpComponentContext;
2566 let endpoint = component
2567 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2568 .unwrap();
2569 assert!(endpoint.create_consumer(rt()).is_err());
2571 }
2572
2573 #[test]
2574 fn test_http_endpoint_creates_producer() {
2575 let ctx = test_producer_ctx();
2576 let component = HttpComponent::new();
2577 let endpoint_ctx = NoOpComponentContext;
2578 let endpoint = component
2579 .create_endpoint("http://localhost/api", &endpoint_ctx)
2580 .unwrap();
2581 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2582 }
2583
2584 #[tokio::test]
2589 async fn test_producer_with_token_provider() {
2590 use camel_auth::oauth2::TokenProvider;
2591 use tower::ServiceExt;
2592
2593 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2594 Arc::new(std::sync::Mutex::new(None));
2595 let captured_clone = Arc::clone(&captured_auth);
2596
2597 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2598 let port = listener.local_addr().unwrap().port();
2599
2600 let _handle = tokio::spawn(async move {
2601 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2602 if let Ok((mut stream, _)) = listener.accept().await {
2603 let mut buf = vec![0u8; 8192];
2604 let n = stream.read(&mut buf).await.unwrap_or(0);
2605 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2606 let auth = request
2607 .lines()
2608 .find(|l| l.to_lowercase().starts_with("authorization:"))
2609 .map(|l| {
2610 l.split(':')
2611 .nth(1)
2612 .map(|s| s.trim().to_string())
2613 .unwrap_or_default()
2614 });
2615 *captured_clone.lock().unwrap() = auth;
2616 let body = r#"{"echo":"ok"}"#;
2617 let resp = format!(
2618 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2619 body.len(),
2620 body
2621 );
2622 let _ = stream.write_all(resp.as_bytes()).await;
2623 }
2624 });
2625
2626 #[derive(Debug)]
2627 struct StaticProvider;
2628 #[async_trait::async_trait]
2629 impl TokenProvider for StaticProvider {
2630 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2631 Ok("injected-token".into())
2632 }
2633 }
2634
2635 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2636 let ctx = test_producer_ctx();
2637 let component = HttpComponent::new();
2638 let endpoint_ctx = NoOpComponentContext;
2639 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2640 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2641
2642 let exchange = Exchange::new(Message::new("hello"));
2643
2644 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2645 let mut layered = layer.layer(producer);
2646 let result = layered.ready().await.unwrap().call(exchange).await;
2647 assert!(result.is_ok(), "producer call failed: {:?}", result);
2648
2649 tokio::time::sleep(Duration::from_millis(100)).await;
2650 let auth = captured_auth.lock().unwrap().take();
2651 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2652 }
2653
2654 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2655 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2656 let addr = listener.local_addr().unwrap();
2657 let url = format!("http://127.0.0.1:{}", addr.port());
2658
2659 let handle = tokio::spawn(async move {
2660 loop {
2661 if let Ok((mut stream, _)) = listener.accept().await {
2662 tokio::spawn(async move {
2663 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2664 let mut buf = vec![0u8; 4096];
2665 let n = stream.read(&mut buf).await.unwrap_or(0);
2666 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2667
2668 let method = request.split_whitespace().next().unwrap_or("GET");
2669
2670 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2671 let response = format!(
2672 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2673 body.len(),
2674 body
2675 );
2676 let _ = stream.write_all(response.as_bytes()).await;
2677 });
2678 }
2679 }
2680 });
2681
2682 (url, handle)
2683 }
2684
2685 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2686 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2687 let addr = listener.local_addr().unwrap();
2688 let url = format!("http://127.0.0.1:{}", addr.port());
2689
2690 let handle = tokio::spawn(async move {
2691 loop {
2692 if let Ok((mut stream, _)) = listener.accept().await {
2693 let status = status;
2694 tokio::spawn(async move {
2695 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2696 let mut buf = vec![0u8; 4096];
2697 let _ = stream.read(&mut buf).await;
2698
2699 let status_text = match status {
2700 404 => "Not Found",
2701 500 => "Internal Server Error",
2702 _ => "Error",
2703 };
2704 let body = "error body";
2705 let response = format!(
2706 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2707 status,
2708 status_text,
2709 body.len(),
2710 body
2711 );
2712 let _ = stream.write_all(response.as_bytes()).await;
2713 });
2714 }
2715 }
2716 });
2717
2718 (url, handle)
2719 }
2720
2721 #[tokio::test]
2722 async fn test_http_producer_get_request() {
2723 use tower::ServiceExt;
2724
2725 let (url, _handle) = start_test_server().await;
2726 let ctx = test_producer_ctx();
2727
2728 let component = HttpComponent::new();
2729 let endpoint_ctx = NoOpComponentContext;
2730 let endpoint = component
2731 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2732 .unwrap();
2733 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2734
2735 let exchange = Exchange::new(Message::default());
2736 let result = producer.oneshot(exchange).await.unwrap();
2737
2738 let status = result
2739 .input
2740 .header("CamelHttpResponseCode")
2741 .and_then(|v| v.as_u64())
2742 .unwrap();
2743 assert_eq!(status, 200);
2744
2745 assert!(!result.input.body.is_empty());
2746 }
2747
2748 #[tokio::test]
2749 async fn test_http_producer_post_with_body() {
2750 use tower::ServiceExt;
2751
2752 let (url, _handle) = start_test_server().await;
2753 let ctx = test_producer_ctx();
2754
2755 let component = HttpComponent::new();
2756 let endpoint_ctx = NoOpComponentContext;
2757 let endpoint = component
2758 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
2759 .unwrap();
2760 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2761
2762 let exchange = Exchange::new(Message::new("request body"));
2763 let result = producer.oneshot(exchange).await.unwrap();
2764
2765 let status = result
2766 .input
2767 .header("CamelHttpResponseCode")
2768 .and_then(|v| v.as_u64())
2769 .unwrap();
2770 assert_eq!(status, 200);
2771 }
2772
2773 #[tokio::test]
2774 async fn test_http_producer_method_from_header() {
2775 use tower::ServiceExt;
2776
2777 let (url, _handle) = start_test_server().await;
2778 let ctx = test_producer_ctx();
2779
2780 let component = HttpComponent::new();
2781 let endpoint_ctx = NoOpComponentContext;
2782 let endpoint = component
2783 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
2784 .unwrap();
2785 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2786
2787 let mut exchange = Exchange::new(Message::default());
2788 exchange.input.set_header(
2789 "CamelHttpMethod",
2790 serde_json::Value::String("DELETE".to_string()),
2791 );
2792
2793 let result = producer.oneshot(exchange).await.unwrap();
2794 let status = result
2795 .input
2796 .header("CamelHttpResponseCode")
2797 .and_then(|v| v.as_u64())
2798 .unwrap();
2799 assert_eq!(status, 200);
2800 }
2801
2802 #[tokio::test]
2803 async fn test_http_producer_forced_method() {
2804 use tower::ServiceExt;
2805
2806 let (url, _handle) = start_test_server().await;
2807 let ctx = test_producer_ctx();
2808
2809 let component = HttpComponent::new();
2810 let endpoint_ctx = NoOpComponentContext;
2811 let endpoint = component
2812 .create_endpoint(
2813 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
2814 &endpoint_ctx,
2815 )
2816 .unwrap();
2817 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2818
2819 let exchange = Exchange::new(Message::default());
2820 let result = producer.oneshot(exchange).await.unwrap();
2821
2822 let status = result
2823 .input
2824 .header("CamelHttpResponseCode")
2825 .and_then(|v| v.as_u64())
2826 .unwrap();
2827 assert_eq!(status, 200);
2828 }
2829
2830 #[tokio::test]
2831 async fn test_http_producer_throw_exception_on_failure() {
2832 use tower::ServiceExt;
2833
2834 let (url, _handle) = start_status_server(404).await;
2835 let ctx = test_producer_ctx();
2836
2837 let component = HttpComponent::new();
2838 let endpoint_ctx = NoOpComponentContext;
2839 let endpoint = component
2840 .create_endpoint(
2841 &format!("{url}/not-found?allowInternal=true"),
2842 &endpoint_ctx,
2843 )
2844 .unwrap();
2845 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2846
2847 let exchange = Exchange::new(Message::default());
2848 let result = producer.oneshot(exchange).await;
2849 assert!(result.is_err());
2850
2851 match result.unwrap_err() {
2852 CamelError::HttpOperationFailed { status_code, .. } => {
2853 assert_eq!(status_code, 404);
2854 }
2855 e => panic!("Expected HttpOperationFailed, got: {e}"),
2856 }
2857 }
2858
2859 #[tokio::test]
2860 async fn test_http_producer_no_throw_on_failure() {
2861 use tower::ServiceExt;
2862
2863 let (url, _handle) = start_status_server(500).await;
2864 let ctx = test_producer_ctx();
2865
2866 let component = HttpComponent::new();
2867 let endpoint_ctx = NoOpComponentContext;
2868 let endpoint = component
2869 .create_endpoint(
2870 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
2871 &endpoint_ctx,
2872 )
2873 .unwrap();
2874 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2875
2876 let exchange = Exchange::new(Message::default());
2877 let result = producer.oneshot(exchange).await.unwrap();
2878
2879 let status = result
2880 .input
2881 .header("CamelHttpResponseCode")
2882 .and_then(|v| v.as_u64())
2883 .unwrap();
2884 assert_eq!(status, 500);
2885 }
2886
2887 #[tokio::test]
2888 async fn test_http_producer_uri_override() {
2889 use tower::ServiceExt;
2890
2891 let (url, _handle) = start_test_server().await;
2892 let ctx = test_producer_ctx();
2893
2894 let component = HttpComponent::new();
2895 let endpoint_ctx = NoOpComponentContext;
2896 let endpoint = component
2897 .create_endpoint(
2898 "http://localhost:1/does-not-exist?allowInternal=true",
2899 &endpoint_ctx,
2900 )
2901 .unwrap();
2902 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2903
2904 let mut exchange = Exchange::new(Message::default());
2905 exchange.input.set_header(
2906 "CamelHttpUri",
2907 serde_json::Value::String(format!("{url}/api")),
2908 );
2909
2910 let result = producer.oneshot(exchange).await.unwrap();
2911 let status = result
2912 .input
2913 .header("CamelHttpResponseCode")
2914 .and_then(|v| v.as_u64())
2915 .unwrap();
2916 assert_eq!(status, 200);
2917 }
2918
2919 #[tokio::test]
2920 async fn test_http_producer_response_headers_mapped() {
2921 use tower::ServiceExt;
2922
2923 let (url, _handle) = start_test_server().await;
2924 let ctx = test_producer_ctx();
2925
2926 let component = HttpComponent::new();
2927 let endpoint_ctx = NoOpComponentContext;
2928 let endpoint = component
2929 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
2930 .unwrap();
2931 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2932
2933 let exchange = Exchange::new(Message::default());
2934 let result = producer.oneshot(exchange).await.unwrap();
2935
2936 assert!(
2937 result.input.header("Content-Type").is_some(),
2938 "Response should have Content-Type header"
2939 );
2940 assert!(result.input.header("CamelHttpResponseText").is_some());
2941 }
2942
2943 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
2948 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2949 let addr = listener.local_addr().unwrap();
2950 let url = format!("http://127.0.0.1:{}", addr.port());
2951
2952 let handle = tokio::spawn(async move {
2953 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2954 loop {
2955 if let Ok((mut stream, _)) = listener.accept().await {
2956 tokio::spawn(async move {
2957 let mut buf = vec![0u8; 4096];
2958 let n = stream.read(&mut buf).await.unwrap_or(0);
2959 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2960
2961 if request.contains("GET /final") {
2963 let body = r#"{"status":"final"}"#;
2964 let response = format!(
2965 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2966 body.len(),
2967 body
2968 );
2969 let _ = stream.write_all(response.as_bytes()).await;
2970 } else {
2971 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
2973 let _ = stream.write_all(response.as_bytes()).await;
2974 }
2975 });
2976 }
2977 }
2978 });
2979
2980 (url, handle)
2981 }
2982
2983 #[tokio::test]
2984 async fn test_follow_redirects_false_does_not_follow() {
2985 use tower::ServiceExt;
2986
2987 let (url, _handle) = start_redirect_server().await;
2988 let ctx = test_producer_ctx();
2989
2990 let component =
2991 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
2992 let endpoint_ctx = NoOpComponentContext;
2993 let endpoint = component
2994 .create_endpoint(
2995 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
2996 &endpoint_ctx,
2997 )
2998 .unwrap();
2999 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3000
3001 let exchange = Exchange::new(Message::default());
3002 let result = producer.oneshot(exchange).await.unwrap();
3003
3004 let status = result
3006 .input
3007 .header("CamelHttpResponseCode")
3008 .and_then(|v| v.as_u64())
3009 .unwrap();
3010 assert_eq!(
3011 status, 302,
3012 "Should NOT follow redirect when followRedirects=false"
3013 );
3014 }
3015
3016 #[tokio::test]
3017 async fn test_follow_redirects_true_follows_redirect() {
3018 use tower::ServiceExt;
3019
3020 let (url, _handle) = start_redirect_server().await;
3021 let ctx = test_producer_ctx();
3022
3023 let component =
3024 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3025 let endpoint_ctx = NoOpComponentContext;
3026 let endpoint = component
3027 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3028 .unwrap();
3029 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3030
3031 let exchange = Exchange::new(Message::default());
3032 let result = producer.oneshot(exchange).await.unwrap();
3033
3034 let status = result
3036 .input
3037 .header("CamelHttpResponseCode")
3038 .and_then(|v| v.as_u64())
3039 .unwrap();
3040 assert_eq!(
3041 status, 200,
3042 "Should follow redirect when followRedirects=true"
3043 );
3044 }
3045
3046 #[tokio::test]
3049 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
3050 use tower::ServiceExt;
3051
3052 let (url, _handle) = start_redirect_server().await;
3054 let ctx = test_producer_ctx();
3055
3056 let component =
3057 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3058 let endpoint_ctx = NoOpComponentContext;
3059 let endpoint = component
3060 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3061 .unwrap();
3062 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3063
3064 let exchange = Exchange::new(Message::default());
3065 let result = producer.oneshot(exchange).await;
3066
3067 assert!(
3069 result.is_ok(),
3070 "Redirect should succeed with allowInternal=true, got: {:?}",
3071 result
3072 );
3073 let exchange = result.unwrap();
3074 let status = exchange
3075 .input
3076 .header("CamelHttpResponseCode")
3077 .and_then(|v| v.as_u64())
3078 .unwrap();
3079 assert_eq!(status, 200, "Should follow redirect to /final");
3080 }
3081
3082 #[tokio::test]
3084 async fn test_redirect_to_private_ip_allowed_when_configured() {
3085 use tower::ServiceExt;
3086
3087 let (url, _handle) = start_redirect_server().await;
3089 let ctx = test_producer_ctx();
3090
3091 let component =
3092 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3093 let endpoint_ctx = NoOpComponentContext;
3094 let endpoint = component
3095 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3096 .unwrap();
3097 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3098
3099 let exchange = Exchange::new(Message::default());
3100 let result = producer.oneshot(exchange).await.unwrap();
3101
3102 let status = result
3103 .input
3104 .header("CamelHttpResponseCode")
3105 .and_then(|v| v.as_u64())
3106 .unwrap();
3107 assert_eq!(
3108 status, 200,
3109 "Should follow redirect to private IP when allowInternal=true"
3110 );
3111 }
3112
3113 #[tokio::test]
3116 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
3117 use tower::ServiceExt;
3118
3119 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3121 let addr = listener.local_addr().unwrap();
3122 let url = format!("http://127.0.0.1:{}", addr.port());
3123
3124 let handle = tokio::spawn(async move {
3125 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3126 loop {
3127 if let Ok((mut stream, _)) = listener.accept().await {
3128 tokio::spawn(async move {
3129 let mut buf = vec![0u8; 4096];
3130 let _ = stream.read(&mut buf).await;
3131 let response = "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n";
3133 let _ = stream.write_all(response.as_bytes()).await;
3134 });
3135 }
3136 }
3137 });
3138
3139 let ctx = test_producer_ctx();
3140 let component =
3141 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3142 let endpoint_ctx = NoOpComponentContext;
3143 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
3145 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3146
3147 let exchange = Exchange::new(Message::default());
3148 let result = producer.oneshot(exchange).await;
3149
3150 assert!(
3152 result.is_err(),
3153 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
3154 );
3155 let err = result.unwrap_err().to_string();
3156 assert!(
3157 err.contains("blocked IP")
3158 || err.contains("private IP")
3159 || err.contains("SSRF")
3160 || err.contains("not allowed"),
3161 "Error should mention SSRF/IP blocking, got: {err}"
3162 );
3163
3164 handle.abort();
3165 }
3166
3167 #[tokio::test]
3169 async fn test_too_many_redirects_returns_error() {
3170 use tower::ServiceExt;
3171
3172 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3174 let addr = listener.local_addr().unwrap();
3175 let url = format!("http://127.0.0.1:{}", addr.port());
3176
3177 let handle = tokio::spawn(async move {
3178 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3179 loop {
3180 if let Ok((mut stream, _)) = listener.accept().await {
3181 tokio::spawn(async move {
3182 let mut buf = vec![0u8; 4096];
3183 let _ = stream.read(&mut buf).await;
3184 let response =
3186 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
3187 let _ = stream.write_all(response.as_bytes()).await;
3188 });
3189 }
3190 }
3191 });
3192
3193 let ctx = test_producer_ctx();
3194 let component =
3195 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3196 let endpoint_ctx = NoOpComponentContext;
3197 let endpoint = component
3198 .create_endpoint(
3199 &format!("{url}?allowInternal=true&maxRedirects=2"),
3200 &endpoint_ctx,
3201 )
3202 .unwrap();
3203 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3204
3205 let exchange = Exchange::new(Message::default());
3206 let result = producer.oneshot(exchange).await;
3207
3208 match result {
3216 Err(e) => {
3217 let msg = e.to_string();
3219 assert!(
3220 msg.contains("HTTP operation failed") || msg.contains("302"),
3221 "expected redirect-after-exhaustion error, got: {msg}"
3222 );
3223 }
3224 Ok(ex) => {
3225 let response_code = ex
3226 .input
3227 .header("CamelHttpResponseCode")
3228 .and_then(|v| v.as_u64());
3229 assert_eq!(
3230 response_code,
3231 Some(302),
3232 "expected 302 after exhausting redirects"
3233 );
3234 }
3235 }
3236
3237 handle.abort();
3238 }
3239
3240 #[tokio::test]
3241 async fn test_query_params_forwarded_to_http_request() {
3242 use tower::ServiceExt;
3243
3244 let (url, _handle) = start_test_server().await;
3245 let ctx = test_producer_ctx();
3246
3247 let component = HttpComponent::new();
3248 let endpoint_ctx = NoOpComponentContext;
3249 let endpoint = component
3251 .create_endpoint(
3252 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
3253 &endpoint_ctx,
3254 )
3255 .unwrap();
3256 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3257
3258 let exchange = Exchange::new(Message::default());
3259 let result = producer.oneshot(exchange).await.unwrap();
3260
3261 let status = result
3264 .input
3265 .header("CamelHttpResponseCode")
3266 .and_then(|v| v.as_u64())
3267 .unwrap();
3268 assert_eq!(status, 200);
3269 }
3270
3271 #[tokio::test]
3272 async fn test_non_camel_query_params_are_forwarded() {
3273 let config = HttpEndpointConfig::from_uri(
3276 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
3277 )
3278 .unwrap();
3279
3280 assert!(
3282 config.query_params.contains_key("apiKey"),
3283 "apiKey should be preserved"
3284 );
3285 assert!(
3286 config.query_params.contains_key("token"),
3287 "token should be preserved"
3288 );
3289 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
3290 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
3291
3292 assert!(
3294 !config.query_params.contains_key("httpMethod"),
3295 "httpMethod should not be forwarded"
3296 );
3297 }
3298
3299 #[test]
3300 fn test_query_params_are_url_encoded_when_resolving_url() {
3301 let config =
3302 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
3303 let exchange = Exchange::new(Message::default());
3304
3305 let url = HttpProducer::resolve_url(&exchange, &config);
3306
3307 assert!(url.contains("q=hello+world"), "url was: {url}");
3308 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
3309 }
3310
3311 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
3316 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3317 let addr = listener.local_addr().unwrap();
3318 let url = format!("http://127.0.0.1:{}", addr.port());
3319
3320 let handle = tokio::spawn(async move {
3321 loop {
3322 if let Ok((mut stream, _)) = listener.accept().await {
3323 let delay = delay_ms;
3324 tokio::spawn(async move {
3325 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3326 let mut buf = vec![0u8; 4096];
3327 let _ = stream.read(&mut buf).await;
3328 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
3330 let _ = stream.write_all(headers.as_bytes()).await;
3331 tokio::time::sleep(Duration::from_millis(delay)).await;
3333 let body = r#"{"status":"slow"}"#;
3334 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
3335 let _ = stream.write_all(chunk.as_bytes()).await;
3336 });
3337 }
3338 }
3339 });
3340
3341 (url, handle)
3342 }
3343
3344 #[tokio::test]
3345 async fn test_http_producer_timeout() {
3346 use tower::ServiceExt;
3347
3348 let (url, _handle) = start_slow_server(500).await;
3350 let ctx = test_producer_ctx();
3351
3352 let component = HttpComponent::with_config(
3353 HttpConfig::default()
3354 .with_read_timeout_ms(100)
3355 .with_response_timeout_ms(30_000), );
3357 let endpoint_ctx = NoOpComponentContext;
3358 let endpoint = component
3359 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
3360 .unwrap();
3361 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3362
3363 let exchange = Exchange::new(Message::default());
3364 let result = producer.oneshot(exchange).await;
3365
3366 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
3367 let err = result.unwrap_err().to_string();
3368 assert!(
3369 err.contains("Read timeout") || err.contains("timeout"),
3370 "Error should mention timeout, got: {}",
3371 err
3372 );
3373 }
3374
3375 #[tokio::test]
3376 async fn test_http_producer_no_timeout_when_fast() {
3377 use tower::ServiceExt;
3378
3379 let (url, _handle) = start_test_server().await;
3380 let ctx = test_producer_ctx();
3381
3382 let component =
3383 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
3384 let endpoint_ctx = NoOpComponentContext;
3385 let endpoint = component
3386 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3387 .unwrap();
3388 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3389
3390 let exchange = Exchange::new(Message::default());
3391 let result = producer.oneshot(exchange).await.unwrap();
3392
3393 let status = result
3394 .input
3395 .header("CamelHttpResponseCode")
3396 .and_then(|v| v.as_u64())
3397 .unwrap();
3398 assert_eq!(status, 200);
3399 }
3400
3401 #[tokio::test]
3406 async fn test_http_producer_blocks_metadata_endpoint() {
3407 use tower::ServiceExt;
3408
3409 let ctx = test_producer_ctx();
3410 let component = HttpComponent::new();
3411 let endpoint_ctx = NoOpComponentContext;
3412 let endpoint = component
3413 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
3414 .unwrap();
3415 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3416
3417 let mut exchange = Exchange::new(Message::default());
3418 exchange.input.set_header(
3419 "CamelHttpUri",
3420 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
3421 );
3422
3423 let result = producer.oneshot(exchange).await;
3424 assert!(result.is_err(), "Should block AWS metadata endpoint");
3425
3426 let err = result.unwrap_err();
3427 assert!(
3428 err.to_string().contains("Private IP"),
3429 "Error should mention private IP blocking, got: {}",
3430 err
3431 );
3432 }
3433
3434 #[test]
3435 fn test_ssrf_config_defaults() {
3436 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
3437 assert!(
3438 !config.allow_internal,
3439 "Private IPs should be blocked by default"
3440 );
3441 assert!(
3442 config.blocked_hosts.is_empty(),
3443 "Blocked hosts should be empty by default"
3444 );
3445 }
3446
3447 #[test]
3448 fn test_ssrf_config_allow_internal() {
3449 let config =
3450 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
3451 assert!(
3452 config.allow_internal,
3453 "Private IPs should be allowed when explicitly set"
3454 );
3455 }
3456
3457 #[test]
3458 fn test_ssrf_config_blocked_hosts() {
3459 let config = HttpEndpointConfig::from_uri(
3460 "http://example.com/api?blockedHosts=evil.com,malware.net",
3461 )
3462 .unwrap();
3463 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
3464 }
3465
3466 #[tokio::test]
3467 async fn test_http_producer_blocks_localhost() {
3468 use tower::ServiceExt;
3469
3470 let ctx = test_producer_ctx();
3471 let component = HttpComponent::new();
3472 let endpoint_ctx = NoOpComponentContext;
3473 let endpoint = component
3474 .create_endpoint("http://example.com/api", &endpoint_ctx)
3475 .unwrap();
3476 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3477
3478 let mut exchange = Exchange::new(Message::default());
3479 exchange.input.set_header(
3480 "CamelHttpUri",
3481 serde_json::Value::String("http://localhost:8080/internal".to_string()),
3482 );
3483
3484 let result = producer.oneshot(exchange).await;
3485 assert!(result.is_err(), "Should block localhost");
3486 }
3487
3488 #[tokio::test]
3489 async fn test_http_producer_blocks_loopback_ip() {
3490 use tower::ServiceExt;
3491
3492 let ctx = test_producer_ctx();
3493 let component = HttpComponent::new();
3494 let endpoint_ctx = NoOpComponentContext;
3495 let endpoint = component
3496 .create_endpoint("http://example.com/api", &endpoint_ctx)
3497 .unwrap();
3498 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3499
3500 let mut exchange = Exchange::new(Message::default());
3501 exchange.input.set_header(
3502 "CamelHttpUri",
3503 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
3504 );
3505
3506 let result = producer.oneshot(exchange).await;
3507 assert!(result.is_err(), "Should block loopback IP");
3508 }
3509
3510 #[tokio::test]
3511 async fn test_http_producer_allows_private_ip_when_enabled() {
3512 use tower::ServiceExt;
3513
3514 let ctx = test_producer_ctx();
3515 let component = HttpComponent::new();
3516 let endpoint_ctx = NoOpComponentContext;
3517 let endpoint = component
3520 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
3521 .unwrap();
3522 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3523
3524 let exchange = Exchange::new(Message::default());
3525
3526 let result = producer.oneshot(exchange).await;
3529 if let Err(ref e) = result {
3531 let err_str = e.to_string();
3532 assert!(
3533 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
3534 "Should not be SSRF error, got: {}",
3535 err_str
3536 );
3537 }
3538 }
3539
3540 #[test]
3545 fn test_http_server_config_parse() {
3546 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
3547 assert_eq!(cfg.host, "0.0.0.0");
3548 assert_eq!(cfg.port, 8080);
3549 assert_eq!(cfg.path, "/orders");
3550 assert_eq!(cfg.max_inflight_requests, 1024);
3551 }
3552
3553 #[test]
3554 fn test_http_server_config_scheme() {
3555 assert_eq!(HttpServerConfig::scheme(), "http");
3557 }
3558
3559 #[test]
3560 fn test_http_server_config_from_components() {
3561 let components = camel_component_api::UriComponents {
3563 scheme: "https".to_string(),
3564 path: "//0.0.0.0:8443/api".to_string(),
3565 params: std::collections::HashMap::from([
3566 ("maxRequestBody".to_string(), "5242880".to_string()),
3567 ("maxInflightRequests".to_string(), "7".to_string()),
3568 ]),
3569 };
3570 let cfg = HttpServerConfig::from_components(components).unwrap();
3571 assert_eq!(cfg.host, "0.0.0.0");
3572 assert_eq!(cfg.port, 8443);
3573 assert_eq!(cfg.path, "/api");
3574 assert_eq!(cfg.max_request_body, 5242880);
3575 assert_eq!(cfg.max_inflight_requests, 7);
3576 }
3577
3578 #[test]
3579 fn test_http_server_config_default_path() {
3580 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
3581 assert_eq!(cfg.path, "/");
3582 }
3583
3584 #[test]
3585 fn test_http_server_config_wrong_scheme() {
3586 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
3587 }
3588
3589 #[test]
3590 fn test_http_server_config_invalid_port() {
3591 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
3592 }
3593
3594 #[test]
3595 fn test_http_server_config_default_port_by_scheme() {
3596 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
3598 assert_eq!(cfg_http.port, 80);
3599
3600 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
3602 assert_eq!(cfg_https.port, 443);
3603 }
3604
3605 #[test]
3606 fn test_request_envelope_and_reply_are_send() {
3607 fn assert_send<T: Send>() {}
3608 assert_send::<RequestEnvelope>();
3609 assert_send::<HttpReply>();
3610 }
3611
3612 #[test]
3617 fn test_server_registry_global_is_singleton() {
3618 let r1 = ServerRegistry::global();
3619 let r2 = ServerRegistry::global();
3620 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
3621 }
3622
3623 #[allow(clippy::await_holding_lock)]
3624 #[tokio::test]
3625 async fn test_concurrent_get_or_spawn_returns_same_registry() {
3626 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3627 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3628 let port = listener.local_addr().unwrap().port();
3629 drop(listener);
3630
3631 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
3632 Arc::new(std::sync::Mutex::new(Vec::new()));
3633
3634 let mut handles = Vec::new();
3635 for _ in 0..4 {
3636 let results = results.clone();
3637 handles.push(tokio::spawn(async move {
3638 let registry = ServerRegistry::global()
3639 .get_or_spawn(
3640 "127.0.0.1",
3641 port,
3642 2 * 1024 * 1024,
3643 10 * 1024 * 1024,
3644 1024,
3645 test_rt(),
3646 "test-route".into(),
3647 None,
3648 )
3649 .await
3650 .unwrap();
3651 results.lock().unwrap().push(registry);
3652 }));
3653 }
3654
3655 for h in handles {
3656 h.await.unwrap();
3657 }
3658
3659 let registries = results.lock().unwrap();
3660 assert_eq!(registries.len(), 4);
3661 for i in 1..registries.len() {
3662 assert!(
3663 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
3664 "all concurrent callers should get same route registry"
3665 );
3666 }
3667 }
3668
3669 #[test]
3670 fn test_server_registry_distinguishes_host_and_port() {
3671 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3672 let rt = tokio::runtime::Runtime::new().expect("runtime");
3673 rt.block_on(async {
3674 let registry = ServerRegistry::global();
3675 let d1 = registry
3679 .get_or_spawn(
3680 "127.0.0.1",
3681 0,
3682 1024 * 1024,
3683 10 * 1024 * 1024,
3684 1024,
3685 test_rt(),
3686 "test-route-1".into(),
3687 None,
3688 )
3689 .await;
3690 let d2 = registry
3691 .get_or_spawn(
3692 "0.0.0.0",
3693 0,
3694 1024 * 1024,
3695 10 * 1024 * 1024,
3696 1024,
3697 test_rt(),
3698 "test-route-2".into(),
3699 None,
3700 )
3701 .await;
3702 assert!(d1.is_ok());
3703 assert!(d2.is_ok());
3704 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
3705 });
3706 }
3707
3708 #[allow(clippy::await_holding_lock)]
3709 #[tokio::test]
3710 async fn test_shared_server_max_request_body_policy_is_deterministic() {
3711 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3712 let registry = ServerRegistry::global();
3713 let d1 = registry
3715 .get_or_spawn(
3716 "127.0.0.1",
3717 9991,
3718 1024 * 1024,
3719 10 * 1024 * 1024,
3720 1024,
3721 test_rt(),
3722 "test-route".into(),
3723 None,
3724 )
3725 .await;
3726 assert!(d1.is_ok());
3727
3728 let d2 = registry
3731 .get_or_spawn(
3732 "127.0.0.1",
3733 9991,
3734 2 * 1024 * 1024,
3735 10 * 1024 * 1024,
3736 1024,
3737 test_rt(),
3738 "test-route-2".into(),
3739 None,
3740 )
3741 .await;
3742 assert!(d2.is_err());
3743 let err = d2.unwrap_err();
3744 assert!(
3745 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
3746 "Expected incompatible maxRequestBody error, got: {}",
3747 err
3748 );
3749 }
3750
3751 #[test]
3752 fn test_server_registry_reset_clears_entries() {
3753 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3754 let rt = tokio::runtime::Runtime::new().expect("runtime");
3755 rt.block_on(async {
3756 let d1 = ServerRegistry::global()
3758 .get_or_spawn(
3759 "127.0.0.1",
3760 9992,
3761 1024 * 1024,
3762 10 * 1024 * 1024,
3763 1024,
3764 test_rt(),
3765 "test-route".into(),
3766 None,
3767 )
3768 .await;
3769 assert!(d1.is_ok());
3770
3771 let guard = ServerRegistry::global().inner.lock().expect("lock");
3773 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
3774 drop(guard);
3775
3776 ServerRegistry::reset();
3778
3779 let guard = ServerRegistry::global().inner.lock().expect("lock");
3781 assert!(
3782 guard.is_empty(),
3783 "registry should be empty after reset, has {} entries",
3784 guard.len()
3785 );
3786 });
3787 }
3788
3789 #[tokio::test]
3790 async fn registry_rejects_tls_on_plain_port() {
3791 ServerRegistry::reset();
3792 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
3793
3794 let _r1 = ServerRegistry::global()
3796 .get_or_spawn(
3797 "127.0.0.1",
3798 0,
3799 1024,
3800 1024,
3801 16,
3802 Arc::clone(&rt),
3803 "route-1".into(),
3804 None, )
3806 .await;
3807
3808 let result = ServerRegistry::global()
3810 .get_or_spawn(
3811 "127.0.0.1",
3812 0,
3813 1024,
3814 1024,
3815 16,
3816 Arc::clone(&rt),
3817 "route-2".into(),
3818 Some(crate::config::ServerTlsConfig {
3819 cert_path: "/x.pem".into(),
3820 key_path: "/y.pem".into(),
3821 }),
3822 )
3823 .await;
3824 assert!(result.is_err(), "must reject TLS on plain port");
3825 }
3826
3827 #[tokio::test]
3832 async fn test_unregister_last_http_route_keeps_server_alive() {
3833 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3834 ServerRegistry::reset();
3835 let registry = ServerRegistry::global();
3836
3837 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3838 let port = listener.local_addr().unwrap().port();
3839 drop(listener); let rt = test_rt();
3841
3842 let _r1 = registry
3845 .get_or_spawn(
3846 "127.0.0.1",
3847 port,
3848 1024 * 1024,
3849 10 * 1024 * 1024,
3850 16,
3851 rt.clone(),
3852 "test-route-1".into(),
3853 None,
3854 )
3855 .await
3856 .unwrap();
3857 let _r2 = registry
3858 .get_or_spawn(
3859 "127.0.0.1",
3860 port,
3861 1024 * 1024,
3862 10 * 1024 * 1024,
3863 16,
3864 rt,
3865 "test-route-2".into(),
3866 None,
3867 )
3868 .await
3869 .unwrap();
3870
3871 let key = ("127.0.0.1".to_string(), port);
3872 let cell = {
3873 let guard = registry.inner.lock().expect("lock");
3874 guard.get(&key).expect("entry should exist").clone()
3875 };
3876
3877 registry.unregister("127.0.0.1", port).await;
3879 {
3880 let handle = cell
3881 .get()
3882 .expect("handle should still exist after first unregister");
3883 assert!(
3884 !handle.monitor_task.is_finished(),
3885 "monitor task should still be alive after first unregister"
3886 );
3887 }
3888
3889 registry.unregister("127.0.0.1", port).await;
3891 tokio::time::sleep(Duration::from_millis(20)).await;
3892 {
3893 let handle = cell
3894 .get()
3895 .expect("handle should still exist after last unregister");
3896 assert!(
3897 !handle.monitor_task.is_finished(),
3898 "monitor task should still be alive — server is process-lifetime"
3899 );
3900 }
3901
3902 {
3904 let guard = registry.inner.lock().expect("lock");
3905 assert!(
3906 guard.get(&key).is_some(),
3907 "entry should remain in registry — server kept alive for restart"
3908 );
3909 }
3910 }
3911
3912 #[tokio::test]
3917 async fn test_dispatch_handler_returns_404_for_unknown_path() {
3918 let registry = HttpRouteRegistry::new();
3919 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3921 let port = listener.local_addr().unwrap().port();
3922 tokio::spawn(run_axum_server(
3923 listener,
3924 registry,
3925 2 * 1024 * 1024,
3926 10 * 1024 * 1024,
3927 Arc::new(tokio::sync::Semaphore::new(1024)),
3928 test_rt(),
3929 "test-route".into(),
3930 ));
3931
3932 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3934
3935 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
3936 .await
3937 .unwrap();
3938 assert_eq!(resp.status().as_u16(), 404);
3939 }
3940
3941 #[tokio::test]
3946 async fn test_http_consumer_start_registers_path() {
3947 use camel_component_api::ConsumerContext;
3948
3949 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3951 let port = listener.local_addr().unwrap().port();
3952 drop(listener); let consumer_cfg = HttpServerConfig {
3955 scheme: "http".to_string(),
3956 host: "127.0.0.1".to_string(),
3957 port,
3958 path: "/ping".to_string(),
3959 max_request_body: 2 * 1024 * 1024,
3960 max_response_body: 10 * 1024 * 1024,
3961 max_inflight_requests: 1024,
3962 method: None,
3963 tls_config: None,
3964 };
3965 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3966
3967 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
3968 let token = tokio_util::sync::CancellationToken::new();
3969 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3970
3971 tokio::spawn(async move {
3972 consumer.start(ctx).await.unwrap();
3973 });
3974
3975 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3976
3977 let client = reqwest::Client::new();
3978 let resp_future = client
3979 .post(format!("http://127.0.0.1:{port}/ping"))
3980 .body("hello world")
3981 .send();
3982
3983 let (http_result, _) = tokio::join!(resp_future, async {
3984 if let Some(mut envelope) = rx.recv().await {
3985 envelope.exchange.input.set_header(
3987 "CamelHttpResponseCode",
3988 serde_json::Value::Number(201.into()),
3989 );
3990 if let Some(reply_tx) = envelope.reply_tx {
3991 let _ = reply_tx.send(Ok(envelope.exchange));
3992 }
3993 }
3994 });
3995
3996 let resp = http_result.unwrap();
3997 assert_eq!(resp.status().as_u16(), 201);
3998
3999 token.cancel();
4000 }
4001
4002 #[test]
4005 fn test_http_consumer_startup_mode_is_explicit() {
4006 use camel_component_api::ConsumerStartupMode;
4007 let consumer_cfg = HttpServerConfig {
4008 scheme: "http".to_string(),
4009 host: "127.0.0.1".to_string(),
4010 port: 0,
4011 path: "/x".to_string(),
4012 max_request_body: 2 * 1024 * 1024,
4013 max_response_body: 10 * 1024 * 1024,
4014 max_inflight_requests: 1024,
4015 method: None,
4016 tls_config: None,
4017 };
4018 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
4019 assert_eq!(
4020 consumer.startup_mode(),
4021 ConsumerStartupMode::Explicit,
4022 "HttpConsumer must opt into Explicit startup"
4023 );
4024 }
4025
4026 #[tokio::test]
4032 async fn test_http_consumer_emits_mark_ready_after_bind() {
4033 use camel_component_api::{ConsumerContext, StartupSignal};
4034
4035 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4036
4037 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4038 let port = listener.local_addr().unwrap().port();
4039 drop(listener);
4040
4041 let consumer_cfg = HttpServerConfig {
4042 scheme: "http".to_string(),
4043 host: "127.0.0.1".to_string(),
4044 port,
4045 path: "/ready-probe".to_string(),
4046 max_request_body: 2 * 1024 * 1024,
4047 max_response_body: 10 * 1024 * 1024,
4048 max_inflight_requests: 1024,
4049 method: None,
4050 tls_config: None,
4051 };
4052 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4053
4054 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4055 let token = tokio_util::sync::CancellationToken::new();
4056 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
4057
4058 let (signal, startup_rx) = StartupSignal::pair();
4060 let ctx = ctx.with_startup(signal);
4061
4062 tokio::spawn(async move {
4065 let _ = consumer.start(ctx).await;
4066 });
4067
4068 let result =
4073 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
4074 .await
4075 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
4076 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
4077
4078 token.cancel();
4080 }
4081
4082 #[tokio::test]
4083 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
4084 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4085
4086 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4087 let port = listener.local_addr().unwrap().port();
4088 drop(listener);
4089
4090 let consumer_cfg = HttpServerConfig {
4091 scheme: "http".to_string(),
4092 host: "127.0.0.1".to_string(),
4093 port,
4094 path: "/saturation".to_string(),
4095 max_request_body: 2 * 1024 * 1024,
4096 max_response_body: 10 * 1024 * 1024,
4097 max_inflight_requests: 1,
4098 method: None,
4099 tls_config: None,
4100 };
4101 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4102
4103 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4104 let token = tokio_util::sync::CancellationToken::new();
4105 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4106 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4107 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4108
4109 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
4110 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
4111
4112 tokio::spawn(async move {
4113 let mut first_seen_tx = Some(first_seen_tx);
4114 let mut unblock_first_rx = Some(unblock_first_rx);
4115
4116 while let Some(envelope) = rx.recv().await {
4117 if let Some(tx) = first_seen_tx.take() {
4118 let _ = tx.send(());
4119 if let Some(rx_unblock) = unblock_first_rx.take() {
4120 let _ = rx_unblock.await;
4121 }
4122 }
4123
4124 if let Some(reply_tx) = envelope.reply_tx {
4125 let _ = reply_tx.send(Ok(envelope.exchange));
4126 }
4127 }
4128 });
4129
4130 let client = reqwest::Client::new();
4131 let first_req = {
4132 let client = client.clone();
4133 async move {
4134 client
4135 .get(format!("http://127.0.0.1:{port}/saturation"))
4136 .send()
4137 .await
4138 .unwrap()
4139 }
4140 };
4141
4142 let first_handle = tokio::spawn(first_req);
4143 first_seen_rx.await.unwrap();
4144
4145 let second_resp = client
4146 .get(format!("http://127.0.0.1:{port}/saturation"))
4147 .send()
4148 .await
4149 .unwrap();
4150
4151 assert_eq!(second_resp.status().as_u16(), 503);
4152
4153 let _ = unblock_first_tx.send(());
4154 let first_resp = first_handle.await.unwrap();
4155 assert_eq!(first_resp.status().as_u16(), 200);
4156
4157 token.cancel();
4158 }
4159
4160 #[tokio::test]
4161 #[allow(clippy::await_holding_lock)]
4162 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
4163 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4164
4165 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4166
4167 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4168 let port = listener.local_addr().unwrap().port();
4169 drop(listener);
4170
4171 let consumer_cfg = HttpServerConfig {
4172 scheme: "http".to_string(),
4173 host: "127.0.0.1".to_string(),
4174 port,
4175 path: "/limit-bytes".to_string(),
4176 max_request_body: 2 * 1024 * 1024,
4177 max_response_body: 16,
4178 max_inflight_requests: 1024,
4179 method: None,
4180 tls_config: None,
4181 };
4182 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4183
4184 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4185 let token = tokio_util::sync::CancellationToken::new();
4186 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4187 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4188 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4189
4190 let client = reqwest::Client::new();
4191 let send_fut = client
4192 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
4193 .send();
4194
4195 let (http_result, _) = tokio::join!(send_fut, async {
4196 if let Some(mut envelope) = rx.recv().await {
4197 envelope.exchange.input.body =
4198 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
4199 if let Some(reply_tx) = envelope.reply_tx {
4200 let _ = reply_tx.send(Ok(envelope.exchange));
4201 }
4202 }
4203 });
4204
4205 let resp = http_result.unwrap();
4206 assert_eq!(resp.status().as_u16(), 500);
4207 let body = resp.text().await.unwrap();
4208 assert_eq!(body, "Response body exceeds configured limit");
4209 token.cancel();
4210 }
4211
4212 #[tokio::test]
4213 #[allow(clippy::await_holding_lock)]
4214 async fn test_http_consumer_enforces_max_response_body_for_json() {
4215 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4216
4217 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4218
4219 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4220 let port = listener.local_addr().unwrap().port();
4221 drop(listener);
4222
4223 let consumer_cfg = HttpServerConfig {
4224 scheme: "http".to_string(),
4225 host: "127.0.0.1".to_string(),
4226 port,
4227 path: "/limit-json".to_string(),
4228 max_request_body: 2 * 1024 * 1024,
4229 max_response_body: 16,
4230 max_inflight_requests: 1024,
4231 method: None,
4232 tls_config: None,
4233 };
4234 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4235
4236 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4237 let token = tokio_util::sync::CancellationToken::new();
4238 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4239 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4240 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4241
4242 let client = reqwest::Client::new();
4243 let send_fut = client
4244 .get(format!("http://127.0.0.1:{port}/limit-json"))
4245 .send();
4246
4247 let (http_result, _) = tokio::join!(send_fut, async {
4248 if let Some(mut envelope) = rx.recv().await {
4249 envelope.exchange.input.body = camel_component_api::Body::Json(
4250 serde_json::json!({"message":"this response is bigger than sixteen"}),
4251 );
4252 if let Some(reply_tx) = envelope.reply_tx {
4253 let _ = reply_tx.send(Ok(envelope.exchange));
4254 }
4255 }
4256 });
4257
4258 let resp = http_result.unwrap();
4259 assert_eq!(resp.status().as_u16(), 500);
4260 let body = resp.text().await.unwrap();
4261 assert_eq!(body, "Response body exceeds configured limit");
4262 token.cancel();
4263 }
4264
4265 #[tokio::test]
4266 #[allow(clippy::await_holding_lock)]
4267 async fn test_http_consumer_enforces_max_response_body_for_xml() {
4268 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4269
4270 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4271
4272 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4273 let port = listener.local_addr().unwrap().port();
4274 drop(listener);
4275
4276 let consumer_cfg = HttpServerConfig {
4277 scheme: "http".to_string(),
4278 host: "127.0.0.1".to_string(),
4279 port,
4280 path: "/limit-xml".to_string(),
4281 max_request_body: 2 * 1024 * 1024,
4282 max_response_body: 16,
4283 max_inflight_requests: 1024,
4284 method: None,
4285 tls_config: None,
4286 };
4287 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4288
4289 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4290 let token = tokio_util::sync::CancellationToken::new();
4291 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4292 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4293 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4294
4295 let client = reqwest::Client::new();
4296 let send_fut = client
4297 .get(format!("http://127.0.0.1:{port}/limit-xml"))
4298 .send();
4299
4300 let (http_result, _) = tokio::join!(send_fut, async {
4301 if let Some(mut envelope) = rx.recv().await {
4302 envelope.exchange.input.body = camel_component_api::Body::Xml(
4303 "<root><value>way-too-large</value></root>".into(),
4304 );
4305 if let Some(reply_tx) = envelope.reply_tx {
4306 let _ = reply_tx.send(Ok(envelope.exchange));
4307 }
4308 }
4309 });
4310
4311 let resp = http_result.unwrap();
4312 assert_eq!(resp.status().as_u16(), 500);
4313 let body = resp.text().await.unwrap();
4314 assert_eq!(body, "Response body exceeds configured limit");
4315 token.cancel();
4316 }
4317
4318 #[tokio::test]
4319 #[allow(clippy::await_holding_lock)]
4320 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
4321 use camel_component_api::{
4322 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
4323 };
4324 use futures::stream;
4325
4326 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4327
4328 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
4329 let port = listener.local_addr().unwrap().port();
4330 drop(listener);
4331
4332 let consumer_cfg = HttpServerConfig {
4333 scheme: "http".to_string(),
4334 host: "0.0.0.0".to_string(),
4335 port,
4336 path: "/limit-stream".to_string(),
4337 max_request_body: 2 * 1024 * 1024,
4338 max_response_body: 16,
4339 max_inflight_requests: 1024,
4340 method: None,
4341 tls_config: None,
4342 };
4343 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4344
4345 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4346 let token = tokio_util::sync::CancellationToken::new();
4347 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4348 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4349 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4350
4351 let client = reqwest::Client::new();
4352 let send_fut = client
4353 .get(format!("http://127.0.0.1:{port}/limit-stream"))
4354 .send();
4355
4356 let (http_result, _) = tokio::join!(send_fut, async {
4357 if let Some(mut envelope) = rx.recv().await {
4358 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4359 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
4360 let stream = Box::pin(stream::iter(chunks));
4361 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
4362 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4363 metadata: StreamMetadata {
4364 size_hint: Some(32),
4365 content_type: Some("application/octet-stream".into()),
4366 origin: None,
4367 },
4368 });
4369 if let Some(reply_tx) = envelope.reply_tx {
4370 let _ = reply_tx.send(Ok(envelope.exchange));
4371 }
4372 }
4373 });
4374
4375 let resp = http_result.unwrap();
4376 assert_eq!(resp.status().as_u16(), 200);
4377 let body = resp.bytes().await.unwrap();
4378 assert_eq!(body.len(), 32);
4379 token.cancel();
4380 }
4381
4382 #[tokio::test]
4387 #[allow(clippy::await_holding_lock)]
4388 async fn test_integration_single_consumer_round_trip() {
4389 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4390
4391 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4395
4396 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4398 let port = listener.local_addr().unwrap().port();
4399 drop(listener); let component = HttpComponent::new();
4402 let endpoint_ctx = NoOpComponentContext;
4403 let endpoint = component
4404 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
4405 .unwrap();
4406 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4407
4408 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4409 let token = tokio_util::sync::CancellationToken::new();
4410 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4411
4412 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4413 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4414
4415 let client = reqwest::Client::new();
4416 let send_fut = client
4417 .post(format!("http://127.0.0.1:{port}/echo"))
4418 .header("Content-Type", "text/plain")
4419 .body("ping")
4420 .send();
4421
4422 let (http_result, _) = tokio::join!(send_fut, async {
4423 if let Some(mut envelope) = rx.recv().await {
4424 assert_eq!(
4425 envelope.exchange.input.header("CamelHttpMethod"),
4426 Some(&serde_json::Value::String("POST".into()))
4427 );
4428 assert_eq!(
4429 envelope.exchange.input.header("CamelHttpPath"),
4430 Some(&serde_json::Value::String("/echo".into()))
4431 );
4432 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
4433 if let Some(reply_tx) = envelope.reply_tx {
4434 let _ = reply_tx.send(Ok(envelope.exchange));
4435 }
4436 }
4437 });
4438
4439 let resp = http_result.unwrap();
4440 assert_eq!(resp.status().as_u16(), 200);
4441 let body = resp.text().await.unwrap();
4442 assert_eq!(body, "pong");
4443
4444 token.cancel();
4445 }
4446
4447 #[tokio::test]
4448 #[allow(clippy::await_holding_lock)]
4449 async fn test_integration_two_consumers_shared_port() {
4450 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4451
4452 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4453
4454 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4456 let port = listener.local_addr().unwrap().port();
4457 drop(listener);
4458
4459 let component = HttpComponent::new();
4460 let endpoint_ctx = NoOpComponentContext;
4461
4462 let endpoint_a = component
4464 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
4465 .unwrap();
4466 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
4467
4468 let endpoint_b = component
4470 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
4471 .unwrap();
4472 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
4473
4474 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4475 let token_a = tokio_util::sync::CancellationToken::new();
4476 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
4477
4478 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4479 let token_b = tokio_util::sync::CancellationToken::new();
4480 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
4481
4482 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
4483 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
4484 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4485
4486 let client = reqwest::Client::new();
4487
4488 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
4490 let (resp_hello, _) = tokio::join!(fut_hello, async {
4491 if let Some(mut envelope) = rx_a.recv().await {
4492 envelope.exchange.input.body =
4493 camel_component_api::Body::Text("hello-response".to_string());
4494 if let Some(reply_tx) = envelope.reply_tx {
4495 let _ = reply_tx.send(Ok(envelope.exchange));
4496 }
4497 }
4498 });
4499
4500 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
4502 let (resp_world, _) = tokio::join!(fut_world, async {
4503 if let Some(mut envelope) = rx_b.recv().await {
4504 envelope.exchange.input.body =
4505 camel_component_api::Body::Text("world-response".to_string());
4506 if let Some(reply_tx) = envelope.reply_tx {
4507 let _ = reply_tx.send(Ok(envelope.exchange));
4508 }
4509 }
4510 });
4511
4512 let body_a = resp_hello.unwrap().text().await.unwrap();
4513 let body_b = resp_world.unwrap().text().await.unwrap();
4514
4515 assert_eq!(body_a, "hello-response");
4516 assert_eq!(body_b, "world-response");
4517
4518 token_a.cancel();
4519 token_b.cancel();
4520 }
4521
4522 #[tokio::test]
4523 #[allow(clippy::await_holding_lock)]
4524 async fn test_integration_unregistered_path_returns_404() {
4525 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4526
4527 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4528
4529 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4531 let port = listener.local_addr().unwrap().port();
4532 drop(listener);
4533
4534 let component = HttpComponent::new();
4535 let endpoint_ctx = NoOpComponentContext;
4536 let endpoint = component
4537 .create_endpoint(
4538 &format!("http://127.0.0.1:{port}/registered"),
4539 &endpoint_ctx,
4540 )
4541 .unwrap();
4542 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4543
4544 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4545 let token = tokio_util::sync::CancellationToken::new();
4546 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4547
4548 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4549
4550 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4552 loop {
4553 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
4554 .await
4555 .is_ok()
4556 {
4557 break;
4558 }
4559 if std::time::Instant::now() >= deadline {
4560 panic!("HTTP server did not start within 5s on port {port}");
4561 }
4562 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4563 }
4564
4565 let client = reqwest::Client::new();
4566 let resp = client
4567 .get(format!("http://127.0.0.1:{port}/not-there"))
4568 .send()
4569 .await
4570 .unwrap();
4571 assert_eq!(resp.status().as_u16(), 404);
4572
4573 token.cancel();
4574 }
4575
4576 #[test]
4577 fn test_http_consumer_declares_concurrent() {
4578 use camel_component_api::ConcurrencyModel;
4579
4580 let config = HttpServerConfig {
4581 scheme: "http".to_string(),
4582 host: "127.0.0.1".to_string(),
4583 port: 19999,
4584 path: "/test".to_string(),
4585 max_request_body: 2 * 1024 * 1024,
4586 max_response_body: 10 * 1024 * 1024,
4587 max_inflight_requests: 1024,
4588 method: None,
4589 tls_config: None,
4590 };
4591 let consumer = HttpConsumer::new(config, test_rt());
4592 assert_eq!(
4593 consumer.concurrency_model(),
4594 ConcurrencyModel::Concurrent { max: None }
4595 );
4596 }
4597
4598 #[test]
4599 fn server_config_parses_tls_cert_and_key() {
4600 let cfg = HttpServerConfig::from_uri(
4601 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
4602 )
4603 .unwrap();
4604 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
4605 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
4606 }
4607
4608 #[test]
4609 fn server_config_no_tls_when_params_absent() {
4610 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
4611 assert!(cfg.tls_config.is_none());
4612 }
4613
4614 #[tokio::test]
4619 async fn test_http_reply_body_stream_variant_exists() {
4620 use bytes::Bytes;
4621 use camel_component_api::CamelError;
4622 use futures::stream;
4623
4624 let chunks: Vec<Result<Bytes, CamelError>> =
4625 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
4626 let stream = Box::pin(stream::iter(chunks));
4627 let reply_body = HttpReplyBody::Stream(stream);
4628 match reply_body {
4630 HttpReplyBody::Stream(_) => {}
4631 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
4632 }
4633 }
4634
4635 #[cfg(feature = "otel")]
4640 mod otel_tests {
4641 use super::*;
4642 use camel_component_api::Message;
4643 use tower::ServiceExt;
4644
4645 #[tokio::test]
4646 async fn test_producer_injects_traceparent_header() {
4647 let (url, _handle) = start_test_server_with_header_capture().await;
4648 let ctx = test_producer_ctx();
4649
4650 let component = HttpComponent::new();
4651 let endpoint_ctx = NoOpComponentContext;
4652 let endpoint = component
4653 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4654 .unwrap();
4655 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4656
4657 let mut exchange = Exchange::new(Message::default());
4659 let mut headers = std::collections::HashMap::new();
4660 headers.insert(
4661 "traceparent".to_string(),
4662 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
4663 );
4664 camel_otel::extract_into_exchange(&mut exchange, &headers);
4665
4666 let result = producer.oneshot(exchange).await.unwrap();
4667
4668 let status = result
4670 .input
4671 .header("CamelHttpResponseCode")
4672 .and_then(|v| v.as_u64())
4673 .unwrap();
4674 assert_eq!(status, 200);
4675
4676 let traceparent = result.input.header("X-Received-Traceparent");
4678 assert!(
4679 traceparent.is_some(),
4680 "traceparent header should have been sent"
4681 );
4682
4683 let traceparent_str = traceparent.unwrap().as_str().unwrap();
4684 let parts: Vec<&str> = traceparent_str.split('-').collect();
4686 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4687 assert_eq!(parts[0], "00", "version should be 00");
4688 assert_eq!(
4689 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4690 "trace-id should match"
4691 );
4692 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
4693 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
4694 }
4695
4696 #[tokio::test]
4697 async fn test_consumer_extracts_traceparent_header() {
4698 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4699
4700 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4702 let port = listener.local_addr().unwrap().port();
4703 drop(listener);
4704
4705 let component = HttpComponent::new();
4706 let endpoint_ctx = NoOpComponentContext;
4707 let endpoint = component
4708 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4709 .unwrap();
4710 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4711
4712 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4713 let token = tokio_util::sync::CancellationToken::new();
4714 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4715
4716 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4717 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4718
4719 let client = reqwest::Client::new();
4721 let send_fut = client
4722 .post(format!("http://127.0.0.1:{port}/trace"))
4723 .header(
4724 "traceparent",
4725 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4726 )
4727 .body("test")
4728 .send();
4729
4730 let (http_result, _) = tokio::join!(send_fut, async {
4731 if let Some(envelope) = rx.recv().await {
4732 let mut injected_headers = std::collections::HashMap::new();
4735 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4736
4737 assert!(
4738 injected_headers.contains_key("traceparent"),
4739 "Exchange should have traceparent after extraction"
4740 );
4741
4742 let traceparent = injected_headers.get("traceparent").unwrap();
4743 let parts: Vec<&str> = traceparent.split('-').collect();
4744 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4745 assert_eq!(
4746 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4747 "Trace ID should match the original traceparent header"
4748 );
4749
4750 if let Some(reply_tx) = envelope.reply_tx {
4751 let _ = reply_tx.send(Ok(envelope.exchange));
4752 }
4753 }
4754 });
4755
4756 let resp = http_result.unwrap();
4757 assert_eq!(resp.status().as_u16(), 200);
4758
4759 token.cancel();
4760 }
4761
4762 #[tokio::test]
4763 async fn test_consumer_extracts_mixed_case_traceparent_header() {
4764 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4765
4766 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4768 let port = listener.local_addr().unwrap().port();
4769 drop(listener);
4770
4771 let component = HttpComponent::new();
4772 let endpoint_ctx = NoOpComponentContext;
4773 let endpoint = component
4774 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4775 .unwrap();
4776 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4777
4778 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4779 let token = tokio_util::sync::CancellationToken::new();
4780 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4781
4782 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4783 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4784
4785 let client = reqwest::Client::new();
4787 let send_fut = client
4788 .post(format!("http://127.0.0.1:{port}/trace"))
4789 .header(
4790 "TraceParent",
4791 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4792 )
4793 .body("test")
4794 .send();
4795
4796 let (http_result, _) = tokio::join!(send_fut, async {
4797 if let Some(envelope) = rx.recv().await {
4798 let mut injected_headers = HashMap::new();
4801 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4802
4803 assert!(
4804 injected_headers.contains_key("traceparent"),
4805 "Exchange should have traceparent after extraction from mixed-case header"
4806 );
4807
4808 let traceparent = injected_headers.get("traceparent").unwrap();
4809 let parts: Vec<&str> = traceparent.split('-').collect();
4810 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4811 assert_eq!(
4812 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4813 "Trace ID should match the original mixed-case TraceParent header"
4814 );
4815
4816 if let Some(reply_tx) = envelope.reply_tx {
4817 let _ = reply_tx.send(Ok(envelope.exchange));
4818 }
4819 }
4820 });
4821
4822 let resp = http_result.unwrap();
4823 assert_eq!(resp.status().as_u16(), 200);
4824
4825 token.cancel();
4826 }
4827
4828 #[tokio::test]
4829 async fn test_producer_no_trace_context_no_crash() {
4830 let (url, _handle) = start_test_server().await;
4831 let ctx = test_producer_ctx();
4832
4833 let component = HttpComponent::new();
4834 let endpoint_ctx = NoOpComponentContext;
4835 let endpoint = component
4836 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4837 .unwrap();
4838 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4839
4840 let exchange = Exchange::new(Message::default());
4842
4843 let result = producer.oneshot(exchange).await.unwrap();
4845
4846 let status = result
4848 .input
4849 .header("CamelHttpResponseCode")
4850 .and_then(|v| v.as_u64())
4851 .unwrap();
4852 assert_eq!(status, 200);
4853 }
4854
4855 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
4857 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4858 let addr = listener.local_addr().unwrap();
4859 let url = format!("http://127.0.0.1:{}", addr.port());
4860
4861 let handle = tokio::spawn(async move {
4862 loop {
4863 if let Ok((mut stream, _)) = listener.accept().await {
4864 tokio::spawn(async move {
4865 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4866 let mut buf = vec![0u8; 8192];
4867 let n = stream.read(&mut buf).await.unwrap_or(0);
4868 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4869
4870 let traceparent = request
4872 .lines()
4873 .find(|line| line.to_lowercase().starts_with("traceparent:"))
4874 .map(|line| {
4875 line.split(':')
4876 .nth(1)
4877 .map(|s| s.trim().to_string())
4878 .unwrap_or_default()
4879 })
4880 .unwrap_or_default();
4881
4882 let body =
4883 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
4884 let response = format!(
4885 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
4886 body.len(),
4887 traceparent,
4888 body
4889 );
4890 let _ = stream.write_all(response.as_bytes()).await;
4891 });
4892 }
4893 }
4894 });
4895
4896 (url, handle)
4897 }
4898 }
4899
4900 #[tokio::test]
4909 async fn test_request_body_arrives_as_stream() {
4910 use camel_component_api::Body;
4911 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4912
4913 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4914 let port = listener.local_addr().unwrap().port();
4915 drop(listener);
4916
4917 let component = HttpComponent::new();
4918 let endpoint_ctx = NoOpComponentContext;
4919 let endpoint = component
4920 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
4921 .unwrap();
4922 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4923
4924 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4925 let token = tokio_util::sync::CancellationToken::new();
4926 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4927
4928 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4929 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4930
4931 let client = reqwest::Client::new();
4932 let send_fut = client
4933 .post(format!("http://127.0.0.1:{port}/upload"))
4934 .body("hello streaming world")
4935 .send();
4936
4937 let (http_result, _) = tokio::join!(send_fut, async {
4938 if let Some(mut envelope) = rx.recv().await {
4939 assert!(
4941 matches!(envelope.exchange.input.body, Body::Stream(_)),
4942 "expected Body::Stream, got discriminant {:?}",
4943 std::mem::discriminant(&envelope.exchange.input.body)
4944 );
4945 let bytes = envelope
4947 .exchange
4948 .input
4949 .body
4950 .into_bytes(1024 * 1024)
4951 .await
4952 .unwrap();
4953 assert_eq!(&bytes[..], b"hello streaming world");
4954
4955 envelope.exchange.input.body = camel_component_api::Body::Empty;
4956 if let Some(reply_tx) = envelope.reply_tx {
4957 let _ = reply_tx.send(Ok(envelope.exchange));
4958 }
4959 }
4960 });
4961
4962 let resp = http_result.unwrap();
4963 assert_eq!(resp.status().as_u16(), 200);
4964
4965 token.cancel();
4966 }
4967
4968 #[tokio::test]
4973 async fn test_streaming_response_chunked() {
4974 use bytes::Bytes;
4975 use camel_component_api::Body;
4976 use camel_component_api::CamelError;
4977 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4978 use camel_component_api::{StreamBody, StreamMetadata};
4979 use futures::stream;
4980 use std::sync::Arc;
4981 use tokio::sync::Mutex;
4982
4983 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4984 let port = listener.local_addr().unwrap().port();
4985 drop(listener);
4986
4987 let component = HttpComponent::new();
4988 let endpoint_ctx = NoOpComponentContext;
4989 let endpoint = component
4990 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
4991 .unwrap();
4992 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4993
4994 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4995 let token = tokio_util::sync::CancellationToken::new();
4996 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4997
4998 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4999 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5000
5001 let client = reqwest::Client::new();
5002 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
5003
5004 let (http_result, _) = tokio::join!(send_fut, async {
5005 if let Some(mut envelope) = rx.recv().await {
5006 let chunks: Vec<Result<Bytes, CamelError>> =
5008 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
5009 let stream = Box::pin(stream::iter(chunks));
5010 envelope.exchange.input.body = Body::Stream(StreamBody {
5011 stream: Arc::new(Mutex::new(Some(stream))),
5012 metadata: StreamMetadata::default(),
5013 });
5014 if let Some(reply_tx) = envelope.reply_tx {
5015 let _ = reply_tx.send(Ok(envelope.exchange));
5016 }
5017 }
5018 });
5019
5020 let resp = http_result.unwrap();
5021 assert_eq!(resp.status().as_u16(), 200);
5022 let body = resp.text().await.unwrap();
5023 assert_eq!(body, "chunk1chunk2");
5024
5025 token.cancel();
5026 }
5027
5028 #[tokio::test]
5033 async fn test_413_when_content_length_exceeds_limit() {
5034 use camel_component_api::ConsumerContext;
5035
5036 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5037 let port = listener.local_addr().unwrap().port();
5038 drop(listener);
5039
5040 let component = HttpComponent::new();
5042 let endpoint_ctx = NoOpComponentContext;
5043 let endpoint = component
5044 .create_endpoint(
5045 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
5046 &endpoint_ctx,
5047 )
5048 .unwrap();
5049 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5050
5051 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5052 let token = tokio_util::sync::CancellationToken::new();
5053 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5054
5055 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5056 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5057
5058 let client = reqwest::Client::new();
5059 let resp = client
5060 .post(format!("http://127.0.0.1:{port}/upload"))
5061 .header("Content-Length", "1000") .body("x".repeat(1000))
5063 .send()
5064 .await
5065 .unwrap();
5066
5067 assert_eq!(resp.status().as_u16(), 413);
5068
5069 token.cancel();
5070 }
5071
5072 #[tokio::test]
5076 async fn test_chunked_upload_without_content_length_bypasses_limit() {
5077 use bytes::Bytes;
5078 use camel_component_api::Body;
5079 use camel_component_api::ConsumerContext;
5080 use futures::stream;
5081
5082 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5083 let port = listener.local_addr().unwrap().port();
5084 drop(listener);
5085
5086 let component = HttpComponent::new();
5088 let endpoint_ctx = NoOpComponentContext;
5089 let endpoint = component
5090 .create_endpoint(
5091 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
5092 &endpoint_ctx,
5093 )
5094 .unwrap();
5095 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5096
5097 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5098 let token = tokio_util::sync::CancellationToken::new();
5099 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5100
5101 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5102 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5103
5104 let client = reqwest::Client::new();
5105
5106 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
5110 Ok(Bytes::from("y".repeat(50))),
5111 Ok(Bytes::from("y".repeat(50))),
5112 ];
5113 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
5114 let send_fut = client
5115 .post(format!("http://127.0.0.1:{port}/upload"))
5116 .body(stream_body)
5117 .send();
5118
5119 let consumer_fut = async {
5120 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
5122 Ok(Some(mut envelope)) => {
5123 assert!(
5124 matches!(envelope.exchange.input.body, Body::Stream(_)),
5125 "expected Body::Stream"
5126 );
5127 envelope.exchange.input.body = camel_component_api::Body::Empty;
5128 if let Some(reply_tx) = envelope.reply_tx {
5129 let _ = reply_tx.send(Ok(envelope.exchange));
5130 }
5131 }
5132 Ok(None) => panic!("consumer channel closed unexpectedly"),
5133 Err(_) => {
5134 }
5137 }
5138 };
5139
5140 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
5141
5142 let resp = http_result.unwrap();
5143 assert_ne!(
5145 resp.status().as_u16(),
5146 413,
5147 "chunked upload must not be rejected by maxRequestBody"
5148 );
5149 assert_eq!(resp.status().as_u16(), 200);
5150
5151 token.cancel();
5152 }
5153
5154 #[test]
5155 fn test_is_private_ip_ranges() {
5156 use camel_api::is_ssrf_blocked_ip;
5157 assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); assert!(!is_ssrf_blocked_ip(
5176 &"2001:4860:4860::8888".parse().unwrap()
5177 )); }
5179
5180 #[test]
5181 fn test_title_case_header() {
5182 assert_eq!(title_case_header("content-type"), "Content-Type");
5183 assert_eq!(title_case_header("authorization"), "Authorization");
5184 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
5185 assert_eq!(title_case_header("host"), "Host");
5186 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
5187 assert_eq!(title_case_header("single"), "Single");
5188 assert_eq!(title_case_header(""), "");
5189 }
5190
5191 #[test]
5192 fn test_resolve_url_combines_path_and_query_sources() {
5193 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
5194 let mut exchange = Exchange::new(Message::default());
5195 exchange.input.set_header(
5196 "CamelHttpPath",
5197 serde_json::Value::String("next".to_string()),
5198 );
5199 let url = HttpProducer::resolve_url(&exchange, &cfg);
5200 assert!(url.starts_with("http://example.com/base/next?"));
5201 assert!(url.contains("foo=bar"));
5202
5203 exchange.input.set_header(
5204 "CamelHttpUri",
5205 serde_json::Value::String("http://other.test/root".to_string()),
5206 );
5207 exchange.input.set_header(
5208 "CamelHttpQuery",
5209 serde_json::Value::String("a=1&b=2".to_string()),
5210 );
5211
5212 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
5213 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
5214 }
5215
5216 #[test]
5217 fn test_http_producer_helpers_status_and_size_boundaries() {
5218 assert!(HttpProducer::is_ok_status(200, (200, 299)));
5219 assert!(HttpProducer::is_ok_status(299, (200, 299)));
5220 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
5221 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
5222
5223 assert!(!exceeds_max_response_body(10, 10));
5224 assert!(exceeds_max_response_body(11, 10));
5225 }
5226
5227 async fn setup_consumer_on_free_port(
5232 path: &str,
5233 ) -> (
5234 u16,
5235 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
5236 tokio_util::sync::CancellationToken,
5237 ) {
5238 use camel_component_api::ConsumerContext;
5239
5240 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5241 let port = listener.local_addr().unwrap().port();
5242 drop(listener);
5243
5244 let consumer_cfg = HttpServerConfig {
5245 scheme: "http".to_string(),
5246 host: "127.0.0.1".to_string(),
5247 port,
5248 path: path.to_string(),
5249 max_request_body: 2 * 1024 * 1024,
5250 max_response_body: 10 * 1024 * 1024,
5251 max_inflight_requests: 1024,
5252 method: None,
5253 tls_config: None,
5254 };
5255 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5256
5257 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5258 let token = tokio_util::sync::CancellationToken::new();
5259 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5260
5261 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5262 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5263
5264 (port, rx, token)
5265 }
5266
5267 #[tokio::test]
5268 async fn test_content_type_inferred_for_json_body() {
5269 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
5270
5271 let client = reqwest::Client::new();
5272 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
5273
5274 let (http_result, _) = tokio::join!(send_fut, async {
5275 if let Some(mut envelope) = rx.recv().await {
5276 envelope.exchange.input.body =
5277 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
5278 if let Some(reply_tx) = envelope.reply_tx {
5279 let _ = reply_tx.send(Ok(envelope.exchange));
5280 }
5281 }
5282 });
5283
5284 let resp = http_result.unwrap();
5285 assert_eq!(resp.status().as_u16(), 200);
5286 let ct = resp
5287 .headers()
5288 .get("content-type")
5289 .expect("Content-Type header should be present");
5290 assert_eq!(ct, "application/json");
5291 let body = resp.text().await.unwrap();
5292 assert_eq!(body, r#"{"message":"hello"}"#);
5293
5294 token.cancel();
5295 }
5296
5297 #[tokio::test]
5298 async fn test_content_type_inferred_for_text_body() {
5299 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
5300
5301 let client = reqwest::Client::new();
5302 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
5303
5304 let (http_result, _) = tokio::join!(send_fut, async {
5305 if let Some(mut envelope) = rx.recv().await {
5306 envelope.exchange.input.body =
5307 camel_component_api::Body::Text("plain text response".to_string());
5308 if let Some(reply_tx) = envelope.reply_tx {
5309 let _ = reply_tx.send(Ok(envelope.exchange));
5310 }
5311 }
5312 });
5313
5314 let resp = http_result.unwrap();
5315 assert_eq!(resp.status().as_u16(), 200);
5316 let ct = resp
5317 .headers()
5318 .get("content-type")
5319 .expect("Content-Type header should be present");
5320 assert_eq!(ct, "text/plain; charset=utf-8");
5321 let body = resp.text().await.unwrap();
5322 assert_eq!(body, "plain text response");
5323
5324 token.cancel();
5325 }
5326
5327 #[tokio::test]
5328 async fn test_content_type_inferred_for_xml_body() {
5329 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
5330
5331 let client = reqwest::Client::new();
5332 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
5333
5334 let (http_result, _) = tokio::join!(send_fut, async {
5335 if let Some(mut envelope) = rx.recv().await {
5336 envelope.exchange.input.body =
5337 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
5338 if let Some(reply_tx) = envelope.reply_tx {
5339 let _ = reply_tx.send(Ok(envelope.exchange));
5340 }
5341 }
5342 });
5343
5344 let resp = http_result.unwrap();
5345 assert_eq!(resp.status().as_u16(), 200);
5346 let ct = resp
5347 .headers()
5348 .get("content-type")
5349 .expect("Content-Type header should be present");
5350 assert_eq!(ct, "application/xml");
5351 let body = resp.text().await.unwrap();
5352 assert_eq!(body, "<root><item>value</item></root>");
5353
5354 token.cancel();
5355 }
5356
5357 #[tokio::test]
5358 async fn test_no_content_type_for_empty_body() {
5359 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
5360
5361 let client = reqwest::Client::new();
5362 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
5363
5364 let (http_result, _) = tokio::join!(send_fut, async {
5365 if let Some(mut envelope) = rx.recv().await {
5366 envelope.exchange.input.body = camel_component_api::Body::Empty;
5367 if let Some(reply_tx) = envelope.reply_tx {
5368 let _ = reply_tx.send(Ok(envelope.exchange));
5369 }
5370 }
5371 });
5372
5373 let resp = http_result.unwrap();
5374 assert_eq!(resp.status().as_u16(), 200);
5375 assert!(
5376 resp.headers().get("content-type").is_none(),
5377 "Empty body should not set Content-Type"
5378 );
5379
5380 token.cancel();
5381 }
5382
5383 #[tokio::test]
5384 async fn test_no_content_type_for_raw_bytes_body() {
5385 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
5386
5387 let client = reqwest::Client::new();
5388 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
5389
5390 let (http_result, _) = tokio::join!(send_fut, async {
5391 if let Some(mut envelope) = rx.recv().await {
5392 envelope.exchange.input.body =
5393 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
5394 if let Some(reply_tx) = envelope.reply_tx {
5395 let _ = reply_tx.send(Ok(envelope.exchange));
5396 }
5397 }
5398 });
5399
5400 let resp = http_result.unwrap();
5401 assert_eq!(resp.status().as_u16(), 200);
5402 assert!(
5403 resp.headers().get("content-type").is_none(),
5404 "Raw Bytes body should not set Content-Type"
5405 );
5406
5407 token.cancel();
5408 }
5409
5410 #[tokio::test]
5411 async fn test_content_type_from_stream_metadata() {
5412 use camel_component_api::{StreamBody, StreamMetadata};
5413 use futures::stream;
5414
5415 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
5416
5417 let client = reqwest::Client::new();
5418 let send_fut = client
5419 .get(format!("http://127.0.0.1:{port}/stream-ct"))
5420 .send();
5421
5422 let (http_result, _) = tokio::join!(send_fut, async {
5423 if let Some(mut envelope) = rx.recv().await {
5424 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5425 vec![Ok(bytes::Bytes::from("audio data"))];
5426 let stream = Box::pin(stream::iter(chunks));
5427 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5428 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5429 metadata: StreamMetadata {
5430 size_hint: None,
5431 content_type: Some("audio/mpeg".to_string()),
5432 origin: None,
5433 },
5434 });
5435 if let Some(reply_tx) = envelope.reply_tx {
5436 let _ = reply_tx.send(Ok(envelope.exchange));
5437 }
5438 }
5439 });
5440
5441 let resp = http_result.unwrap();
5442 assert_eq!(resp.status().as_u16(), 200);
5443 let ct = resp
5444 .headers()
5445 .get("content-type")
5446 .expect("Content-Type header should be present");
5447 assert_eq!(ct, "audio/mpeg");
5448 let body = resp.text().await.unwrap();
5449 assert_eq!(body, "audio data");
5450
5451 token.cancel();
5452 }
5453
5454 #[tokio::test]
5455 async fn test_user_content_type_overrides_inferred() {
5456 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
5457
5458 let client = reqwest::Client::new();
5459 let send_fut = client
5460 .get(format!("http://127.0.0.1:{port}/override-ct"))
5461 .send();
5462
5463 let (http_result, _) = tokio::join!(send_fut, async {
5464 if let Some(mut envelope) = rx.recv().await {
5465 envelope.exchange.input.body =
5466 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
5467 envelope.exchange.input.set_header(
5468 "Content-Type",
5469 serde_json::Value::String("text/html".to_string()),
5470 );
5471 if let Some(reply_tx) = envelope.reply_tx {
5472 let _ = reply_tx.send(Ok(envelope.exchange));
5473 }
5474 }
5475 });
5476
5477 let resp = http_result.unwrap();
5478 assert_eq!(resp.status().as_u16(), 200);
5479 let ct = resp
5480 .headers()
5481 .get("content-type")
5482 .expect("Content-Type header should be present");
5483 assert_eq!(
5484 ct, "text/html",
5485 "User-set Content-Type should take precedence over inferred type"
5486 );
5487
5488 token.cancel();
5489 }
5490
5491 #[tokio::test]
5492 async fn test_user_content_type_with_bytes_body() {
5493 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
5494
5495 let client = reqwest::Client::new();
5496 let send_fut = client
5497 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
5498 .send();
5499
5500 let (http_result, _) = tokio::join!(send_fut, async {
5501 if let Some(mut envelope) = rx.recv().await {
5502 envelope.exchange.input.body =
5503 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
5504 envelope.exchange.input.set_header(
5505 "Content-Type",
5506 serde_json::Value::String("application/json".to_string()),
5507 );
5508 if let Some(reply_tx) = envelope.reply_tx {
5509 let _ = reply_tx.send(Ok(envelope.exchange));
5510 }
5511 }
5512 });
5513
5514 let resp = http_result.unwrap();
5515 assert_eq!(resp.status().as_u16(), 200);
5516 let ct = resp
5517 .headers()
5518 .get("content-type")
5519 .expect("Content-Type header should be present for Bytes body with user header");
5520 assert_eq!(
5521 ct, "application/json",
5522 "User Content-Type should be sent for Bytes body"
5523 );
5524
5525 token.cancel();
5526 }
5527
5528 #[tokio::test]
5533 async fn monitor_task_silent_on_clean_exit() {
5534 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
5535 monitor_axum_task(
5537 handle,
5538 "127.0.0.1:0".to_string(),
5539 noop_rt(),
5540 "test-monitor".into(),
5541 )
5542 .await;
5543 }
5544
5545 #[tokio::test]
5546 async fn monitor_task_handles_panicked_task() {
5547 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
5548 panic!("simulated server crash");
5549 });
5550 monitor_axum_task(
5552 handle,
5553 "127.0.0.1:9999".to_string(),
5554 noop_rt(),
5555 "test-monitor".into(),
5556 )
5557 .await;
5558 }
5559
5560 #[test]
5565 fn http_auth_basic_debug_redacts_password() {
5566 let auth = HttpAuth::Basic {
5567 username: "admin".to_string(),
5568 password: "hunter2".to_string(),
5569 };
5570 let debug = format!("{:?}", auth);
5571 assert!(
5572 !debug.contains("hunter2"),
5573 "password must be redacted: {debug}"
5574 );
5575 assert!(debug.contains("admin"), "username should appear: {debug}");
5576 }
5577
5578 #[test]
5579 fn http_auth_bearer_debug_redacts_token() {
5580 let auth = HttpAuth::Bearer {
5581 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
5582 };
5583 let debug = format!("{:?}", auth);
5584 assert!(
5585 !debug.contains("eyJhbGci"),
5586 "token must be redacted: {debug}"
5587 );
5588 }
5589
5590 #[test]
5591 fn http_auth_none_debug_shows_variant() {
5592 let debug = format!("{:?}", HttpAuth::None);
5593 assert!(
5594 debug.contains("None"),
5595 "None variant should appear: {debug}"
5596 );
5597 }
5598
5599 #[test]
5600 fn http_endpoint_config_debug_redacts_auth_credentials() {
5601 let config = HttpEndpointConfig::from_uri(
5602 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
5603 )
5604 .unwrap();
5605 let debug = format!("{:?}", config);
5606 assert!(
5607 !debug.contains("secret123"),
5608 "password must be redacted in HttpEndpointConfig debug: {debug}"
5609 );
5610 }
5611
5612 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
5617 use tower_http::services::ServeDir;
5618
5619 fn make_test_registry() -> HttpRouteRegistry {
5620 HttpRouteRegistry::new()
5621 }
5622
5623 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
5624 AppState {
5625 registry,
5626 max_request_body: 2 * 1024 * 1024,
5627 max_response_body: 10 * 1024 * 1024,
5628 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
5629 }
5630 }
5631
5632 #[allow(clippy::await_holding_lock)]
5633 #[tokio::test]
5634 async fn test_static_file_serving_serves_file_contents() {
5635 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5636 ServerRegistry::reset();
5637
5638 let temp_dir =
5640 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
5641 std::fs::create_dir_all(&temp_dir).unwrap();
5642 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
5643 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
5644
5645 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5646
5647 let registry = make_test_registry();
5648 let serve_dir = ServeDir::new(&canonical_dir)
5649 .precompressed_gzip()
5650 .precompressed_br()
5651 .append_index_html_on_directories(true);
5652
5653 let mount = StaticMount {
5654 mount_path: "/".to_string(),
5655 mode: MountMode::Static,
5656 dir: canonical_dir.clone(),
5657 cache_control: "public, max-age=3600".to_string(),
5658 error_pages: std::collections::HashMap::new(),
5659 serve_dir,
5660 };
5661 registry.register_static_mount(mount).await.unwrap();
5662
5663 let state = make_test_state(registry);
5664
5665 let req = Request::builder()
5667 .uri("/hello.txt")
5668 .body(AxumBody::empty())
5669 .unwrap();
5670 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
5671 assert_eq!(resp.status(), StatusCode::OK);
5672 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5673 .await
5674 .unwrap();
5675 assert_eq!(&body[..], b"Hello, static world!");
5676
5677 let req = Request::builder()
5679 .uri("/style.css")
5680 .body(AxumBody::empty())
5681 .unwrap();
5682 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5683 assert_eq!(resp.status(), StatusCode::OK);
5684 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5685 .await
5686 .unwrap();
5687 assert_eq!(&body[..], b"body { color: red; }");
5688
5689 let req = Request::builder()
5691 .uri("/missing.txt")
5692 .body(AxumBody::empty())
5693 .unwrap();
5694 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
5695 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5696
5697 std::fs::remove_dir_all(&temp_dir).ok();
5699 }
5700
5701 #[allow(clippy::await_holding_lock)]
5702 #[tokio::test]
5703 async fn test_spa_fallback_serves_index_for_unknown_paths() {
5704 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5705 ServerRegistry::reset();
5706
5707 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
5708 std::fs::create_dir_all(&temp_dir).unwrap();
5709 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
5710 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
5711
5712 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5713
5714 let registry = make_test_registry();
5715 let serve_dir = ServeDir::new(&canonical_dir)
5716 .precompressed_gzip()
5717 .precompressed_br()
5718 .append_index_html_on_directories(true);
5719
5720 let mount = StaticMount {
5721 mount_path: "/".to_string(),
5722 mode: MountMode::Spa,
5723 dir: canonical_dir.clone(),
5724 cache_control: "public, max-age=0".to_string(),
5725 error_pages: std::collections::HashMap::new(),
5726 serve_dir,
5727 };
5728 registry.register_static_mount(mount).await.unwrap();
5730
5731 let state = make_test_state(registry);
5732
5733 let req = Request::builder()
5735 .method("GET")
5736 .uri("/dashboard")
5737 .header("Accept", "text/html")
5738 .body(AxumBody::empty())
5739 .unwrap();
5740 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
5741 assert_eq!(resp.status(), StatusCode::OK);
5742 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5743 .await
5744 .unwrap();
5745 assert_eq!(&body[..], b"<h1>SPA App</h1>");
5746
5747 let req = Request::builder()
5749 .method("GET")
5750 .uri("/app.js")
5751 .body(AxumBody::empty())
5752 .unwrap();
5753 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
5754 assert_eq!(resp.status(), StatusCode::OK);
5755 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5756 .await
5757 .unwrap();
5758 assert_eq!(&body[..], b"console.log('app')");
5759
5760 let req = Request::builder()
5762 .method("GET")
5763 .uri("/api/data")
5764 .header("Accept", "application/json")
5765 .body(AxumBody::empty())
5766 .unwrap();
5767 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
5768 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5769
5770 let req = Request::builder()
5772 .method("GET")
5773 .uri("/style.css")
5774 .header("Accept", "text/html")
5775 .body(AxumBody::empty())
5776 .unwrap();
5777 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5778 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5779
5780 std::fs::remove_dir_all(&temp_dir).ok();
5782 }
5783
5784 #[allow(clippy::await_holding_lock)]
5785 #[tokio::test]
5786 async fn test_error_page_mapping_serves_custom_404() {
5787 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5788 ServerRegistry::reset();
5789
5790 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
5791 let errors_dir = temp_dir.join("errors");
5792 std::fs::create_dir_all(&errors_dir).unwrap();
5793 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
5794 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
5795
5796 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5797 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
5798
5799 let registry = make_test_registry();
5800 let serve_dir = ServeDir::new(&canonical_dir)
5801 .precompressed_gzip()
5802 .precompressed_br()
5803 .append_index_html_on_directories(true);
5804
5805 let mut error_pages = std::collections::HashMap::new();
5806 error_pages.insert(404, canonical_404);
5807
5808 let mount = StaticMount {
5809 mount_path: "/".to_string(),
5810 mode: MountMode::Static,
5811 dir: canonical_dir.clone(),
5812 cache_control: "public, max-age=0".to_string(),
5813 error_pages,
5814 serve_dir,
5815 };
5816 registry.register_static_mount(mount).await.unwrap();
5817
5818 let state = make_test_state(registry);
5819
5820 let req = Request::builder()
5822 .method("GET")
5823 .uri("/missing.html")
5824 .body(AxumBody::empty())
5825 .unwrap();
5826 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
5827 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5828 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5829 .await
5830 .unwrap();
5831 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
5832
5833 let req = Request::builder()
5835 .method("GET")
5836 .uri("/index.html")
5837 .body(AxumBody::empty())
5838 .unwrap();
5839 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
5840 assert_eq!(resp.status(), StatusCode::OK);
5841 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5842 .await
5843 .unwrap();
5844 assert_eq!(&body[..], b"<h1>Home</h1>");
5845
5846 std::fs::remove_dir_all(&temp_dir).ok();
5848 }
5849
5850 #[tokio::test]
5851 async fn http_consumer_returns_body_and_code_on_stop() {
5852 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
5853 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
5854 use tower::ServiceExt;
5855
5856 let set_body_step = CompiledStep::Process {
5858 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5859 ex.input.body = Body::Text("nope".into());
5860 Box::pin(async move { Ok(ex) })
5861 }),
5862 body_contract: None,
5863 lifecycle: None,
5864 };
5865 let set_status_step = CompiledStep::Process {
5866 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5867 ex.input.set_header(
5868 "CamelHttpResponseCode",
5869 serde_json::Value::Number(409.into()),
5870 );
5871 Box::pin(async move { Ok(ex) })
5872 }),
5873 body_contract: None,
5874 lifecycle: None,
5875 };
5876 let pipeline = compose_pipeline_with_handler(
5877 vec![set_body_step, set_status_step, CompiledStep::Stop],
5878 None,
5879 PipelineRuntimeCtx::compile_time(),
5880 );
5881
5882 let ex = Exchange::new(Message::default());
5883 let result = pipeline.oneshot(ex).await;
5884 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
5885 let returned = result.unwrap();
5886 assert_eq!(returned.input.body.as_text(), Some("nope"));
5887 assert_eq!(
5888 returned
5889 .input
5890 .header("CamelHttpResponseCode")
5891 .and_then(|v| v.as_u64()),
5892 Some(409)
5893 );
5894 }
5895
5896 #[tokio::test]
5897 async fn http_consumer_returns_200_when_body_empty_on_stop() {
5898 use camel_api::{Exchange, Message};
5906 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
5907 use tower::ServiceExt;
5908
5909 let pipeline = compose_pipeline_with_handler(
5910 vec![CompiledStep::Stop],
5911 None,
5912 PipelineRuntimeCtx::compile_time(),
5913 );
5914 let ex = Exchange::new(Message::default());
5915 let result = pipeline.oneshot(ex).await;
5916 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
5917 }
5920
5921 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
5929 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5930 let port = listener.local_addr().unwrap().port();
5931 let registry = HttpRouteRegistry::new();
5932 tokio::spawn(run_axum_server(
5933 listener,
5934 registry.clone(),
5935 2 * 1024 * 1024,
5936 10 * 1024 * 1024,
5937 Arc::new(tokio::sync::Semaphore::new(1024)),
5938 test_rt(),
5939 "test-route".into(),
5940 ));
5941 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5943 (port, registry)
5944 }
5945
5946 fn spawn_responder(
5951 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
5952 status: u16,
5953 body: String,
5954 ) -> tokio::task::JoinHandle<()> {
5955 tokio::spawn(async move {
5956 if let Some(envelope) = rx.recv().await {
5957 let _ = envelope.reply_tx.send(HttpReply {
5958 status,
5959 headers: vec![],
5960 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
5961 });
5962 }
5963 })
5964 }
5965
5966 #[tokio::test]
5967 async fn method_aware_dispatch_same_path_different_verbs() {
5968 let (port, registry) = spawn_test_server().await;
5969
5970 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5975 registry
5976 .register_rest_endpoint(
5977 "GET".into(),
5978 vec![PathSegment::Literal("users".into())],
5979 get_tx,
5980 )
5981 .await;
5982
5983 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5984 registry
5985 .register_rest_endpoint(
5986 "POST".into(),
5987 vec![PathSegment::Literal("users".into())],
5988 post_tx,
5989 )
5990 .await;
5991
5992 let get_handle = spawn_responder(get_rx, 200, "list".into());
5993 let post_handle = spawn_responder(post_rx, 201, "create".into());
5994
5995 let client = reqwest::Client::new();
5996
5997 let resp = client
5999 .get(format!("http://127.0.0.1:{port}/users"))
6000 .send()
6001 .await
6002 .unwrap();
6003 assert_eq!(resp.status().as_u16(), 200);
6004 let body = resp.text().await.unwrap();
6005 assert_eq!(body, "list");
6006
6007 let resp = client
6009 .post(format!("http://127.0.0.1:{port}/users"))
6010 .send()
6011 .await
6012 .unwrap();
6013 assert_eq!(resp.status().as_u16(), 201);
6014 let body = resp.text().await.unwrap();
6015 assert_eq!(body, "create");
6016
6017 let _ = tokio::join!(get_handle, post_handle);
6018 }
6019
6020 #[tokio::test]
6021 async fn method_aware_dispatch_templated_path_extracts_params() {
6022 let (port, registry) = spawn_test_server().await;
6023
6024 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6028 registry
6029 .register_rest_endpoint(
6030 "GET".into(),
6031 vec![
6032 PathSegment::Literal("users".into()),
6033 PathSegment::Param("id".into()),
6034 ],
6035 tx,
6036 )
6037 .await;
6038
6039 let handle = tokio::spawn(async move {
6042 if let Some(envelope) = rx.recv().await {
6043 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
6044 let _ = envelope.reply_tx.send(HttpReply {
6045 status: 200,
6046 headers: vec![],
6047 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
6048 });
6049 }
6050 });
6051
6052 let client = reqwest::Client::new();
6053 let resp = client
6054 .get(format!("http://127.0.0.1:{port}/users/42"))
6055 .send()
6056 .await
6057 .unwrap();
6058 assert_eq!(resp.status().as_u16(), 200);
6059 let body = resp.text().await.unwrap();
6060 assert_eq!(body, "id=42");
6061
6062 let _ = handle.await;
6063 }
6064
6065 #[tokio::test]
6066 async fn method_aware_dispatch_unmatched_method_falls_through() {
6067 let (port, _registry) = spawn_test_server().await;
6072
6073 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6075 _registry
6076 .register_rest_endpoint(
6077 "GET".into(),
6078 vec![PathSegment::Literal("users".into())],
6079 get_tx,
6080 )
6081 .await;
6082
6083 let drain = tokio::spawn(async move {
6086 let mut get_rx = get_rx;
6087 while get_rx.recv().await.is_some() {}
6088 });
6089
6090 let client = reqwest::Client::new();
6091 let resp = client
6092 .delete(format!("http://127.0.0.1:{port}/users"))
6093 .send()
6094 .await
6095 .unwrap();
6096 assert_eq!(resp.status().as_u16(), 404);
6097
6098 drop(drain);
6099 }
6100
6101 #[tokio::test]
6102 async fn regression_legacy_exact_api_route_still_works() {
6103 let (port, registry) = spawn_test_server().await;
6108
6109 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6110 registry.register_api_route("/legacy/path".into(), tx).await;
6111
6112 let handle = tokio::spawn(async move {
6113 if let Some(envelope) = rx.recv().await {
6114 let _ = envelope.reply_tx.send(HttpReply {
6115 status: 200,
6116 headers: vec![],
6117 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
6118 });
6119 }
6120 });
6121
6122 let client = reqwest::Client::new();
6123 let resp = client
6124 .get(format!("http://127.0.0.1:{port}/legacy/path"))
6125 .send()
6126 .await
6127 .unwrap();
6128 assert_eq!(resp.status().as_u16(), 200);
6129 let body = resp.text().await.unwrap();
6130 assert_eq!(body, "legacy ok");
6131
6132 let _ = handle.await;
6133 }
6134
6135 #[allow(clippy::await_holding_lock)]
6136 #[tokio::test]
6137 async fn regression_static_mount_still_works() {
6138 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6142 ServerRegistry::reset();
6143
6144 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
6145 std::fs::create_dir_all(&temp_dir).unwrap();
6146 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
6147 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6148
6149 let registry = make_test_registry();
6150 let serve_dir = ServeDir::new(&canonical_dir)
6151 .precompressed_gzip()
6152 .precompressed_br()
6153 .append_index_html_on_directories(true);
6154 let mount = StaticMount {
6155 mount_path: "/".to_string(),
6156 mode: MountMode::Static,
6157 dir: canonical_dir.clone(),
6158 cache_control: "public, max-age=3600".to_string(),
6159 error_pages: std::collections::HashMap::new(),
6160 serve_dir,
6161 };
6162 registry.register_static_mount(mount).await.unwrap();
6163
6164 let state = make_test_state(registry);
6165 let req = Request::builder()
6166 .uri("/regress.txt")
6167 .body(AxumBody::empty())
6168 .unwrap();
6169 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
6170 assert_eq!(resp.status(), StatusCode::OK);
6171 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6172 .await
6173 .unwrap();
6174 assert_eq!(&body[..], b"static works");
6175
6176 std::fs::remove_dir_all(&temp_dir).ok();
6177 }
6178
6179 #[tokio::test]
6188 async fn deregister_one_method_keeps_sibling_verbs() {
6189 let (port, registry) = spawn_test_server().await;
6193
6194 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6195 registry
6196 .register_rest_endpoint(
6197 "GET".into(),
6198 vec![PathSegment::Literal("users".into())],
6199 get_tx,
6200 )
6201 .await;
6202
6203 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6204 registry
6205 .register_rest_endpoint(
6206 "POST".into(),
6207 vec![PathSegment::Literal("users".into())],
6208 post_tx,
6209 )
6210 .await;
6211
6212 let drain = tokio::spawn(async move {
6214 let mut get_rx = get_rx;
6215 while get_rx.recv().await.is_some() {}
6216 });
6217
6218 registry.unregister_rest_endpoint("GET", "/users").await;
6220 drop(drain);
6221
6222 let post_handle = spawn_responder(post_rx, 201, "create".into());
6223
6224 let client = reqwest::Client::new();
6225 let resp = client
6227 .post(format!("http://127.0.0.1:{port}/users"))
6228 .send()
6229 .await
6230 .unwrap();
6231 assert_eq!(resp.status().as_u16(), 201);
6232 assert_eq!(resp.text().await.unwrap(), "create");
6233
6234 let _ = post_handle.await;
6235 }
6236
6237 #[tokio::test]
6238 async fn dispatch_exact_legacy_beats_rest_template() {
6239 let (port, registry) = spawn_test_server().await;
6244
6245 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6247 registry
6248 .register_api_route("/api/users".into(), exact_tx)
6249 .await;
6250 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
6251
6252 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6254 registry
6255 .register_rest_endpoint(
6256 "GET".into(),
6257 vec![
6258 PathSegment::Literal("api".into()),
6259 PathSegment::Param("resource".into()),
6260 ],
6261 tpl_tx,
6262 )
6263 .await;
6264 let _tpl_drain = tokio::spawn(async move {
6270 let mut tpl_rx = tpl_rx;
6271 if let Some(env) = tpl_rx.recv().await {
6272 let _ = env.reply_tx.send(HttpReply {
6273 status: 200,
6274 headers: vec![],
6275 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
6276 });
6277 }
6278 });
6279
6280 let client = reqwest::Client::new();
6281 let resp = client
6282 .get(format!("http://127.0.0.1:{port}/api/users"))
6283 .send()
6284 .await
6285 .unwrap();
6286 assert_eq!(resp.status().as_u16(), 200);
6287 assert_eq!(resp.text().await.unwrap(), "exact");
6289
6290 let _ = exact_handle.await;
6291 }
6292
6293 #[tokio::test]
6294 async fn ambiguous_rest_templates_return_500_not_silent_404() {
6295 let (port, registry) = spawn_test_server().await;
6300
6301 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6302 registry
6303 .register_rest_endpoint(
6304 "GET".into(),
6305 vec![
6306 PathSegment::Literal("users".into()),
6307 PathSegment::Param("id".into()),
6308 ],
6309 a_tx,
6310 )
6311 .await;
6312
6313 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6314 registry
6315 .register_rest_endpoint(
6316 "GET".into(),
6317 vec![
6318 PathSegment::Literal("users".into()),
6319 PathSegment::Param("name".into()),
6320 ],
6321 b_tx,
6322 )
6323 .await;
6324
6325 let client = reqwest::Client::new();
6326 let resp = client
6327 .get(format!("http://127.0.0.1:{port}/users/42"))
6328 .send()
6329 .await
6330 .unwrap();
6331 assert_eq!(resp.status().as_u16(), 500);
6333 }
6334
6335 #[test]
6336 fn from_uri_round_trips_templated_path_with_http_method() {
6337 use crate::UriConfig;
6343 let cfg =
6344 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
6345 assert_eq!(cfg.host, "0.0.0.0");
6346 assert_eq!(cfg.port, 8080);
6347 assert_eq!(cfg.path, "/users/{id}");
6348 assert_eq!(cfg.method.as_deref(), Some("GET"));
6349
6350 let cfg_lc =
6352 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
6353 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
6354 assert_eq!(cfg_lc.path, "/orders");
6355 }
6356
6357 #[test]
6362 fn type_conversion_failed_maps_to_400() {
6363 let reply = pipeline_error_to_reply(
6364 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
6365 "/api/users",
6366 );
6367 assert_eq!(reply.status, 400);
6368 let ct = reply
6370 .headers
6371 .iter()
6372 .find(|(k, _)| k == "Content-Type")
6373 .map(|(_, v)| v.as_str());
6374 assert_eq!(ct, Some("application/json"));
6375 let body = match &reply.body {
6377 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6378 _ => panic!("expected bytes body"),
6379 };
6380 assert!(body.contains("\"error\""));
6381 assert!(body.contains("bad_request"));
6382 assert!(body.contains("invalid JSON at line 1"));
6383 }
6384
6385 #[test]
6386 fn other_error_still_maps_to_500() {
6387 let reply =
6388 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
6389 assert_eq!(reply.status, 500);
6390 }
6391
6392 #[test]
6393 fn unauthenticated_maps_to_401() {
6394 let reply = pipeline_error_to_reply(
6395 CamelError::Unauthenticated("no token".to_string()),
6396 "/api/users",
6397 );
6398 assert_eq!(reply.status, 401);
6399 }
6400
6401 #[test]
6402 fn unauthorized_maps_to_403() {
6403 let reply = pipeline_error_to_reply(
6404 CamelError::Unauthorized("forbidden".to_string()),
6405 "/api/users",
6406 );
6407 assert_eq!(reply.status, 403);
6408 }
6409
6410 #[test]
6411 fn validation_error_maps_to_400() {
6412 let reply = pipeline_error_to_reply(
6413 CamelError::ValidationError("body does not match schema".to_string()),
6414 "/api/users",
6415 );
6416 assert_eq!(reply.status, 400);
6417 let ct = reply
6418 .headers
6419 .iter()
6420 .find(|(k, _)| k == "Content-Type")
6421 .map(|(_, v)| v.as_str());
6422 assert_eq!(ct, Some("application/json"));
6423 let body = match &reply.body {
6424 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6425 _ => panic!("expected bytes body"),
6426 };
6427 assert!(body.contains("\"error\""));
6428 assert!(body.contains("validation_error"));
6429 assert!(body.contains("body does not match schema"));
6430 }
6431
6432 #[test]
6433 fn https_consumer_without_tls_cert_errors() {
6434 let endpoint = HttpEndpoint {
6435 uri: "https://0.0.0.0:8443/api".to_string(),
6436 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6437 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6438 client: reqwest::Client::new(),
6439 http_config: HttpConfig::default(),
6440 };
6441 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6442 let result = endpoint.create_consumer(rt);
6443 assert!(result.is_err(), "expected error for https without tls cert");
6444 if let Err(e) = result {
6445 let msg = e.to_string();
6446 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
6447 }
6448 }
6449
6450 #[test]
6451 fn http_consumer_with_tls_config_errors() {
6452 let endpoint = HttpEndpoint {
6453 uri: "http://0.0.0.0:8080/api".to_string(),
6454 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
6455 server_config: HttpServerConfig::from_uri(
6456 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
6457 )
6458 .unwrap(),
6459 client: reqwest::Client::new(),
6460 http_config: HttpConfig::default(),
6461 };
6462 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6463 let result = endpoint.create_consumer(rt);
6464 assert!(result.is_err(), "expected error for http with tls config");
6465 if let Err(e) = result {
6466 let msg = e.to_string();
6467 assert!(msg.contains("https"), "error must mention https: {msg}");
6468 }
6469 }
6470
6471 #[test]
6472 fn https_consumer_with_partial_tls_cert_only_errors() {
6473 let server_config =
6476 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6477 assert!(
6478 server_config.tls_config.is_none(),
6479 "partial tlsCert must not create ServerTlsConfig"
6480 );
6481 let endpoint = HttpEndpoint {
6482 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
6483 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
6484 .unwrap(),
6485 server_config,
6486 client: reqwest::Client::new(),
6487 http_config: HttpConfig::default(),
6488 };
6489 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6490 let result = endpoint.create_consumer(rt);
6491 assert!(
6492 result.is_err(),
6493 "must error: https:// requires both tlsCert and tlsKey"
6494 );
6495 }
6496
6497 #[test]
6498 fn load_tls_config_parses_valid_pem() {
6499 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6501 use camel_component_api::test_support::tls;
6502 let (_, cert_pem, key_pem) = tls::gen_server_cert();
6503 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
6504 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
6505
6506 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
6507 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
6508 }
6509
6510 #[tokio::test(flavor = "multi_thread")]
6511 #[allow(clippy::await_holding_lock)]
6512 async fn consumer_tls_handshake_roundtrip() {
6513 use camel_component_api::test_support::tls;
6514 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6515
6516 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6518
6519 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6521
6522 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6524 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
6525 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
6526 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
6527
6528 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6530 let port = probe.local_addr().unwrap().port();
6531 drop(probe);
6532
6533 ServerRegistry::reset();
6534
6535 let component = HttpComponent::new();
6537 let endpoint_ctx = NoOpComponentContext;
6538 let uri = format!(
6539 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6540 cert_path.to_string_lossy(),
6541 key_path.to_string_lossy(),
6542 );
6543 let endpoint = component
6544 .create_endpoint(&uri, &endpoint_ctx)
6545 .expect("create TLS endpoint");
6546 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
6547
6548 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6550 let token = tokio_util::sync::CancellationToken::new();
6551 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
6552 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6553
6554 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6556
6557 let ca_bytes = std::fs::read(&ca_path).unwrap();
6559 let client = reqwest::Client::builder()
6560 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
6561 .build()
6562 .unwrap();
6563
6564 let send_fut = client
6565 .post(format!("https://localhost:{port}/test"))
6566 .body("ping")
6567 .send();
6568
6569 let (http_result, _) = tokio::join!(send_fut, async {
6571 if let Some(mut envelope) = rx.recv().await {
6572 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
6573 if let Some(reply_tx) = envelope.reply_tx {
6574 let _ = reply_tx.send(Ok(envelope.exchange));
6575 }
6576 }
6577 });
6578
6579 let resp = http_result.expect("TLS handshake + request must succeed");
6580
6581 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
6582 let body = resp.text().await.unwrap();
6583 assert_eq!(body, "pong");
6584
6585 token.cancel();
6586 }
6587
6588 #[tokio::test(flavor = "multi_thread")]
6589 #[allow(clippy::await_holding_lock)]
6590 async fn consumer_tls_rejects_client_without_ca() {
6591 use camel_component_api::test_support::tls;
6592 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6593
6594 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6595
6596 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6598
6599 let (_, cert_pem, key_pem) = tls::gen_server_cert();
6600 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
6601 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
6602
6603 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6604 let port = probe.local_addr().unwrap().port();
6605 drop(probe);
6606
6607 ServerRegistry::reset();
6608
6609 let component = HttpComponent::new();
6611 let endpoint_ctx = NoOpComponentContext;
6612 let uri = format!(
6613 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6614 cert_path.to_string_lossy(),
6615 key_path.to_string_lossy(),
6616 );
6617 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
6618 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6619 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6620 let token = tokio_util::sync::CancellationToken::new();
6621 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
6622 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6623
6624 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6625
6626 let client = reqwest::Client::builder().build().unwrap();
6628
6629 let result = client
6630 .get(format!("https://localhost:{port}/test"))
6631 .send()
6632 .await;
6633
6634 assert!(
6635 result.is_err(),
6636 "must reject without CA — proves real verification"
6637 );
6638
6639 token.cancel();
6640 }
6641
6642 #[test]
6643 fn server_config_partial_tls_cert_without_key() {
6644 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6646 assert!(cfg.tls_config.is_none());
6648 }
6649}