1pub mod auth;
2pub mod bundle;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12pub(crate) mod tls_reload;
13use crate::config::parse_ok_status_code_range;
14pub use bundle::HttpBundle;
15pub use bundle::HttpStaticBundle;
16pub use config::HttpConfig;
17pub use health::HttpHealthCheck;
18pub use registry::HttpRouteRegistry;
19pub use static_config::HttpStaticConfig;
20pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
21
22use std::collections::HashMap;
23use std::future::Future;
24use std::pin::Pin;
25
26use std::sync::{Arc, Mutex, OnceLock};
27use std::task::{Context, Poll};
28use std::time::Duration;
29
30use tokio::sync::OnceCell;
31use tower::Layer;
32use tower::Service;
33use tracing::debug;
34
35use axum::body::BodyDataStream;
36use camel_api::component_metadata::ComponentMetadata;
37use camel_auth::bearer_token_layer::BearerTokenLayer;
38use camel_auth::oauth2::TokenProvider;
39use camel_component_api::tls_source::ServerTlsSource;
40use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
41use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
42use camel_component_api::{UriComponents, UriConfig, parse_uri};
43use futures::TryStreamExt;
44use futures::stream::BoxStream;
45
46#[derive(Debug, Clone)]
107pub struct HttpEndpointConfig {
108 pub base_url: String,
109 pub http_method: Option<String>,
110 pub throw_exception_on_failure: bool,
111 pub ok_status_code_range: (u16, u16),
112 pub response_timeout: Option<Duration>,
113 pub query_params: HashMap<String, String>,
114 pub allow_internal: bool,
115 pub blocked_hosts: Vec<String>,
116 pub max_body_size: usize,
117 pub read_timeout_ms: u64,
118 pub max_response_bytes: usize,
119 pub auth: HttpAuth,
120 pub token_provider: Option<Arc<dyn TokenProvider>>,
121 pub user_agent: Option<String>,
122 pub bridge_endpoint: bool,
123 pub connection_close: bool,
124 pub skip_request_headers: Vec<String>,
125 pub skip_response_headers: Vec<String>,
126 pub follow_redirects: bool,
127 pub max_redirects: usize,
128}
129
130#[derive(Clone, PartialEq)]
131pub enum HttpAuth {
132 None,
133 Basic { username: String, password: String },
134 Bearer { token: String },
135}
136
137impl std::fmt::Debug for HttpAuth {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 match self {
140 HttpAuth::None => f.write_str("None"),
141 HttpAuth::Basic { username, .. } => f
142 .debug_struct("Basic")
143 .field("username", username)
144 .field("password", &"***")
145 .finish(),
146 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
147 }
148 }
149}
150
151const HTTP_CAMEL_OPTIONS: &[&str] = &[
153 "httpMethod",
154 "throwExceptionOnFailure",
155 "okStatusCodeRange",
156 "followRedirects",
157 "maxRedirects",
158 "connectTimeout",
159 "responseTimeout",
160 "allowInternal",
161 "blockedHosts",
162 "maxBodySize",
163 "readTimeout",
164 "maxResponseBytes",
165 "authMethod",
166 "authUsername",
167 "authPassword",
168 "authBearerToken",
169 "userAgent",
170 "cookieHandling",
171 "bridgeEndpoint",
172 "connectionClose",
173 "skipRequestHeaders",
174 "skipResponseHeaders",
175];
176
177impl UriConfig for HttpEndpointConfig {
178 fn scheme() -> &'static str {
180 "http"
181 }
182
183 fn from_uri(uri: &str) -> Result<Self, CamelError> {
184 let parts = parse_uri(uri)?;
185 Self::from_components(parts)
186 }
187
188 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
189 if parts.scheme != "http" && parts.scheme != "https" {
191 return Err(CamelError::InvalidUri(format!(
192 "expected scheme 'http' or 'https', got '{}'",
193 parts.scheme
194 )));
195 }
196
197 let base_url = format!("{}:{}", parts.scheme, parts.path);
200
201 let http_method = parts.params.get("httpMethod").cloned();
202
203 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
204 Some(v) => parse_bool_param_http(v).map_err(|e| {
205 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
206 })?,
207 None => true,
208 };
209
210 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
212 Some(v) => parse_ok_status_code_range(v)?,
213 None => (200, 299),
214 };
215
216 let response_timeout = match parts.params.get("responseTimeout") {
217 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
218 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
219 })?),
220 None => None,
221 };
222
223 let allow_internal = match parts.params.get("allowInternal") {
225 Some(v) => parse_bool_param_http(v).map_err(|e| {
226 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
227 })?,
228 None => false, };
230
231 let blocked_hosts = parts
233 .params
234 .get("blockedHosts")
235 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
236 .unwrap_or_default();
237
238 let max_body_size = match parts.params.get("maxBodySize") {
239 Some(v) => v.parse::<usize>().map_err(|e| {
240 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
241 })?,
242 None => 10 * 1024 * 1024, };
244
245 let read_timeout_ms = match parts.params.get("readTimeout") {
246 Some(v) => v.parse::<u64>().map_err(|e| {
247 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
248 })?,
249 None => 30_000, };
251
252 let max_response_bytes = match parts.params.get("maxResponseBytes") {
253 Some(v) => v.parse::<usize>().map_err(|e| {
254 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
255 })?,
256 None => 10 * 1024 * 1024, };
258
259 let auth = parse_auth_from_params(&parts.params)?;
260
261 let user_agent = parts.params.get("userAgent").cloned();
262
263 if parts.params.contains_key("cookieHandling") {
264 return Err(CamelError::InvalidUri(
265 "cookieHandling is not supported".into(),
266 ));
267 }
268
269 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
270 Some(v) => parse_bool_param_http(v).map_err(|e| {
271 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
272 })?,
273 None => false,
274 };
275
276 let connection_close = match parts.params.get("connectionClose") {
277 Some(v) => parse_bool_param_http(v).map_err(|e| {
278 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
279 })?,
280 None => false,
281 };
282
283 let skip_request_headers = parts
284 .params
285 .get("skipRequestHeaders")
286 .map(|v| {
287 v.split(',')
288 .map(str::trim)
289 .filter(|s| !s.is_empty())
290 .map(|s| s.to_ascii_lowercase())
291 .collect::<Vec<_>>()
292 })
293 .unwrap_or_default();
294
295 let skip_response_headers = parts
296 .params
297 .get("skipResponseHeaders")
298 .map(|v| {
299 v.split(',')
300 .map(str::trim)
301 .filter(|s| !s.is_empty())
302 .map(|s| s.to_ascii_lowercase())
303 .collect::<Vec<_>>()
304 })
305 .unwrap_or_default();
306
307 let follow_redirects = match parts.params.get("followRedirects") {
308 Some(v) => parse_bool_param_http(v).map_err(|e| {
309 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
310 })?,
311 None => false,
312 };
313
314 let max_redirects = match parts.params.get("maxRedirects") {
315 Some(v) => v.parse::<usize>().map_err(|e| {
316 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
317 })?,
318 None => 10,
319 };
320
321 let query_params: HashMap<String, String> = parts
323 .params
324 .into_iter()
325 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
326 .collect();
327
328 Ok(Self {
329 base_url,
330 http_method,
331 throw_exception_on_failure,
332 ok_status_code_range,
333 response_timeout,
334 query_params,
335 allow_internal,
336 blocked_hosts,
337 max_body_size,
338 read_timeout_ms,
339 max_response_bytes,
340 auth,
341 token_provider: None,
342 user_agent,
343 bridge_endpoint,
344 connection_close,
345 skip_request_headers,
346 skip_response_headers,
347 follow_redirects,
348 max_redirects,
349 })
350 }
351}
352
353#[derive(Debug, Clone, UriConfig)]
360#[allow(dead_code)]
361#[uri_scheme = "http"]
362#[uri_config(
363 skip_impl,
364 metadata(
365 scheme = "http",
366 description = "HTTP client and server component",
367 producer,
368 consumer,
369 streaming
370 ),
371 crate = "camel_component_api"
372)]
373struct HttpEndpointUriConfig {
374 #[allow(dead_code)]
375 _base_url: String,
376
377 #[uri_param(
378 name = "httpMethod",
379 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
380 )]
381 http_method: Option<String>,
382
383 #[uri_param(
384 name = "throwExceptionOnFailure",
385 default = "true",
386 desc = "Throw on non-2xx status"
387 )]
388 throw_exception_on_failure: bool,
389
390 #[uri_param(
391 name = "okStatusCodeRange",
392 default = "200-299",
393 desc = "Success status code range"
394 )]
395 ok_status_code_range: String,
396
397 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
398 response_timeout: Option<u64>,
399
400 #[uri_param(
401 name = "allowInternal",
402 default = "false",
403 desc = "Allow private/internal network destinations (SSRF)"
404 )]
405 allow_internal: bool,
406
407 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
408 blocked_hosts: Option<String>,
409
410 #[uri_param(
411 name = "maxBodySize",
412 default = "10485760",
413 desc = "Max request/response body bytes"
414 )]
415 max_body_size: u64,
416
417 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
418 read_timeout: Option<u64>,
419
420 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
421 max_response_bytes: Option<u64>,
422
423 #[uri_param(
424 name = "authMethod",
425 kind = "enum:Basic,Bearer",
426 desc = "Authentication method"
427 )]
428 auth_method: Option<String>,
429
430 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
431 auth_username: Option<String>,
432
433 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
434 auth_password: Option<String>,
435
436 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
437 auth_bearer_token: Option<String>,
438
439 #[uri_param(name = "userAgent", desc = "User-Agent header")]
440 user_agent: Option<String>,
441
442 #[uri_param(
443 name = "bridgeEndpoint",
444 default = "false",
445 desc = "Bridge endpoint mode"
446 )]
447 bridge_endpoint: bool,
448
449 #[uri_param(
450 name = "connectionClose",
451 default = "false",
452 desc = "Send Connection: close"
453 )]
454 connection_close: bool,
455
456 #[uri_param(
457 name = "skipRequestHeaders",
458 desc = "Comma-separated request headers to skip"
459 )]
460 skip_request_headers: Option<String>,
461
462 #[uri_param(
463 name = "skipResponseHeaders",
464 desc = "Comma-separated response headers to skip"
465 )]
466 skip_response_headers: Option<String>,
467
468 #[uri_param(
469 name = "followRedirects",
470 default = "false",
471 desc = "Follow HTTP redirects"
472 )]
473 follow_redirects: bool,
474
475 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
476 max_redirects: u64,
477}
478
479impl HttpEndpointConfig {
480 pub fn metadata() -> ComponentMetadata {
483 HttpEndpointUriConfig::metadata()
484 }
485
486 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
488 HttpEndpointUriConfig::uri_options()
489 }
490}
491
492fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
493 let Some(method) = params.get("authMethod") else {
494 return Ok(HttpAuth::None);
495 };
496
497 if method.eq_ignore_ascii_case("none") {
498 return Ok(HttpAuth::None);
499 }
500
501 if method.eq_ignore_ascii_case("basic") {
502 let username = params.get("authUsername").cloned().ok_or_else(|| {
503 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
504 })?;
505 let password = params.get("authPassword").cloned().ok_or_else(|| {
506 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
507 })?;
508 return Ok(HttpAuth::Basic { username, password });
509 }
510
511 if method.eq_ignore_ascii_case("bearer") {
512 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
513 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
514 })?;
515 return Ok(HttpAuth::Bearer { token });
516 }
517
518 Err(CamelError::InvalidUri(format!(
519 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
520 )))
521}
522
523fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
524 match value.to_ascii_lowercase().as_str() {
525 "true" | "1" | "yes" => Ok(true),
526 "false" | "0" | "no" => Ok(false),
527 _ => Err(CamelError::InvalidUri(format!(
528 "invalid boolean value: '{value}'"
529 ))),
530 }
531}
532
533impl HttpEndpointConfig {
534 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
535 let parts = parse_uri(uri)?;
536 let mut endpoint = Self::from_components(parts.clone())?;
537 if endpoint.response_timeout.is_none() {
538 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
539 }
540 if !parts.params.contains_key("allowInternal") {
541 endpoint.allow_internal = config.allow_internal;
542 }
543 if !parts.params.contains_key("blockedHosts") {
544 endpoint.blocked_hosts = config.blocked_hosts.clone();
545 }
546 if !parts.params.contains_key("maxBodySize") {
547 endpoint.max_body_size = config.max_body_size;
548 }
549 if !parts.params.contains_key("readTimeout") {
550 endpoint.read_timeout_ms = config.read_timeout_ms;
551 }
552 if !parts.params.contains_key("maxResponseBytes") {
553 endpoint.max_response_bytes = config.max_response_bytes;
554 }
555 if !parts.params.contains_key("okStatusCodeRange")
556 && let Some(range) = &config.ok_status_code_range
557 {
558 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
559 }
560 if !parts.params.contains_key("followRedirects") {
561 endpoint.follow_redirects = config.follow_redirects;
562 }
563 if !parts.params.contains_key("maxRedirects") {
564 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
565 }
566
567 Ok(endpoint)
568 }
569}
570
571#[derive(Debug, Clone)]
577pub struct HttpServerConfig {
578 pub scheme: String,
580 pub host: String,
582 pub port: u16,
584 pub path: String,
586 pub max_request_body: usize,
588 pub max_response_body: usize,
590 pub max_inflight_requests: usize,
592 pub method: Option<String>,
599 pub tls_config: Option<crate::config::ServerTlsConfig>,
602}
603
604impl UriConfig for HttpServerConfig {
605 fn scheme() -> &'static str {
607 "http"
608 }
609
610 fn from_uri(uri: &str) -> Result<Self, CamelError> {
611 let parts = parse_uri(uri)?;
612 Self::from_components(parts)
613 }
614
615 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
616 if parts.scheme != "http" && parts.scheme != "https" {
618 return Err(CamelError::InvalidUri(format!(
619 "expected scheme 'http' or 'https', got '{}'",
620 parts.scheme
621 )));
622 }
623
624 let authority_and_path = parts.path.trim_start_matches('/');
627
628 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
630 (&authority_and_path[..idx], &authority_and_path[idx..])
631 } else {
632 (authority_and_path, "/")
633 };
634
635 let path = if path_suffix.is_empty() {
636 "/"
637 } else {
638 path_suffix
639 }
640 .to_string();
641
642 let (host, port) = if let Some(colon) = authority.rfind(':') {
644 let port_str = &authority[colon + 1..];
645 match port_str.parse::<u16>() {
646 Ok(p) => (authority[..colon].to_string(), p),
647 Err(_) => {
648 return Err(CamelError::InvalidUri(format!(
649 "invalid port '{}' in authority",
650 port_str
651 )));
652 }
653 }
654 } else {
655 let default_port = if parts.scheme == "https" { 443 } else { 80 };
657 (authority.to_string(), default_port)
658 };
659
660 let max_request_body = parts
661 .params
662 .get("maxRequestBody")
663 .and_then(|v| v.parse::<usize>().ok())
664 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
667 .params
668 .get("maxResponseBody")
669 .and_then(|v| v.parse::<usize>().ok())
670 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
673 .params
674 .get("maxInflightRequests")
675 .and_then(|v| v.parse::<usize>().ok())
676 .unwrap_or(1024);
677
678 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
684
685 Ok(Self {
686 scheme: parts.scheme,
687 host,
688 port,
689 path,
690 max_request_body,
691 max_response_body,
692 max_inflight_requests,
693 method,
694 tls_config: {
695 let cert = parts.params.get("tlsCert").cloned();
696 let key = parts.params.get("tlsKey").cloned();
697 match (cert, key) {
698 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
699 cert_path: c,
700 key_path: k,
701 }),
702 (None, None) => None,
703 _ => None, }
705 },
706 })
707 }
708}
709
710impl HttpServerConfig {
711 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
712 let parts = parse_uri(uri)?;
713 let mut server = Self::from_components(parts.clone())?;
714 if !parts.params.contains_key("maxRequestBody") {
715 server.max_request_body = config.max_request_body;
716 }
717 if !parts.params.contains_key("maxResponseBody") {
718 server.max_response_body = config.max_body_size;
720 }
721 Ok(server)
722 }
723}
724
725pub enum HttpReplyBody {
733 Bytes(bytes::Bytes),
734 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
735}
736
737pub struct RequestEnvelope {
742 pub method: String,
743 pub path: String,
744 pub query: String,
745 pub headers: http::HeaderMap,
746 pub body: StreamBody,
747 pub path_params: std::collections::HashMap<String, String>,
753 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
754}
755
756pub struct HttpReply {
760 pub status: u16,
761 pub headers: Vec<(String, String)>,
762 pub body: HttpReplyBody,
763}
764
765type ServerKey = (String, u16);
770
771struct ServerHandle {
773 registry: HttpRouteRegistry,
774 max_request_body: usize,
775 max_response_body: usize,
776 max_inflight_requests: usize,
777 is_tls: bool,
778 tls_cert_path: Option<String>,
779 tls_key_path: Option<String>,
780 monitor_task: tokio::task::JoinHandle<()>,
783 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
786 tls_source: Option<ServerTlsSource>,
787}
788
789pub struct ServerRegistry {
791 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
792}
793
794impl ServerRegistry {
795 pub fn global() -> &'static Self {
797 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
798 INSTANCE.get_or_init(|| ServerRegistry {
799 inner: Mutex::new(HashMap::new()),
800 })
801 }
802
803 #[allow(clippy::too_many_arguments)]
806 pub async fn get_or_spawn(
807 &'static self,
808 host: &str,
809 port: u16,
810 max_request_body: usize,
811 max_response_body: usize,
812 max_inflight_requests: usize,
813 runtime: Arc<dyn RuntimeObservability>,
814 route_id: String,
815 tls_config: Option<crate::config::ServerTlsConfig>,
816 ) -> Result<HttpRouteRegistry, CamelError> {
817 let host_owned = host.to_string();
818
819 let cell = {
820 let mut guard = self.inner.lock().map_err(|_| {
821 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
822 })?;
823 let key = (host.to_string(), port);
824 if let Some(existing) = guard.get(&key)
828 && let Some(handle) = existing.get()
829 && handle.monitor_task.is_finished()
830 {
831 if handle.is_tls {
834 let scheme = if handle.is_tls { "https" } else { "http" };
835 camel_component_api::tls_source::TlsReloadRegistry::global()
836 .unregister(scheme, host, port);
837 }
838 guard.remove(&key);
839 }
840 guard
841 .entry(key)
842 .or_insert_with(|| Arc::new(OnceCell::new()))
843 .clone()
844 };
845
846 if let Some(existing) = cell.get()
847 && existing.max_request_body != max_request_body
848 {
849 return Err(CamelError::EndpointCreationFailed(format!(
850 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
851 existing.max_request_body, max_request_body
852 )));
853 }
854
855 if let Some(existing) = cell.get()
856 && existing.max_response_body != max_response_body
857 {
858 return Err(CamelError::EndpointCreationFailed(format!(
859 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
860 existing.max_response_body, max_response_body
861 )));
862 }
863
864 if let Some(existing) = cell.get()
865 && existing.max_inflight_requests != max_inflight_requests
866 {
867 return Err(CamelError::EndpointCreationFailed(format!(
868 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
869 existing.max_inflight_requests, max_inflight_requests
870 )));
871 }
872
873 if let Some(existing) = cell.get()
875 && existing.is_tls != tls_config.is_some()
876 {
877 return Err(CamelError::EndpointCreationFailed(format!(
878 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
879 existing.is_tls,
880 tls_config.is_some()
881 )));
882 }
883
884 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
886 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
887 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
888 {
889 return Err(CamelError::EndpointCreationFailed(format!(
890 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
891 )));
892 }
893
894 let handle = cell
895 .get_or_try_init(|| {
896 let rt = Arc::clone(&runtime);
897 let rid = route_id.clone();
898 async move {
899 let addr = format!("{host_owned}:{port}");
900 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
901 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
902 })?;
903 let registry = HttpRouteRegistry::new();
904 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
905 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
908 let tls_source: Option<ServerTlsSource>;
909 let server_task = if let Some(ref tls) = tls_config {
910 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
911 let source = ServerTlsSource {
912 cert_path: std::path::PathBuf::from(&tls.cert_path),
913 key_path: std::path::PathBuf::from(&tls.key_path),
914 client_ca_path: None,
915 };
916 let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
920 std::sync::Arc::new(rustls_config),
921 );
922 tls_rustls_cfg = Some(rustls_cfg.clone());
923 tls_source = Some(source);
924 let std_listener = listener.into_std().map_err(|e| {
926 CamelError::EndpointCreationFailed(format!(
927 "TLS listener conversion: {e}"
928 ))
929 })?;
930 tokio::spawn(run_axum_server_tls(
931 std_listener,
932 rustls_cfg,
933 registry.clone(),
934 max_request_body,
935 max_response_body,
936 Arc::clone(&inflight),
937 Arc::clone(&rt),
938 rid.clone(),
939 ))
940 } else {
941 tls_rustls_cfg = None;
942 tls_source = None;
943 tokio::spawn(run_axum_server(
944 listener,
945 registry.clone(),
946 max_request_body,
947 max_response_body,
948 Arc::clone(&inflight),
949 Arc::clone(&rt),
950 rid.clone(),
951 ))
952 };
953 let addr_for_monitor = format!("{host_owned}:{port}");
954 let monitor_task = tokio::spawn(monitor_axum_task(
955 server_task,
956 addr_for_monitor,
957 Arc::clone(&rt),
958 rid,
959 ));
960 let handle = ServerHandle {
961 registry,
962 max_request_body,
963 max_response_body,
964 max_inflight_requests,
965 is_tls: tls_config.is_some(),
966 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
967 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
968 monitor_task,
969 tls_config: tls_rustls_cfg,
970 tls_source,
971 };
972 if let (Some(tls_cfg), Some(source)) =
977 (handle.tls_config.as_ref(), handle.tls_source.as_ref())
978 {
979 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
980 tls_cfg.clone(),
981 source.clone(),
982 host_owned.clone(),
983 port,
984 ));
985 camel_component_api::tls_source::TlsReloadRegistry::global()
986 .register(handler);
987 }
988 Ok::<ServerHandle, CamelError>(handle)
989 }
990 })
991 .await?;
992
993 Ok(handle.registry.clone())
994 }
995
996 pub async fn unregister(&self, host: &str, port: u16) {
1000 debug!(
1001 host = host,
1002 port = port,
1003 "consumer unregistered from HTTP server"
1004 );
1005 }
1006
1007 #[cfg(test)]
1014 pub fn reset() {
1015 let instance = Self::global();
1016 let mut guard = instance
1017 .inner
1018 .lock()
1019 .expect("ServerRegistry lock poisoned during test reset");
1020 guard.clear();
1021 }
1022}
1023
1024use axum::{
1029 Router,
1030 body::Body as AxumBody,
1031 extract::{Request, State},
1032 http::{Response, StatusCode},
1033 response::IntoResponse,
1034};
1035
1036#[derive(Clone)]
1037pub(crate) struct AppState {
1038 registry: HttpRouteRegistry,
1039 max_request_body: usize,
1040 max_response_body: usize,
1041 inflight: Arc<tokio::sync::Semaphore>,
1042}
1043
1044async fn run_axum_server(
1045 listener: tokio::net::TcpListener,
1046 registry: HttpRouteRegistry,
1047 max_request_body: usize,
1048 max_response_body: usize,
1049 inflight: Arc<tokio::sync::Semaphore>,
1050 runtime: Arc<dyn RuntimeObservability>,
1051 route_id: String,
1052) {
1053 let state = AppState {
1054 registry,
1055 max_request_body,
1056 max_response_body,
1057 inflight,
1058 };
1059 let app = Router::new().fallback(dispatch_handler).with_state(state);
1060
1061 axum::serve(listener, app).await.unwrap_or_else(|e| {
1062 runtime
1063 .metrics()
1064 .increment_errors(&route_id, "e:http:accept");
1065 tracing::error!(error = %e, "Axum server error");
1067 });
1068}
1069
1070#[allow(clippy::too_many_arguments)]
1071async fn run_axum_server_tls(
1072 listener: std::net::TcpListener,
1073 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1074 registry: HttpRouteRegistry,
1075 max_request_body: usize,
1076 max_response_body: usize,
1077 inflight: Arc<tokio::sync::Semaphore>,
1078 runtime: Arc<dyn RuntimeObservability>,
1079 route_id: String,
1080) {
1081 let state = AppState {
1082 registry,
1083 max_request_body,
1084 max_response_body,
1085 inflight,
1086 };
1087 let app = Router::new().fallback(dispatch_handler).with_state(state);
1088
1089 axum_server::from_tcp_rustls(listener, tls_cfg)
1093 .serve(app.into_make_service())
1094 .await
1095 .unwrap_or_else(|e| {
1096 runtime
1097 .metrics()
1098 .increment_errors(&route_id, "e:http:accept-tls");
1099 tracing::error!(error = %e, "Axum TLS server error");
1101 });
1102}
1103
1104async fn monitor_axum_task(
1112 handle: tokio::task::JoinHandle<()>,
1113 addr: String,
1114 runtime: Arc<dyn RuntimeObservability>,
1115 route_id: String,
1116) {
1117 match handle.await {
1118 Ok(()) => {
1119 }
1121 Err(join_err) => {
1122 runtime
1123 .metrics()
1124 .increment_errors(&route_id, "e:http:server-task-exited");
1125 tracing::error!(
1127 addr = %addr,
1128 error = %join_err,
1129 "Axum server task exited unexpectedly — all routes on this port are now dead"
1130 );
1131 }
1132 }
1133}
1134
1135fn load_tls_config(
1138 cert_path: &str,
1139 key_path: &str,
1140) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1141 use std::fs::File;
1142 use std::io::BufReader;
1143
1144 let cert_file = File::open(cert_path)
1145 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1146 let key_file = File::open(key_path)
1147 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1148
1149 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1150 .collect::<Result<Vec<_>, _>>()
1151 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1152
1153 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1154 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1155 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1156
1157 tokio_rustls::rustls::ServerConfig::builder()
1158 .with_no_client_auth()
1159 .with_single_cert(certs, key)
1160 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1161}
1162
1163async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1164 let path = req.uri().path().to_owned();
1165 let method = req.method().to_string();
1166
1167 let api_sender = {
1184 let inner = state.registry.inner.read().await;
1185 inner.api_routes.get(&path).cloned()
1186 }; let (rest_sender, path_params) = if api_sender.is_some() {
1189 (None, Default::default())
1191 } else {
1192 let inner = state.registry.inner.read().await;
1193 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1194 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1195 rest_match::MatchOutcome::Ambiguous => {
1196 tracing::warn!(
1202 method = %method,
1203 path = %path,
1204 "ambiguous REST template match — returning 500"
1205 );
1206 return Response::builder()
1207 .status(StatusCode::INTERNAL_SERVER_ERROR)
1208 .body(AxumBody::from("Internal Server Error"))
1209 .expect("infallible"); }
1211 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1212 }
1213 }; let sender = api_sender.or(rest_sender);
1216
1217 if let Some(sender) = sender {
1218 let query = req.uri().query().unwrap_or("").to_string();
1219 let headers = req.headers().clone();
1220
1221 let content_length: Option<u64> = headers
1223 .get(http::header::CONTENT_LENGTH)
1224 .and_then(|v| v.to_str().ok())
1225 .and_then(|s| s.parse().ok());
1226
1227 if let Some(len) = content_length
1228 && len > state.max_request_body as u64
1229 {
1230 return Response::builder()
1231 .status(StatusCode::PAYLOAD_TOO_LARGE)
1232 .body(AxumBody::from("Request body exceeds configured limit"))
1233 .expect("infallible"); }
1235
1236 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1237 Ok(permit) => permit,
1238 Err(_) => {
1239 return Response::builder()
1240 .status(StatusCode::SERVICE_UNAVAILABLE)
1241 .body(AxumBody::from("Service Unavailable"))
1242 .expect("infallible"); }
1244 };
1245
1246 let content_type = headers
1248 .get(http::header::CONTENT_TYPE)
1249 .and_then(|v| v.to_str().ok())
1250 .map(|s| s.to_string());
1251
1252 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1253 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1254 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1255
1256 let stream_body = StreamBody {
1257 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1258 metadata: StreamMetadata {
1259 size_hint: content_length,
1260 content_type,
1261 origin: None,
1262 },
1263 };
1264
1265 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1266 let envelope = RequestEnvelope {
1267 method,
1268 path,
1269 query,
1270 headers,
1271 body: stream_body,
1272 path_params,
1273 reply_tx,
1274 };
1275
1276 if sender.send(envelope).await.is_err() {
1277 return Response::builder()
1278 .status(StatusCode::SERVICE_UNAVAILABLE)
1279 .body(AxumBody::from("Consumer unavailable"))
1280 .expect("infallible"); }
1282
1283 match reply_rx.await {
1284 Ok(reply) => {
1285 let reply = match reply.body {
1286 HttpReplyBody::Bytes(b)
1287 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1288 {
1289 HttpReply {
1290 status: 500,
1291 headers: vec![],
1292 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1293 "Response body exceeds configured limit",
1294 )),
1295 }
1296 }
1297 _ => reply,
1298 };
1299
1300 let status =
1301 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1302 let mut builder = Response::builder().status(status);
1303 for (k, v) in &reply.headers {
1304 builder = builder.header(k.as_str(), v.as_str());
1305 }
1306 match reply.body {
1307 HttpReplyBody::Bytes(b) => {
1308 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1309 Response::builder()
1310 .status(StatusCode::INTERNAL_SERVER_ERROR)
1311 .body(AxumBody::from("Invalid response headers from consumer"))
1312 .expect("infallible") })
1314 }
1315 HttpReplyBody::Stream(stream) => builder
1316 .body(AxumBody::from_stream(stream))
1317 .unwrap_or_else(|_| {
1318 Response::builder()
1319 .status(StatusCode::INTERNAL_SERVER_ERROR)
1320 .body(AxumBody::from("Invalid response headers from consumer"))
1321 .expect("infallible") }),
1323 }
1324 }
1325 Err(_) => Response::builder()
1326 .status(StatusCode::INTERNAL_SERVER_ERROR)
1327 .body(AxumBody::from("Pipeline error"))
1328 .expect("infallible"), }
1330 } else {
1331 static_dispatch::dispatch_static(&state, req, &path).await
1333 }
1334}
1335
1336fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1337 len > max
1338}
1339
1340fn title_case_header(name: &str) -> String {
1341 name.split('-')
1342 .map(|part| {
1343 let mut chars = part.chars();
1344 match chars.next() {
1345 None => String::new(),
1346 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1347 }
1348 })
1349 .collect::<Vec<_>>()
1350 .join("-")
1351}
1352
1353pub struct HttpConsumer {
1358 config: HttpServerConfig,
1359 runtime: Arc<dyn RuntimeObservability>,
1361}
1362
1363impl HttpConsumer {
1364 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1365 Self { config, runtime }
1366 }
1367}
1368
1369#[async_trait::async_trait]
1370impl Consumer for HttpConsumer {
1371 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1372 use camel_component_api::{Body, Exchange, Message};
1373
1374 let registry = ServerRegistry::global()
1375 .get_or_spawn(
1376 &self.config.host,
1377 self.config.port,
1378 self.config.max_request_body,
1379 self.config.max_response_body,
1380 self.config.max_inflight_requests,
1381 self.runtime.clone(),
1382 ctx.route_id().to_string(),
1383 self.config.tls_config.clone(),
1384 )
1385 .await?;
1386
1387 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
1389 if let Some(method) = self.config.method.clone() {
1396 let segments = rest_match::parse_path_template(&self.config.path);
1397 registry
1398 .register_rest_endpoint(method, segments, env_tx)
1399 .await;
1400 } else {
1401 registry
1402 .register_api_route(self.config.path.clone(), env_tx)
1403 .await;
1404 }
1405
1406 ctx.mark_ready();
1415
1416 let path = self.config.path.clone();
1417 let registry_for_cleanup = registry.clone();
1418 let cancel_token = ctx.cancel_token();
1419 loop {
1420 tokio::select! {
1421 _ = ctx.cancelled() => {
1422 break;
1423 }
1424 envelope = env_rx.recv() => {
1425 let Some(envelope) = envelope else { break; };
1426
1427 let mut msg = Message::default();
1429
1430 msg.set_header("CamelHttpMethod",
1432 serde_json::Value::String(envelope.method.clone()));
1433 msg.set_header("CamelHttpPath",
1434 serde_json::Value::String(envelope.path.clone()));
1435 msg.set_header("CamelHttpQuery",
1436 serde_json::Value::String(envelope.query.clone()));
1437
1438 for (param_name, param_value) in &envelope.path_params {
1445 msg.set_header(
1446 format!("CamelHttpPath_{param_name}"),
1447 serde_json::Value::String(param_value.clone()),
1448 );
1449 }
1450
1451 for (k, v) in &envelope.headers {
1453 if let Ok(val_str) = v.to_str() {
1454 msg.set_header(
1455 title_case_header(k.as_str()),
1456 serde_json::Value::String(val_str.to_string()),
1457 );
1458 }
1459 }
1460
1461 msg.body = Body::Stream(envelope.body);
1464
1465 #[allow(unused_mut)]
1466 let mut exchange = Exchange::new(msg);
1467
1468 #[cfg(feature = "otel")]
1470 {
1471 let headers: HashMap<String, String> = envelope
1472 .headers
1473 .iter()
1474 .filter_map(|(k, v)| {
1475 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1476 })
1477 .collect();
1478 camel_otel::extract_into_exchange(&mut exchange, &headers);
1479 }
1480
1481 let reply_tx = envelope.reply_tx;
1482 let sender = ctx.sender().clone();
1483 let path_clone = path.clone();
1484 let cancel = cancel_token.clone();
1485
1486 tokio::spawn(async move {
1506 if cancel.is_cancelled() {
1514 let _ = reply_tx.send(HttpReply {
1515 status: 503,
1516 headers: vec![],
1517 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1518 });
1519 return;
1520 }
1521
1522 let (tx, rx) = tokio::sync::oneshot::channel();
1524 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1525 exchange,
1526 reply_tx: Some(tx),
1527 };
1528
1529 let result = match sender.send(envelope).await {
1530 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1531 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1532 }
1533 .and_then(|r| r);
1534
1535 let reply = match result {
1536 Ok(out) => {
1537 let status = out
1538 .input
1539 .header("CamelHttpResponseCode")
1540 .and_then(|v| {
1541 let raw = v.as_u64()
1542 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1543 let code = raw as u16;
1544 (100..1000).contains(&code).then_some(code)
1545 })
1546 .unwrap_or(200);
1547
1548 let user_content_type = out
1549 .input
1550 .header("Content-Type")
1551 .and_then(|v| v.as_str().map(|s| s.to_string()));
1552
1553 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1554 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1555 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1556 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1557 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1558 v.to_string().into_bytes(),
1559 )), Some("application/json".to_string())),
1560 Body::Stream(s) => {
1561 let ct = s.metadata.content_type.clone();
1562 match s.stream.lock().await.take() {
1563 Some(stream) => (
1564 HttpReplyBody::Stream(stream),
1565 ct,
1566 ),
1567 None => {
1568 tracing::error!(
1570 "Body::Stream already consumed before HTTP reply — returning 500"
1571 );
1572 let error_reply = HttpReply {
1573 status: 500,
1574 headers: vec![],
1575 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1576 };
1577 if reply_tx.send(error_reply).is_err() {
1578 debug!("reply_tx dropped before error reply could be sent");
1579 }
1580 return;
1581 }
1582 }
1583 }
1584 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1586 };
1587
1588 let resp_headers = select_response_headers(
1589 &out.input.headers,
1590 user_content_type,
1591 inferred_content_type,
1592 );
1593
1594 HttpReply {
1595 status,
1596 headers: resp_headers,
1597 body: reply_body,
1598 }
1599 }
1600 Err(e) => {
1601 pipeline_error_to_reply(e, &path_clone)
1602 }
1603 };
1604
1605 let _ = reply_tx.send(reply);
1607 });
1608 }
1609 }
1610 }
1611
1612 if let Some(method) = &self.config.method {
1617 registry_for_cleanup
1618 .unregister_rest_endpoint(method, &path)
1619 .await;
1620 } else {
1621 registry_for_cleanup.unregister_api_route(&path).await;
1622 }
1623
1624 ServerRegistry::global()
1628 .unregister(&self.config.host, self.config.port)
1629 .await;
1630
1631 Ok(())
1632 }
1633
1634 async fn stop(&mut self) -> Result<(), CamelError> {
1635 Ok(())
1636 }
1637
1638 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1639 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1640 }
1641
1642 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1649 camel_component_api::ConsumerStartupMode::Explicit
1650 }
1651}
1652
1653pub struct HttpComponent {
1658 config: HttpConfig,
1659}
1660
1661pub(crate) fn build_client(
1662 config: &HttpConfig,
1663 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1664) -> reqwest::Client {
1665 let mut builder = reqwest::Client::builder()
1666 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1668 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1669 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1670
1671 builder = builder.redirect(reqwest::redirect::Policy::none());
1675
1676 if let Some((host, addrs)) = resolve_override {
1677 builder = builder.resolve_to_addrs(host, addrs);
1678 }
1679
1680 if let Some(tls) = &config.tls
1681 && tls.enabled
1682 {
1683 if tls.insecure || !tls.verify_peer {
1684 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1686 builder = builder.danger_accept_invalid_certs(true);
1687 }
1688
1689 if let Some(ca_path) = &tls.ca_cert_path
1690 && let Ok(ca_bytes) = std::fs::read(ca_path)
1691 {
1692 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1693 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1694 if let Ok(ca_cert) = cert {
1695 builder = builder.add_root_certificate(ca_cert);
1696 }
1697 }
1698
1699 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1700 && let (Ok(cert_bytes), Ok(key_bytes)) =
1701 (std::fs::read(cert_path), std::fs::read(key_path))
1702 {
1703 let mut identity_pem = cert_bytes;
1704 identity_pem.extend_from_slice(&key_bytes);
1705 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1706 builder = builder.identity(identity);
1707 }
1708 }
1709 }
1710
1711 builder
1712 .build()
1713 .expect("reqwest::Client::build() with valid config should not fail") }
1715
1716impl HttpComponent {
1717 pub fn new() -> Self {
1718 let config = HttpConfig::default();
1719 Self { config }
1720 }
1721
1722 pub fn with_config(config: HttpConfig) -> Self {
1723 Self { config }
1724 }
1725
1726 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1727 match config {
1728 Some(cfg) => Self::with_config(cfg),
1729 None => Self::new(),
1730 }
1731 }
1732}
1733
1734impl Default for HttpComponent {
1735 fn default() -> Self {
1736 Self::new()
1737 }
1738}
1739
1740impl Component for HttpComponent {
1741 fn scheme(&self) -> &str {
1742 "http"
1743 }
1744
1745 fn metadata(&self) -> ComponentMetadata {
1746 HttpEndpointConfig::metadata()
1747 }
1748
1749 fn create_endpoint(
1750 &self,
1751 uri: &str,
1752 ctx: &dyn camel_component_api::ComponentContext,
1753 ) -> Result<Box<dyn Endpoint>, CamelError> {
1754 self.config.validate()?;
1755 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1756 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1757 let client = build_client(&self.config, None);
1758 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1759 server_config.host.clone(),
1760 server_config.port,
1761 )));
1762 Ok(Box::new(HttpEndpoint {
1763 uri: uri.to_string(),
1764 config,
1765 server_config,
1766 client,
1767 http_config: self.config.clone(),
1768 }))
1769 }
1770}
1771
1772pub struct HttpsComponent {
1773 config: HttpConfig,
1774}
1775
1776impl HttpsComponent {
1777 pub fn new() -> Self {
1778 let config = HttpConfig::default();
1779 Self { config }
1780 }
1781
1782 pub fn with_config(config: HttpConfig) -> Self {
1783 Self { config }
1784 }
1785
1786 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1787 match config {
1788 Some(cfg) => Self::with_config(cfg),
1789 None => Self::new(),
1790 }
1791 }
1792}
1793
1794impl Default for HttpsComponent {
1795 fn default() -> Self {
1796 Self::new()
1797 }
1798}
1799
1800impl Component for HttpsComponent {
1801 fn scheme(&self) -> &str {
1802 "https"
1803 }
1804
1805 fn metadata(&self) -> ComponentMetadata {
1806 let mut meta = HttpEndpointConfig::metadata();
1809 meta.scheme = "https".to_string();
1810 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
1811 meta
1812 }
1813
1814 fn create_endpoint(
1815 &self,
1816 uri: &str,
1817 ctx: &dyn camel_component_api::ComponentContext,
1818 ) -> Result<Box<dyn Endpoint>, CamelError> {
1819 self.config.validate()?;
1820 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1821 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1822 let client = build_client(&self.config, None);
1823 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1824 server_config.host.clone(),
1825 server_config.port,
1826 )));
1827 Ok(Box::new(HttpEndpoint {
1828 uri: uri.to_string(),
1829 config,
1830 server_config,
1831 client,
1832 http_config: self.config.clone(),
1833 }))
1834 }
1835}
1836
1837struct HttpEndpoint {
1842 uri: String,
1843 config: HttpEndpointConfig,
1844 server_config: HttpServerConfig,
1845 client: reqwest::Client,
1846 http_config: HttpConfig,
1847}
1848
1849impl Endpoint for HttpEndpoint {
1850 fn uri(&self) -> &str {
1851 &self.uri
1852 }
1853
1854 fn create_consumer(
1855 &self,
1856 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1857 ) -> Result<Box<dyn Consumer>, CamelError> {
1858 let scheme_is_https = self.server_config.scheme == "https";
1861 let has_tls = self.server_config.tls_config.is_some();
1862
1863 if scheme_is_https && !has_tls {
1864 return Err(CamelError::EndpointCreationFailed(
1865 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1866 ));
1867 }
1868 if !scheme_is_https && has_tls {
1869 return Err(CamelError::EndpointCreationFailed(
1870 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1871 ));
1872 }
1873 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1874 }
1875
1876 fn create_producer(
1877 &self,
1878 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1879 _ctx: &ProducerContext,
1880 ) -> Result<BoxProcessor, CamelError> {
1881 let producer = HttpProducer {
1882 config: Arc::new(self.config.clone()),
1883 client: self.client.clone(),
1884 http_config: Arc::new(self.http_config.clone()),
1885 };
1886 if let Some(ref provider) = self.config.token_provider {
1887 let layer = BearerTokenLayer::new(Arc::clone(provider));
1888 Ok(BoxProcessor::new(layer.layer(producer)))
1889 } else {
1890 Ok(BoxProcessor::new(producer))
1891 }
1892 }
1893}
1894
1895#[derive(Clone)]
1900struct HttpProducer {
1901 config: Arc<HttpEndpointConfig>,
1902 client: reqwest::Client,
1903 http_config: Arc<HttpConfig>,
1904}
1905
1906impl HttpProducer {
1907 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1908 if let Some(ref method) = config.http_method {
1909 return method.to_uppercase();
1910 }
1911 if let Some(method) = exchange
1912 .input
1913 .header("CamelHttpMethod")
1914 .and_then(|v| v.as_str())
1915 {
1916 return method.to_uppercase();
1917 }
1918 if !exchange.input.body.is_empty() {
1919 return "POST".to_string();
1920 }
1921 "GET".to_string()
1922 }
1923
1924 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1925 if config.bridge_endpoint {
1931 let url = config.base_url.clone();
1932 if config.query_params.is_empty() {
1933 return url;
1934 }
1935 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
1937 parsed.query_pairs_mut().append_pair(k, v);
1938 }
1939 return parsed.to_string();
1940 }
1941
1942 if let Some(uri) = exchange
1943 .input
1944 .header("CamelHttpUri")
1945 .and_then(|v| v.as_str())
1946 {
1947 let mut url = uri.to_string();
1948 if let Some(path) = exchange
1949 .input
1950 .header("CamelHttpPath")
1951 .and_then(|v| v.as_str())
1952 {
1953 if !url.ends_with('/') && !path.starts_with('/') {
1954 url.push('/');
1955 }
1956 url.push_str(path);
1957 }
1958 if let Some(query) = exchange
1959 .input
1960 .header("CamelHttpQuery")
1961 .and_then(|v| v.as_str())
1962 {
1963 url.push('?');
1964 url.push_str(query);
1965 }
1966 return url;
1967 }
1968
1969 let mut url = config.base_url.clone();
1970
1971 if let Some(path) = exchange
1972 .input
1973 .header("CamelHttpPath")
1974 .and_then(|v| v.as_str())
1975 {
1976 if !url.ends_with('/') && !path.starts_with('/') {
1977 url.push('/');
1978 }
1979 url.push_str(path);
1980 }
1981
1982 if let Some(query) = exchange
1983 .input
1984 .header("CamelHttpQuery")
1985 .and_then(|v| v.as_str())
1986 {
1987 url.push('?');
1988 url.push_str(query);
1989 } else if !config.query_params.is_empty() {
1990 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
1992 parsed.query_pairs_mut().append_pair(k, v);
1993 }
1994 url = parsed.to_string();
1995 }
1996
1997 url
1998 }
1999
2000 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2001 status >= range.0 && status <= range.1
2002 }
2003
2004 fn is_entity_enclosing(method: &str) -> bool {
2009 matches!(method, "POST" | "PUT" | "PATCH")
2010 }
2011}
2012
2013impl Service<Exchange> for HttpProducer {
2014 type Response = Exchange;
2015 type Error = CamelError;
2016 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2017
2018 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2019 Poll::Ready(Ok(()))
2020 }
2021
2022 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2023 let config = self.config.clone();
2024 let client = self.client.clone();
2025 let http_config = self.http_config.clone();
2026
2027 Box::pin(async move {
2028 let method_str = HttpProducer::resolve_method(&exchange, &config);
2029 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2034 let url = HttpProducer::resolve_url(&exchange, &config);
2035
2036 ssrf::validate_url_for_ssrf(&url, &config)?;
2038
2039 let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2044 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2045 build_client(&http_config, Some((host.as_str(), addrs)))
2046 } else {
2047 client
2048 };
2049
2050 debug!(
2051 correlation_id = %exchange.correlation_id(),
2052 method = %method_str,
2053 url = %url,
2054 "HTTP request"
2055 );
2056
2057 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2058 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2059 })?;
2060
2061 let mut collected_headers: Vec<(
2063 reqwest::header::HeaderName,
2064 reqwest::header::HeaderValue,
2065 )> = Vec::new();
2066
2067 if let Some(user_agent) = &config.user_agent
2068 && !config.bridge_endpoint
2069 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2070 {
2071 collected_headers.push((reqwest::header::USER_AGENT, val));
2072 }
2073
2074 #[cfg(feature = "otel")]
2076 let should_inject_otel = !config.bridge_endpoint;
2077 #[cfg(feature = "otel")]
2078 if should_inject_otel {
2079 let mut otel_headers = HashMap::new();
2080 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2081 for (k, v) in otel_headers {
2082 if let (Ok(name), Ok(val)) = (
2083 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2084 reqwest::header::HeaderValue::from_str(&v),
2085 ) {
2086 collected_headers.push((name, val));
2087 }
2088 }
2089 }
2090
2091 let conn_tokens = header_policy::connection_tokens(
2092 exchange
2093 .input
2094 .headers
2095 .iter()
2096 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2097 .filter_map(|(_, v)| v.as_str()),
2098 );
2099
2100 for (key, value) in &exchange.input.headers {
2101 if !key.starts_with("Camel")
2102 && !config
2103 .skip_request_headers
2104 .iter()
2105 .any(|h| h.eq_ignore_ascii_case(key))
2106 && !header_policy::excluded_outbound(key, &conn_tokens)
2107 && let Some(val_str) = value.as_str()
2108 && let (Ok(name), Ok(val)) = (
2109 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2110 reqwest::header::HeaderValue::from_str(val_str),
2111 )
2112 {
2113 collected_headers.push((name, val));
2114 }
2115 }
2116
2117 if !config.bridge_endpoint {
2119 match &config.auth {
2120 HttpAuth::None => {}
2121 HttpAuth::Basic { username, password } => {
2122 use base64::Engine;
2123 let credentials = format!("{username}:{password}");
2125 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2126 if let Ok(val) =
2127 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2128 {
2129 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2130 }
2131 }
2132 HttpAuth::Bearer { token } => {
2133 let bearer = format!("Bearer {token}");
2135 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2136 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2137 }
2138 }
2139 }
2140
2141 if config.connection_close
2142 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2143 {
2144 collected_headers.push((reqwest::header::CONNECTION, val));
2145 }
2146 }
2147
2148 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2150 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2151 if suppress_body {
2152 std::mem::take(&mut exchange.input.body);
2160 tracing::warn!(
2162 correlation_id = %exchange.correlation_id(),
2163 method = %method_str,
2164 "dropping request body for non-entity-enclosing HTTP method"
2165 );
2166 }
2167 None } else {
2169 let body = std::mem::take(&mut exchange.input.body);
2170 let bytes = body.into_bytes(config.max_body_size).await?;
2171 if bytes.is_empty() {
2172 None
2174 } else if suppress_body {
2175 tracing::warn!(
2177 correlation_id = %exchange.correlation_id(),
2178 method = %method_str,
2179 "dropping request body for non-entity-enclosing HTTP method"
2180 );
2181 None
2182 } else {
2183 Some(bytes.to_vec())
2184 }
2185 };
2186
2187 let response = if config.follow_redirects && !is_stream_body {
2188 ssrf::send_with_ssrf_safe_redirects(
2190 &client,
2191 &http_config,
2192 &config,
2193 method,
2194 &url,
2195 collected_headers,
2196 materialized_body,
2197 config.max_redirects,
2198 config.response_timeout,
2199 )
2200 .await?
2201 } else {
2202 let mut request = client.request(method, &url);
2204
2205 if let Some(timeout) = config.response_timeout {
2206 request = request.timeout(timeout);
2207 }
2208
2209 for (name, value) in &collected_headers {
2210 request = request.header(name, value);
2211 }
2212
2213 if is_stream_body {
2214 if let Body::Stream(ref s) = exchange.input.body {
2215 let mut stream_lock = s.stream.lock().await;
2216 if let Some(stream) = stream_lock.take() {
2217 request = request.body(reqwest::Body::wrap_stream(stream));
2218 } else {
2219 return Err(CamelError::AlreadyConsumed);
2220 }
2221 }
2222 } else if let Some(ref body_bytes) = materialized_body {
2223 request = request.body(body_bytes.clone());
2224 }
2225
2226 request
2227 .send()
2228 .await
2229 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2230 };
2231
2232 let status_code = response.status().as_u16();
2233 let status_text = response
2234 .status()
2235 .canonical_reason()
2236 .unwrap_or("Unknown")
2237 .to_string();
2238
2239 for (key, value) in response.headers() {
2240 if config
2241 .skip_response_headers
2242 .iter()
2243 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2244 {
2245 continue;
2246 }
2247 if let Ok(val_str) = value.to_str() {
2248 exchange.input.set_header(
2249 title_case_header(key.as_str()),
2250 serde_json::Value::String(val_str.to_string()),
2251 );
2252 }
2253 }
2254
2255 exchange.input.set_header(
2256 "CamelHttpResponseCode",
2257 serde_json::Value::Number(status_code.into()),
2258 );
2259 exchange.input.set_header(
2260 "CamelHttpResponseText",
2261 serde_json::Value::String(status_text.clone()),
2262 );
2263
2264 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2266 let response_body = tokio::time::timeout(read_timeout, async {
2267 if let Some(content_len) = response.content_length()
2269 && content_len > config.max_response_bytes as u64
2270 {
2271 return Err(CamelError::ProcessorError(format!(
2272 "Response body too large: {} bytes exceeds limit of {} bytes",
2273 content_len, config.max_response_bytes
2274 )));
2275 }
2276 use futures::TryStreamExt;
2278 let mut stream = response.bytes_stream();
2279 let mut total: usize = 0;
2280 let mut collected = Vec::new();
2281 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2282 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2283 })? {
2284 total += chunk.len();
2285 if total > config.max_response_bytes {
2286 return Err(CamelError::ProcessorError(format!(
2287 "Response body too large: {} bytes exceeds limit of {} bytes",
2288 total, config.max_response_bytes
2289 )));
2290 }
2291 collected.push(chunk);
2292 }
2293 let mut result = bytes::BytesMut::with_capacity(total);
2294 for chunk in collected {
2295 result.extend_from_slice(&chunk);
2296 }
2297 Ok::<bytes::Bytes, CamelError>(result.freeze())
2298 })
2299 .await
2300 .map_err(|_| {
2301 CamelError::ProcessorError(format!(
2302 "Read timeout after {}ms",
2303 config.read_timeout_ms
2304 ))
2305 })??;
2306
2307 if config.throw_exception_on_failure
2308 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2309 {
2310 return Err(CamelError::HttpOperationFailed {
2311 method: method_str,
2312 url,
2313 status_code,
2314 status_text,
2315 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2316 });
2317 }
2318
2319 if !response_body.is_empty() {
2320 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2321 }
2322
2323 debug!(
2324 correlation_id = %exchange.correlation_id(),
2325 status = status_code,
2326 url = %url,
2327 "HTTP response"
2328 );
2329 Ok(exchange)
2330 })
2331 }
2332}
2333
2334#[cfg(test)]
2344pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2345
2346fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2354 match e {
2355 CamelError::Unauthenticated(msg) => {
2356 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2357 HttpReply {
2358 status: 401,
2359 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2360 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2361 }
2362 }
2363 CamelError::Unauthorized(msg) => {
2364 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2365 HttpReply {
2366 status: 403,
2367 headers: vec![],
2368 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2369 }
2370 }
2371 CamelError::TypeConversionFailed(msg) => {
2372 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2373 let body = serde_json::to_string(&serde_json::json!({
2374 "error": "bad_request",
2375 "message": msg,
2376 }))
2377 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2379 status: 400,
2380 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2381 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2382 }
2383 }
2384 CamelError::ValidationError(msg) => {
2385 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2386 let body = serde_json::to_string(&serde_json::json!({
2387 "error": "validation_error",
2388 "message": msg,
2389 }))
2390 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2392 status: 400,
2393 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2394 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2395 }
2396 }
2397 CamelError::ConsumerStopping => {
2398 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2399 HttpReply {
2400 status: 503,
2401 headers: vec![],
2402 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2403 }
2404 }
2405 e => {
2406 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2408 HttpReply {
2409 status: 500,
2410 headers: vec![],
2411 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2412 }
2413 }
2414 }
2415}
2416
2417fn select_response_headers(
2427 headers: &HashMap<String, serde_json::Value>,
2428 user_content_type: Option<String>,
2429 inferred_content_type: Option<String>,
2430) -> Vec<(String, String)> {
2431 let conn_tokens = header_policy::connection_tokens(
2432 headers
2433 .iter()
2434 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2435 .filter_map(|(_, v)| v.as_str()),
2436 );
2437 let mut selected: Vec<(String, String)> = headers
2438 .iter()
2439 .filter(|(k, _)| !k.starts_with("Camel"))
2440 .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2441 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2442 .collect();
2443 if let Some(ct) = user_content_type.or(inferred_content_type) {
2444 selected.push(("Content-Type".to_string(), ct));
2445 }
2446 selected
2447}
2448
2449#[cfg(test)]
2450mod tests {
2451 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2452 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2453 std::sync::Arc::new(PanicRuntimeObservability)
2454 }
2455 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2456 std::sync::Arc::new(PanicRuntimeObservability)
2457 }
2458 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2459 std::sync::Arc::new(NoopRuntimeObservability)
2460 }
2461
2462 use super::*;
2463 use crate::rest_match::PathSegment;
2464 use camel_component_api::{Message, NoOpComponentContext};
2465 use std::sync::Arc;
2466 use std::time::Duration;
2467
2468 fn test_producer_ctx() -> ProducerContext {
2469 ProducerContext::new()
2470 }
2471
2472 #[test]
2473 fn test_http_config_defaults() {
2474 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2475 assert_eq!(config.base_url, "http://localhost:8080/api");
2476 assert!(config.http_method.is_none());
2477 assert!(config.throw_exception_on_failure);
2478 assert_eq!(config.ok_status_code_range, (200, 299));
2479 assert!(config.response_timeout.is_none());
2480 assert!(matches!(config.auth, HttpAuth::None));
2481 assert!(!config.bridge_endpoint);
2482 assert!(!config.connection_close);
2483 }
2484
2485 #[test]
2486 fn test_http_config_scheme() {
2487 assert_eq!(HttpEndpointConfig::scheme(), "http");
2489 }
2490
2491 #[test]
2492 fn test_http_config_from_components() {
2493 let components = camel_component_api::UriComponents {
2495 scheme: "https".to_string(),
2496 path: "//api.example.com/v1".to_string(),
2497 params: std::collections::HashMap::from([(
2498 "httpMethod".to_string(),
2499 "POST".to_string(),
2500 )]),
2501 };
2502 let config = HttpEndpointConfig::from_components(components).unwrap();
2503 assert_eq!(config.base_url, "https://api.example.com/v1");
2504 assert_eq!(config.http_method, Some("POST".to_string()));
2505 }
2506
2507 #[test]
2508 fn test_http_config_with_options() {
2509 let config = HttpEndpointConfig::from_uri(
2510 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2511 ).unwrap();
2512 assert_eq!(config.base_url, "https://api.example.com/v1");
2513 assert_eq!(config.http_method, Some("PUT".to_string()));
2514 assert!(!config.throw_exception_on_failure);
2515 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2516 }
2517
2518 #[test]
2519 fn test_http_endpoint_config_auth_and_headers_options() {
2520 let config = HttpEndpointConfig::from_uri(
2521 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2522 )
2523 .unwrap();
2524
2525 assert!(matches!(
2526 config.auth,
2527 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2528 ));
2529 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2530 assert!(config.bridge_endpoint);
2531 assert!(config.connection_close);
2532 assert_eq!(
2533 config.skip_request_headers,
2534 vec!["authorization".to_string(), "x-secret".to_string()]
2535 );
2536 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2537 }
2538
2539 #[test]
2540 fn test_http_endpoint_config_bearer_auth() {
2541 let config = HttpEndpointConfig::from_uri(
2542 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2543 )
2544 .unwrap();
2545 assert!(matches!(
2546 config.auth,
2547 HttpAuth::Bearer { token } if token == "t"
2548 ));
2549 }
2550
2551 #[test]
2552 fn rejects_cookie_handling_inmemory() {
2553 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2554 match result {
2555 Err(CamelError::InvalidUri(msg)) => {
2556 assert!(
2557 msg.contains("cookieHandling is not supported"),
2558 "expected rejection message, got: {msg}"
2559 );
2560 }
2561 other => panic!("expected InvalidUri error, got: {other:?}"),
2562 }
2563 }
2564
2565 #[test]
2566 fn rejects_cookie_handling_disabled() {
2567 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2568 match result {
2569 Err(CamelError::InvalidUri(msg)) => {
2570 assert!(
2571 msg.contains("cookieHandling is not supported"),
2572 "expected rejection message, got: {msg}"
2573 );
2574 }
2575 other => panic!("expected InvalidUri error, got: {other:?}"),
2576 }
2577 }
2578
2579 #[test]
2580 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2581 let config = HttpConfig::default()
2582 .with_response_timeout_ms(999)
2583 .with_allow_internal(true)
2584 .with_blocked_hosts(vec!["evil.com".to_string()])
2585 .with_max_body_size(12345);
2586 let endpoint =
2587 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2588 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2589 assert!(endpoint.allow_internal);
2590 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2591 assert_eq!(endpoint.max_body_size, 12345);
2592 }
2593
2594 #[test]
2595 fn test_from_uri_with_defaults_uri_overrides_config() {
2596 let config = HttpConfig::default()
2597 .with_response_timeout_ms(999)
2598 .with_allow_internal(true)
2599 .with_blocked_hosts(vec!["evil.com".to_string()])
2600 .with_max_body_size(12345);
2601 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2602 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2603 &config,
2604 )
2605 .unwrap();
2606 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2607 assert!(!endpoint.allow_internal);
2608 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2609 assert_eq!(endpoint.max_body_size, 99);
2610 }
2611
2612 #[test]
2613 fn test_http_config_ok_status_range() {
2614 let config =
2615 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2616 assert_eq!(config.ok_status_code_range, (200, 204));
2617 }
2618
2619 #[test]
2620 fn test_http_config_wrong_scheme() {
2621 let result = HttpEndpointConfig::from_uri("file:/tmp");
2622 assert!(result.is_err());
2623 }
2624
2625 #[test]
2626 fn test_http_component_scheme() {
2627 let component = HttpComponent::new();
2628 assert_eq!(component.scheme(), "http");
2629 }
2630
2631 #[test]
2632 fn test_https_component_scheme() {
2633 let component = HttpsComponent::new();
2634 assert_eq!(component.scheme(), "https");
2635 }
2636
2637 #[test]
2638 fn test_http_endpoint_creates_consumer() {
2639 let component = HttpComponent::new();
2640 let ctx = NoOpComponentContext;
2641 let endpoint = component
2642 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2643 .unwrap();
2644 assert!(endpoint.create_consumer(rt()).is_ok());
2645 }
2646
2647 #[test]
2648 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2649 let component = HttpsComponent::new();
2650 let ctx = NoOpComponentContext;
2651 let endpoint = component
2652 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2653 .unwrap();
2654 assert!(endpoint.create_consumer(rt()).is_err());
2656 }
2657
2658 #[test]
2659 fn test_http_endpoint_creates_producer() {
2660 let ctx = test_producer_ctx();
2661 let component = HttpComponent::new();
2662 let endpoint_ctx = NoOpComponentContext;
2663 let endpoint = component
2664 .create_endpoint("http://localhost/api", &endpoint_ctx)
2665 .unwrap();
2666 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2667 }
2668
2669 #[tokio::test]
2674 async fn test_producer_with_token_provider() {
2675 use camel_auth::oauth2::TokenProvider;
2676 use tower::ServiceExt;
2677
2678 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2679 Arc::new(std::sync::Mutex::new(None));
2680 let captured_clone = Arc::clone(&captured_auth);
2681
2682 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2683 let port = listener.local_addr().unwrap().port();
2684
2685 let _handle = tokio::spawn(async move {
2686 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2687 if let Ok((mut stream, _)) = listener.accept().await {
2688 let mut buf = vec![0u8; 8192];
2689 let n = stream.read(&mut buf).await.unwrap_or(0);
2690 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2691 let auth = request
2692 .lines()
2693 .find(|l| l.to_lowercase().starts_with("authorization:"))
2694 .map(|l| {
2695 l.split(':')
2696 .nth(1)
2697 .map(|s| s.trim().to_string())
2698 .unwrap_or_default()
2699 });
2700 *captured_clone.lock().unwrap() = auth;
2701 let body = r#"{"echo":"ok"}"#;
2702 let resp = format!(
2703 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2704 body.len(),
2705 body
2706 );
2707 let _ = stream.write_all(resp.as_bytes()).await;
2708 }
2709 });
2710
2711 #[derive(Debug)]
2712 struct StaticProvider;
2713 #[async_trait::async_trait]
2714 impl TokenProvider for StaticProvider {
2715 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2716 Ok("injected-token".into())
2717 }
2718 }
2719
2720 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2721 let ctx = test_producer_ctx();
2722 let component = HttpComponent::new();
2723 let endpoint_ctx = NoOpComponentContext;
2724 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2725 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2726
2727 let exchange = Exchange::new(Message::new("hello"));
2728
2729 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2730 let mut layered = layer.layer(producer);
2731 let result = layered.ready().await.unwrap().call(exchange).await;
2732 assert!(result.is_ok(), "producer call failed: {:?}", result);
2733
2734 tokio::time::sleep(Duration::from_millis(100)).await;
2735 let auth = captured_auth.lock().unwrap().take();
2736 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2737 }
2738
2739 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2740 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2741 let addr = listener.local_addr().unwrap();
2742 let url = format!("http://127.0.0.1:{}", addr.port());
2743
2744 let handle = tokio::spawn(async move {
2745 loop {
2746 if let Ok((mut stream, _)) = listener.accept().await {
2747 tokio::spawn(async move {
2748 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2749 let mut buf = vec![0u8; 4096];
2750 let n = stream.read(&mut buf).await.unwrap_or(0);
2751 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2752
2753 let method = request.split_whitespace().next().unwrap_or("GET");
2754
2755 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2756 let response = format!(
2757 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2758 body.len(),
2759 body
2760 );
2761 let _ = stream.write_all(response.as_bytes()).await;
2762 });
2763 }
2764 }
2765 });
2766
2767 (url, handle)
2768 }
2769
2770 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2771 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2772 let addr = listener.local_addr().unwrap();
2773 let url = format!("http://127.0.0.1:{}", addr.port());
2774
2775 let handle = tokio::spawn(async move {
2776 loop {
2777 if let Ok((mut stream, _)) = listener.accept().await {
2778 let status = status;
2779 tokio::spawn(async move {
2780 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2781 let mut buf = vec![0u8; 4096];
2782 let _ = stream.read(&mut buf).await;
2783
2784 let status_text = match status {
2785 404 => "Not Found",
2786 500 => "Internal Server Error",
2787 _ => "Error",
2788 };
2789 let body = "error body";
2790 let response = format!(
2791 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2792 status,
2793 status_text,
2794 body.len(),
2795 body
2796 );
2797 let _ = stream.write_all(response.as_bytes()).await;
2798 });
2799 }
2800 }
2801 });
2802
2803 (url, handle)
2804 }
2805
2806 async fn start_request_capturing_server() -> (
2807 String,
2808 Arc<std::sync::Mutex<Option<String>>>,
2809 tokio::task::JoinHandle<()>,
2810 ) {
2811 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2812 let port = listener.local_addr().unwrap().port();
2813 let url = format!("http://127.0.0.1:{port}");
2814 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2815 let captured_clone = Arc::clone(&captured);
2816 let handle = tokio::spawn(async move {
2817 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2818 if let Ok((mut stream, _)) = listener.accept().await {
2819 let mut buf = vec![0u8; 16384];
2820 let n = stream.read(&mut buf).await.unwrap_or(0);
2821 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2822 if request.contains("\r\n\r\n") {
2823 *captured_clone.lock().unwrap() = Some(request);
2824 }
2825 let body = r#"{"echo":"ok"}"#;
2826 let resp = format!(
2827 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2828 body.len(),
2829 body
2830 );
2831 let _ = stream.write_all(resp.as_bytes()).await;
2832 }
2833 });
2834 (url, captured, handle)
2835 }
2836
2837 #[tokio::test]
2838 async fn test_http_producer_get_request() {
2839 use tower::ServiceExt;
2840
2841 let (url, _handle) = start_test_server().await;
2842 let ctx = test_producer_ctx();
2843
2844 let component = HttpComponent::new();
2845 let endpoint_ctx = NoOpComponentContext;
2846 let endpoint = component
2847 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2848 .unwrap();
2849 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2850
2851 let exchange = Exchange::new(Message::default());
2852 let result = producer.oneshot(exchange).await.unwrap();
2853
2854 let status = result
2855 .input
2856 .header("CamelHttpResponseCode")
2857 .and_then(|v| v.as_u64())
2858 .unwrap();
2859 assert_eq!(status, 200);
2860
2861 assert!(!result.input.body.is_empty());
2862 }
2863
2864 #[tokio::test]
2865 async fn producer_excludes_host_and_framing() {
2866 use tower::ServiceExt;
2867
2868 let (url, captured, _handle) = start_request_capturing_server().await;
2869 let ctx = test_producer_ctx();
2870 let component = HttpComponent::new();
2871 let endpoint_ctx = NoOpComponentContext;
2872 let endpoint = component
2873 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2874 .unwrap();
2875 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2876
2877 let mut exchange = Exchange::new(Message::default());
2878 exchange.input.set_header("Host", "localhost");
2879 exchange.input.set_header("Content-Length", "42");
2880 exchange.input.set_header("Connection", "keep-alive");
2881 exchange.input.set_header("Upgrade", "h2c");
2882
2883 let result = producer.oneshot(exchange).await;
2884 assert!(result.is_ok(), "producer call failed: {:?}", result);
2885
2886 tokio::time::sleep(Duration::from_millis(100)).await;
2887 let request = captured
2888 .lock()
2889 .unwrap()
2890 .take()
2891 .expect("no outbound request captured");
2892 let lower = request.to_ascii_lowercase();
2893 assert!(
2894 !lower.contains("\r\nhost: localhost"),
2895 "forwarded Host: localhost must be stripped\n{request}"
2896 );
2897 assert!(
2898 !lower.contains("content-length: 42"),
2899 "exchange Content-Length must not be copied\n{request}"
2900 );
2901 assert!(
2902 !lower.lines().any(|l| l.starts_with("connection:")),
2903 "Connection header must not be forwarded\n{request}"
2904 );
2905 assert!(
2906 !lower.lines().any(|l| l.starts_with("upgrade:")),
2907 "Upgrade header must not be forwarded\n{request}"
2908 );
2909 let host_header = lower
2910 .lines()
2911 .find(|l| l.starts_with("host:"))
2912 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
2913 .expect("outbound Host header must be set by reqwest");
2914 assert!(
2915 host_header.starts_with("127.0.0.1:"),
2916 "outbound Host '{host_header}' must match the capture-server address"
2917 );
2918 }
2919
2920 #[tokio::test]
2921 async fn producer_forwards_request_only_headers() {
2922 use tower::ServiceExt;
2923
2924 let (url, captured, _handle) = start_request_capturing_server().await;
2925 let ctx = test_producer_ctx();
2926 let component = HttpComponent::new();
2927 let endpoint_ctx = NoOpComponentContext;
2928 let endpoint = component
2929 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2930 .unwrap();
2931 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2932
2933 let mut exchange = Exchange::new(Message::default());
2934 exchange.input.set_header("Accept", "application/json");
2935 exchange.input.set_header("User-Agent", "myclient/1.0");
2936
2937 let result = producer.oneshot(exchange).await;
2938 assert!(result.is_ok(), "producer call failed: {:?}", result);
2939
2940 tokio::time::sleep(Duration::from_millis(100)).await;
2941 let request = captured
2942 .lock()
2943 .unwrap()
2944 .take()
2945 .expect("no outbound request captured");
2946 let lower = request.to_ascii_lowercase();
2947 assert!(
2948 lower.contains("accept: application/json"),
2949 "request-only Accept header must be forwarded\n{request}"
2950 );
2951 assert!(
2952 lower.contains("user-agent: myclient/1.0"),
2953 "request-only User-Agent header must be forwarded\n{request}"
2954 );
2955 }
2956
2957 #[tokio::test]
2958 async fn producer_honours_skip_request_headers() {
2959 use tower::ServiceExt;
2960
2961 let (url, captured, _handle) = start_request_capturing_server().await;
2962 let ctx = test_producer_ctx();
2963 let component = HttpComponent::new();
2964 let endpoint_ctx = NoOpComponentContext;
2965 let endpoint = component
2966 .create_endpoint(
2967 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
2968 &endpoint_ctx,
2969 )
2970 .unwrap();
2971 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2972
2973 let mut exchange = Exchange::new(Message::default());
2974 exchange.input.set_header("Authorization", "Bearer x");
2975
2976 let result = producer.oneshot(exchange).await;
2977 assert!(result.is_ok(), "producer call failed: {:?}", result);
2978
2979 tokio::time::sleep(Duration::from_millis(100)).await;
2980 let request = captured
2981 .lock()
2982 .unwrap()
2983 .take()
2984 .expect("no outbound request captured");
2985 assert!(
2986 !request.to_ascii_lowercase().contains("authorization"),
2987 "Authorization must be stripped by skipRequestHeaders\n{request}"
2988 );
2989 }
2990
2991 #[tokio::test]
2992 async fn test_http_producer_post_with_body() {
2993 use tower::ServiceExt;
2994
2995 let (url, _handle) = start_test_server().await;
2996 let ctx = test_producer_ctx();
2997
2998 let component = HttpComponent::new();
2999 let endpoint_ctx = NoOpComponentContext;
3000 let endpoint = component
3001 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3002 .unwrap();
3003 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3004
3005 let exchange = Exchange::new(Message::new("request body"));
3006 let result = producer.oneshot(exchange).await.unwrap();
3007
3008 let status = result
3009 .input
3010 .header("CamelHttpResponseCode")
3011 .and_then(|v| v.as_u64())
3012 .unwrap();
3013 assert_eq!(status, 200);
3014 }
3015
3016 #[tokio::test]
3017 async fn test_http_producer_method_from_header() {
3018 use tower::ServiceExt;
3019
3020 let (url, _handle) = start_test_server().await;
3021 let ctx = test_producer_ctx();
3022
3023 let component = HttpComponent::new();
3024 let endpoint_ctx = NoOpComponentContext;
3025 let endpoint = component
3026 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3027 .unwrap();
3028 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3029
3030 let mut exchange = Exchange::new(Message::default());
3031 exchange.input.set_header(
3032 "CamelHttpMethod",
3033 serde_json::Value::String("DELETE".to_string()),
3034 );
3035
3036 let result = producer.oneshot(exchange).await.unwrap();
3037 let status = result
3038 .input
3039 .header("CamelHttpResponseCode")
3040 .and_then(|v| v.as_u64())
3041 .unwrap();
3042 assert_eq!(status, 200);
3043 }
3044
3045 #[tokio::test]
3046 async fn test_http_producer_forced_method() {
3047 use tower::ServiceExt;
3048
3049 let (url, _handle) = start_test_server().await;
3050 let ctx = test_producer_ctx();
3051
3052 let component = HttpComponent::new();
3053 let endpoint_ctx = NoOpComponentContext;
3054 let endpoint = component
3055 .create_endpoint(
3056 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3057 &endpoint_ctx,
3058 )
3059 .unwrap();
3060 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3061
3062 let exchange = Exchange::new(Message::default());
3063 let result = producer.oneshot(exchange).await.unwrap();
3064
3065 let status = result
3066 .input
3067 .header("CamelHttpResponseCode")
3068 .and_then(|v| v.as_u64())
3069 .unwrap();
3070 assert_eq!(status, 200);
3071 }
3072
3073 #[tokio::test]
3074 async fn test_http_producer_throw_exception_on_failure() {
3075 use tower::ServiceExt;
3076
3077 let (url, _handle) = start_status_server(404).await;
3078 let ctx = test_producer_ctx();
3079
3080 let component = HttpComponent::new();
3081 let endpoint_ctx = NoOpComponentContext;
3082 let endpoint = component
3083 .create_endpoint(
3084 &format!("{url}/not-found?allowInternal=true"),
3085 &endpoint_ctx,
3086 )
3087 .unwrap();
3088 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3089
3090 let exchange = Exchange::new(Message::default());
3091 let result = producer.oneshot(exchange).await;
3092 assert!(result.is_err());
3093
3094 match result.unwrap_err() {
3095 CamelError::HttpOperationFailed { status_code, .. } => {
3096 assert_eq!(status_code, 404);
3097 }
3098 e => panic!("Expected HttpOperationFailed, got: {e}"),
3099 }
3100 }
3101
3102 #[tokio::test]
3103 async fn test_http_producer_no_throw_on_failure() {
3104 use tower::ServiceExt;
3105
3106 let (url, _handle) = start_status_server(500).await;
3107 let ctx = test_producer_ctx();
3108
3109 let component = HttpComponent::new();
3110 let endpoint_ctx = NoOpComponentContext;
3111 let endpoint = component
3112 .create_endpoint(
3113 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3114 &endpoint_ctx,
3115 )
3116 .unwrap();
3117 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3118
3119 let exchange = Exchange::new(Message::default());
3120 let result = producer.oneshot(exchange).await.unwrap();
3121
3122 let status = result
3123 .input
3124 .header("CamelHttpResponseCode")
3125 .and_then(|v| v.as_u64())
3126 .unwrap();
3127 assert_eq!(status, 500);
3128 }
3129
3130 #[tokio::test]
3131 async fn test_http_producer_uri_override() {
3132 use tower::ServiceExt;
3133
3134 let (url, _handle) = start_test_server().await;
3135 let ctx = test_producer_ctx();
3136
3137 let component = HttpComponent::new();
3138 let endpoint_ctx = NoOpComponentContext;
3139 let endpoint = component
3140 .create_endpoint(
3141 "http://localhost:1/does-not-exist?allowInternal=true",
3142 &endpoint_ctx,
3143 )
3144 .unwrap();
3145 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3146
3147 let mut exchange = Exchange::new(Message::default());
3148 exchange.input.set_header(
3149 "CamelHttpUri",
3150 serde_json::Value::String(format!("{url}/api")),
3151 );
3152
3153 let result = producer.oneshot(exchange).await.unwrap();
3154 let status = result
3155 .input
3156 .header("CamelHttpResponseCode")
3157 .and_then(|v| v.as_u64())
3158 .unwrap();
3159 assert_eq!(status, 200);
3160 }
3161
3162 #[tokio::test]
3163 async fn test_http_producer_response_headers_mapped() {
3164 use tower::ServiceExt;
3165
3166 let (url, _handle) = start_test_server().await;
3167 let ctx = test_producer_ctx();
3168
3169 let component = HttpComponent::new();
3170 let endpoint_ctx = NoOpComponentContext;
3171 let endpoint = component
3172 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3173 .unwrap();
3174 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3175
3176 let exchange = Exchange::new(Message::default());
3177 let result = producer.oneshot(exchange).await.unwrap();
3178
3179 assert!(
3180 result.input.header("Content-Type").is_some(),
3181 "Response should have Content-Type header"
3182 );
3183 assert!(result.input.header("CamelHttpResponseText").is_some());
3184 }
3185
3186 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3191 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3192 let addr = listener.local_addr().unwrap();
3193 let url = format!("http://127.0.0.1:{}", addr.port());
3194
3195 let handle = tokio::spawn(async move {
3196 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3197 loop {
3198 if let Ok((mut stream, _)) = listener.accept().await {
3199 tokio::spawn(async move {
3200 let mut buf = vec![0u8; 4096];
3201 let n = stream.read(&mut buf).await.unwrap_or(0);
3202 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3203
3204 if request.contains("GET /final") {
3206 let body = r#"{"status":"final"}"#;
3207 let response = format!(
3208 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3209 body.len(),
3210 body
3211 );
3212 let _ = stream.write_all(response.as_bytes()).await;
3213 } else {
3214 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3216 let _ = stream.write_all(response.as_bytes()).await;
3217 }
3218 });
3219 }
3220 }
3221 });
3222
3223 (url, handle)
3224 }
3225
3226 struct CapturedRequest {
3227 method: String,
3228 path: String,
3229 body: Vec<u8>,
3230 content_length: Option<String>,
3231 transfer_encoding: Option<String>,
3232 }
3233
3234 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3240 use tokio::io::AsyncReadExt;
3241
3242 let mut buf: Vec<u8> = Vec::new();
3244 let mut chunk = [0u8; 4096];
3245 let head_end: usize;
3246 loop {
3247 let n = stream.read(&mut chunk).await.unwrap_or(0);
3248 if n == 0 {
3249 return None;
3250 }
3251 buf.extend_from_slice(&chunk[..n]);
3252 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3253 head_end = pos + 4;
3254 break;
3255 }
3256 }
3257
3258 let head = String::from_utf8_lossy(&buf[..head_end]);
3260 let mut lines = head.split("\r\n");
3261 let request_line = lines.next().unwrap_or("");
3262 let mut parts = request_line.split_whitespace();
3263 let method = parts.next().unwrap_or("").to_string();
3264 let path = parts.next().unwrap_or("").to_string();
3265
3266 let mut content_length: Option<String> = None;
3267 let mut transfer_encoding: Option<String> = None;
3268 for line in lines {
3269 if let Some((name, value)) = line.split_once(':') {
3270 let name = name.trim().to_ascii_lowercase();
3271 let value = value.trim().to_string();
3272 if name == "content-length" {
3273 content_length = Some(value);
3274 } else if name == "transfer-encoding" {
3275 transfer_encoding = Some(value);
3276 }
3277 }
3278 }
3279
3280 let body_len: usize = content_length
3282 .as_deref()
3283 .and_then(|v| v.parse::<usize>().ok())
3284 .unwrap_or(0);
3285
3286 let mut body: Vec<u8> = buf[head_end..].to_vec();
3287 while body.len() < body_len {
3288 let n = stream.read(&mut chunk).await.unwrap_or(0);
3289 if n == 0 {
3290 break;
3291 }
3292 body.extend_from_slice(&chunk[..n]);
3293 }
3294 body.truncate(body_len);
3295
3296 Some(CapturedRequest {
3297 method,
3298 path,
3299 body,
3300 content_length,
3301 transfer_encoding,
3302 })
3303 }
3304
3305 async fn start_capture_server() -> (
3310 String,
3311 tokio::task::JoinHandle<()>,
3312 Arc<Mutex<Vec<CapturedRequest>>>,
3313 ) {
3314 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3315 let addr = listener.local_addr().unwrap();
3316 let url = format!("http://127.0.0.1:{}", addr.port());
3317
3318 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3319 let captured_for_return = Arc::clone(&captured);
3320
3321 let handle = tokio::spawn(async move {
3322 use tokio::io::AsyncWriteExt;
3323 loop {
3324 if let Ok((mut stream, _)) = listener.accept().await {
3325 let captured = Arc::clone(&captured);
3326 tokio::spawn(async move {
3327 let Some(req) = capture_request(&mut stream).await else {
3328 return;
3329 };
3330 captured.lock().unwrap().push(req);
3331
3332 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3335 let _ = stream.write_all(response.as_bytes()).await;
3336 });
3337 }
3338 }
3339 });
3340
3341 (url, handle, captured_for_return)
3342 }
3343
3344 async fn start_redirect_capture_server() -> (
3350 String,
3351 tokio::task::JoinHandle<()>,
3352 Arc<Mutex<Vec<CapturedRequest>>>,
3353 ) {
3354 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3355 let addr = listener.local_addr().unwrap();
3356 let url = format!("http://127.0.0.1:{}", addr.port());
3357
3358 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3359 let captured_for_return = Arc::clone(&captured);
3360
3361 let handle = tokio::spawn(async move {
3362 use tokio::io::AsyncWriteExt;
3363 loop {
3364 if let Ok((mut stream, _)) = listener.accept().await {
3365 let captured = Arc::clone(&captured);
3366 tokio::spawn(async move {
3367 let Some(req) = capture_request(&mut stream).await else {
3368 return;
3369 };
3370 let path = req.path.clone();
3371 captured.lock().unwrap().push(req);
3372
3373 let (status_line, location) = match path.as_str() {
3374 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3375 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3376 "/final" => ("HTTP/1.1 200 OK", None),
3377 _ => ("HTTP/1.1 404 Not Found", None),
3378 };
3379
3380 let response = match location {
3381 Some(loc) => format!(
3382 "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3383 ),
3384 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3385 };
3386 let _ = stream.write_all(response.as_bytes()).await;
3387 });
3388 }
3389 }
3390 });
3391
3392 (url, handle, captured_for_return)
3393 }
3394
3395 #[tokio::test]
3396 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3397 use tower::ServiceExt;
3398
3399 let (url, _handle, captured) = start_capture_server().await;
3400 let ctx = test_producer_ctx();
3401
3402 let component = HttpComponent::with_config(HttpConfig::default());
3403 let endpoint_ctx = NoOpComponentContext;
3404 let endpoint = component
3405 .create_endpoint(
3406 &format!("{url}?httpMethod=GET&allowInternal=true"),
3407 &endpoint_ctx,
3408 )
3409 .unwrap();
3410 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3411
3412 let mut exchange = Exchange::new(Message::default());
3413 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3414
3415 let result = producer.oneshot(exchange).await.unwrap();
3416
3417 let status = result
3418 .input
3419 .header("CamelHttpResponseCode")
3420 .and_then(|v| v.as_u64())
3421 .unwrap();
3422 assert_eq!(status, 200);
3423
3424 let captured = captured.lock().unwrap();
3425 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3426 let req = &captured[0];
3427 assert_eq!(req.method, "GET");
3428 assert_eq!(req.path, "/");
3431 assert!(req.body.is_empty(), "GET must not carry a body");
3432 assert!(
3433 req.content_length.is_none(),
3434 "suppressed request must not carry Content-Length"
3435 );
3436 assert!(
3437 req.transfer_encoding.is_none(),
3438 "suppressed request must not carry Transfer-Encoding"
3439 );
3440
3441 assert!(
3443 result.input.body.is_empty(),
3444 "exchange body must be consumed"
3445 );
3446 }
3447
3448 #[tokio::test]
3449 async fn test_head_with_body_suppressed_via_header() {
3450 use tower::ServiceExt;
3451
3452 let (url, _handle, captured) = start_capture_server().await;
3453 let ctx = test_producer_ctx();
3454
3455 let component = HttpComponent::with_config(HttpConfig::default());
3456 let endpoint_ctx = NoOpComponentContext;
3457 let endpoint = component
3458 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3459 .unwrap();
3460 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3461
3462 let mut exchange = Exchange::new(Message::default());
3463 exchange.input.set_header(
3464 "CamelHttpMethod",
3465 serde_json::Value::String("HEAD".to_string()),
3466 );
3467 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3468
3469 let result = producer.oneshot(exchange).await.unwrap();
3470 let status = result
3471 .input
3472 .header("CamelHttpResponseCode")
3473 .and_then(|v| v.as_u64())
3474 .unwrap();
3475 assert_eq!(status, 200);
3476
3477 let captured = captured.lock().unwrap();
3478 assert_eq!(captured.len(), 1);
3479 let req = &captured[0];
3480 assert_eq!(req.method, "HEAD");
3481 assert!(req.body.is_empty(), "HEAD must not carry a body");
3482 }
3483
3484 #[tokio::test]
3485 async fn test_delete_options_trace_with_body_suppressed() {
3486 use tower::ServiceExt;
3487
3488 let (url, _handle, captured) = start_capture_server().await;
3489 let ctx = test_producer_ctx();
3490 let component = HttpComponent::with_config(HttpConfig::default());
3491 let endpoint_ctx = NoOpComponentContext;
3492
3493 for method in ["DELETE", "OPTIONS", "TRACE"] {
3494 let endpoint = component
3495 .create_endpoint(
3496 &format!("{url}?httpMethod={method}&allowInternal=true"),
3497 &endpoint_ctx,
3498 )
3499 .unwrap();
3500 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3501
3502 let mut exchange = Exchange::new(Message::default());
3503 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3504
3505 let result = producer.oneshot(exchange).await.unwrap();
3506 let status = result
3507 .input
3508 .header("CamelHttpResponseCode")
3509 .and_then(|v| v.as_u64())
3510 .unwrap();
3511 assert_eq!(status, 200, "method {method} should succeed");
3512 }
3513
3514 let captured = captured.lock().unwrap();
3515 assert_eq!(captured.len(), 3, "expected three captured requests");
3516 for method in ["DELETE", "OPTIONS", "TRACE"] {
3517 let req = captured
3518 .iter()
3519 .find(|r| r.method == method)
3520 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3521 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3522 }
3523 }
3524
3525 #[tokio::test]
3526 async fn test_post_put_patch_with_body_still_sent() {
3527 use tower::ServiceExt;
3528
3529 let (url, _handle, captured) = start_capture_server().await;
3530 let ctx = test_producer_ctx();
3531 let component = HttpComponent::with_config(HttpConfig::default());
3532 let endpoint_ctx = NoOpComponentContext;
3533
3534 for method in ["POST", "PUT", "PATCH"] {
3535 let endpoint = component
3536 .create_endpoint(
3537 &format!("{url}?httpMethod={method}&allowInternal=true"),
3538 &endpoint_ctx,
3539 )
3540 .unwrap();
3541 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3542
3543 let payload = format!("body-for-{method}");
3544 let mut exchange = Exchange::new(Message::default());
3545 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3546
3547 let result = producer.oneshot(exchange).await.unwrap();
3548 let status = result
3549 .input
3550 .header("CamelHttpResponseCode")
3551 .and_then(|v| v.as_u64())
3552 .unwrap();
3553 assert_eq!(status, 200, "method {method} should succeed");
3554 }
3555
3556 let captured = captured.lock().unwrap();
3557 assert_eq!(captured.len(), 3, "expected three captured requests");
3558 for method in ["POST", "PUT", "PATCH"] {
3559 let req = captured
3560 .iter()
3561 .find(|r| r.method == method)
3562 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3563 let expected = format!("body-for-{method}");
3564 assert!(!req.body.is_empty(), "{method} must still carry its body");
3565 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3566 }
3567 }
3568
3569 #[tokio::test]
3573 async fn test_stream_body_under_get_not_attached() {
3574 use tower::ServiceExt;
3575
3576 let (url, _handle, captured) = start_capture_server().await;
3577 let ctx = test_producer_ctx();
3578
3579 let component = HttpComponent::with_config(HttpConfig::default());
3580 let endpoint_ctx = NoOpComponentContext;
3581 let endpoint = component
3582 .create_endpoint(
3583 &format!("{url}?httpMethod=GET&allowInternal=true"),
3584 &endpoint_ctx,
3585 )
3586 .unwrap();
3587 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3588
3589 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3590 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3591 let stream = Box::pin(futures::stream::iter(chunks));
3592 let mut exchange = Exchange::new(Message::default());
3593 exchange.input.body = Body::Stream(StreamBody {
3594 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3595 metadata: StreamMetadata::default(),
3596 });
3597
3598 let result = producer.oneshot(exchange).await.unwrap();
3599
3600 let status = result
3601 .input
3602 .header("CamelHttpResponseCode")
3603 .and_then(|v| v.as_u64())
3604 .unwrap();
3605 assert_eq!(status, 200);
3606
3607 let captured = captured.lock().unwrap();
3608 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3609 assert!(
3610 captured[0].body.is_empty(),
3611 "GET must not carry a stream body"
3612 );
3613 assert!(
3614 captured[0].transfer_encoding.is_none(),
3615 "suppressed request must not carry Transfer-Encoding"
3616 );
3617 assert!(
3618 captured[0].content_length.is_none(),
3619 "suppressed request must not carry Content-Length"
3620 );
3621 assert!(
3622 result.input.body.is_empty(),
3623 "exchange body must be consumed to Empty, not left as a stream"
3624 );
3625 }
3626
3627 #[tokio::test]
3631 async fn test_redirect_hops_never_replay_suppressed_body() {
3632 use tower::ServiceExt;
3633
3634 let (url, _handle, captured) = start_redirect_capture_server().await;
3635 let ctx = test_producer_ctx();
3636
3637 let component =
3638 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3639 let endpoint_ctx = NoOpComponentContext;
3640
3641 for path in ["/hop307", "/hop308"] {
3642 let endpoint = component
3643 .create_endpoint(
3644 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3645 &endpoint_ctx,
3646 )
3647 .unwrap();
3648 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3649
3650 let mut exchange = Exchange::new(Message::default());
3651 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3652
3653 let result = producer.oneshot(exchange).await.unwrap();
3654 let status = result
3655 .input
3656 .header("CamelHttpResponseCode")
3657 .and_then(|v| v.as_u64())
3658 .unwrap();
3659 assert_eq!(
3660 status, 200,
3661 "redirect chain for {path} should end at /final"
3662 );
3663 }
3664
3665 let captured = captured.lock().unwrap();
3667 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3668 for req in captured.iter() {
3669 assert!(
3670 req.body.is_empty(),
3671 "hop {} {} must not carry a body",
3672 req.method,
3673 req.path
3674 );
3675 }
3676 }
3677
3678 #[tracing_test::traced_test]
3687 #[tokio::test]
3688 async fn test_suppressed_body_logs_exactly_one_warn() {
3689 use tower::ServiceExt;
3690
3691 let (url, _handle, _captured) = start_capture_server().await;
3692 let ctx = test_producer_ctx();
3693
3694 let component = HttpComponent::with_config(HttpConfig::default());
3695 let endpoint_ctx = NoOpComponentContext;
3696 let endpoint = component
3697 .create_endpoint(
3698 &format!("{url}?httpMethod=GET&allowInternal=true"),
3699 &endpoint_ctx,
3700 )
3701 .unwrap();
3702 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3703
3704 let mut exchange = Exchange::new(Message::default());
3705 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3706 let correlation_id = exchange.correlation_id().to_string();
3707
3708 let result = producer.oneshot(exchange).await.unwrap();
3709 let status = result
3710 .input
3711 .header("CamelHttpResponseCode")
3712 .and_then(|v| v.as_u64())
3713 .unwrap();
3714 assert_eq!(status, 200);
3715
3716 logs_assert(|lines: &[&str]| {
3717 let hits = lines
3718 .iter()
3719 .filter(|l| {
3720 l.contains("dropping request body")
3721 && l.contains("method=GET")
3722 && l.contains(&format!("correlation_id={correlation_id}"))
3723 })
3724 .count();
3725 match hits {
3726 1 => Ok(()),
3727 n => Err(format!("expected exactly one body-drop warn, found {n}")),
3728 }
3729 });
3730 }
3731
3732 #[tracing_test::traced_test]
3733 #[tokio::test]
3734 async fn test_empty_body_get_emits_no_warn() {
3735 use tower::ServiceExt;
3736
3737 let (url, _handle, _captured) = start_capture_server().await;
3738 let ctx = test_producer_ctx();
3739
3740 let component = HttpComponent::with_config(HttpConfig::default());
3741 let endpoint_ctx = NoOpComponentContext;
3742 let endpoint = component
3743 .create_endpoint(
3744 &format!("{url}?httpMethod=GET&allowInternal=true"),
3745 &endpoint_ctx,
3746 )
3747 .unwrap();
3748 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3749
3750 let exchange = Exchange::new(Message::default());
3751 let result = producer.oneshot(exchange).await.unwrap();
3752 let status = result
3753 .input
3754 .header("CamelHttpResponseCode")
3755 .and_then(|v| v.as_u64())
3756 .unwrap();
3757 assert_eq!(status, 200);
3758
3759 logs_assert(|lines: &[&str]| {
3760 let hits = lines
3761 .iter()
3762 .filter(|l| l.contains("dropping request body"))
3763 .count();
3764 match hits {
3765 0 => Ok(()),
3766 n => Err(format!("expected no body-drop warn, found {n}")),
3767 }
3768 });
3769 }
3770
3771 #[tokio::test]
3772 async fn test_follow_redirects_false_does_not_follow() {
3773 use tower::ServiceExt;
3774
3775 let (url, _handle) = start_redirect_server().await;
3776 let ctx = test_producer_ctx();
3777
3778 let component =
3779 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
3780 let endpoint_ctx = NoOpComponentContext;
3781 let endpoint = component
3782 .create_endpoint(
3783 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
3784 &endpoint_ctx,
3785 )
3786 .unwrap();
3787 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3788
3789 let exchange = Exchange::new(Message::default());
3790 let result = producer.oneshot(exchange).await.unwrap();
3791
3792 let status = result
3794 .input
3795 .header("CamelHttpResponseCode")
3796 .and_then(|v| v.as_u64())
3797 .unwrap();
3798 assert_eq!(
3799 status, 302,
3800 "Should NOT follow redirect when followRedirects=false"
3801 );
3802 }
3803
3804 #[tokio::test]
3805 async fn test_follow_redirects_true_follows_redirect() {
3806 use tower::ServiceExt;
3807
3808 let (url, _handle) = start_redirect_server().await;
3809 let ctx = test_producer_ctx();
3810
3811 let component =
3812 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3813 let endpoint_ctx = NoOpComponentContext;
3814 let endpoint = component
3815 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3816 .unwrap();
3817 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3818
3819 let exchange = Exchange::new(Message::default());
3820 let result = producer.oneshot(exchange).await.unwrap();
3821
3822 let status = result
3824 .input
3825 .header("CamelHttpResponseCode")
3826 .and_then(|v| v.as_u64())
3827 .unwrap();
3828 assert_eq!(
3829 status, 200,
3830 "Should follow redirect when followRedirects=true"
3831 );
3832 }
3833
3834 #[tokio::test]
3837 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
3838 use tower::ServiceExt;
3839
3840 let (url, _handle) = start_redirect_server().await;
3842 let ctx = test_producer_ctx();
3843
3844 let component =
3845 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3846 let endpoint_ctx = NoOpComponentContext;
3847 let endpoint = component
3848 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3849 .unwrap();
3850 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3851
3852 let exchange = Exchange::new(Message::default());
3853 let result = producer.oneshot(exchange).await;
3854
3855 assert!(
3857 result.is_ok(),
3858 "Redirect should succeed with allowInternal=true, got: {:?}",
3859 result
3860 );
3861 let exchange = result.unwrap();
3862 let status = exchange
3863 .input
3864 .header("CamelHttpResponseCode")
3865 .and_then(|v| v.as_u64())
3866 .unwrap();
3867 assert_eq!(status, 200, "Should follow redirect to /final");
3868 }
3869
3870 #[tokio::test]
3872 async fn test_redirect_to_private_ip_allowed_when_configured() {
3873 use tower::ServiceExt;
3874
3875 let (url, _handle) = start_redirect_server().await;
3877 let ctx = test_producer_ctx();
3878
3879 let component =
3880 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3881 let endpoint_ctx = NoOpComponentContext;
3882 let endpoint = component
3883 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3884 .unwrap();
3885 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3886
3887 let exchange = Exchange::new(Message::default());
3888 let result = producer.oneshot(exchange).await.unwrap();
3889
3890 let status = result
3891 .input
3892 .header("CamelHttpResponseCode")
3893 .and_then(|v| v.as_u64())
3894 .unwrap();
3895 assert_eq!(
3896 status, 200,
3897 "Should follow redirect to private IP when allowInternal=true"
3898 );
3899 }
3900
3901 #[tokio::test]
3904 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
3905 use tower::ServiceExt;
3906
3907 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3909 let addr = listener.local_addr().unwrap();
3910 let url = format!("http://127.0.0.1:{}", addr.port());
3911
3912 let handle = tokio::spawn(async move {
3913 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3914 loop {
3915 if let Ok((mut stream, _)) = listener.accept().await {
3916 tokio::spawn(async move {
3917 let mut buf = vec![0u8; 4096];
3918 let _ = stream.read(&mut buf).await;
3919 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";
3921 let _ = stream.write_all(response.as_bytes()).await;
3922 });
3923 }
3924 }
3925 });
3926
3927 let ctx = test_producer_ctx();
3928 let component =
3929 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3930 let endpoint_ctx = NoOpComponentContext;
3931 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
3933 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3934
3935 let exchange = Exchange::new(Message::default());
3936 let result = producer.oneshot(exchange).await;
3937
3938 assert!(
3940 result.is_err(),
3941 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
3942 );
3943 let err = result.unwrap_err().to_string();
3944 assert!(
3945 err.contains("blocked IP")
3946 || err.contains("private IP")
3947 || err.contains("SSRF")
3948 || err.contains("not allowed"),
3949 "Error should mention SSRF/IP blocking, got: {err}"
3950 );
3951
3952 handle.abort();
3953 }
3954
3955 #[tokio::test]
3957 async fn test_too_many_redirects_returns_error() {
3958 use tower::ServiceExt;
3959
3960 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3962 let addr = listener.local_addr().unwrap();
3963 let url = format!("http://127.0.0.1:{}", addr.port());
3964
3965 let handle = tokio::spawn(async move {
3966 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3967 loop {
3968 if let Ok((mut stream, _)) = listener.accept().await {
3969 tokio::spawn(async move {
3970 let mut buf = vec![0u8; 4096];
3971 let _ = stream.read(&mut buf).await;
3972 let response =
3974 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
3975 let _ = stream.write_all(response.as_bytes()).await;
3976 });
3977 }
3978 }
3979 });
3980
3981 let ctx = test_producer_ctx();
3982 let component =
3983 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3984 let endpoint_ctx = NoOpComponentContext;
3985 let endpoint = component
3986 .create_endpoint(
3987 &format!("{url}?allowInternal=true&maxRedirects=2"),
3988 &endpoint_ctx,
3989 )
3990 .unwrap();
3991 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3992
3993 let exchange = Exchange::new(Message::default());
3994 let result = producer.oneshot(exchange).await;
3995
3996 match result {
4004 Err(e) => {
4005 let msg = e.to_string();
4007 assert!(
4008 msg.contains("HTTP operation failed") || msg.contains("302"),
4009 "expected redirect-after-exhaustion error, got: {msg}"
4010 );
4011 }
4012 Ok(ex) => {
4013 let response_code = ex
4014 .input
4015 .header("CamelHttpResponseCode")
4016 .and_then(|v| v.as_u64());
4017 assert_eq!(
4018 response_code,
4019 Some(302),
4020 "expected 302 after exhausting redirects"
4021 );
4022 }
4023 }
4024
4025 handle.abort();
4026 }
4027
4028 #[tokio::test]
4029 async fn test_query_params_forwarded_to_http_request() {
4030 use tower::ServiceExt;
4031
4032 let (url, _handle) = start_test_server().await;
4033 let ctx = test_producer_ctx();
4034
4035 let component = HttpComponent::new();
4036 let endpoint_ctx = NoOpComponentContext;
4037 let endpoint = component
4039 .create_endpoint(
4040 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4041 &endpoint_ctx,
4042 )
4043 .unwrap();
4044 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4045
4046 let exchange = Exchange::new(Message::default());
4047 let result = producer.oneshot(exchange).await.unwrap();
4048
4049 let status = result
4052 .input
4053 .header("CamelHttpResponseCode")
4054 .and_then(|v| v.as_u64())
4055 .unwrap();
4056 assert_eq!(status, 200);
4057 }
4058
4059 #[tokio::test]
4060 async fn test_non_camel_query_params_are_forwarded() {
4061 let config = HttpEndpointConfig::from_uri(
4064 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4065 )
4066 .unwrap();
4067
4068 assert!(
4070 config.query_params.contains_key("apiKey"),
4071 "apiKey should be preserved"
4072 );
4073 assert!(
4074 config.query_params.contains_key("token"),
4075 "token should be preserved"
4076 );
4077 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4078 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4079
4080 assert!(
4082 !config.query_params.contains_key("httpMethod"),
4083 "httpMethod should not be forwarded"
4084 );
4085 }
4086
4087 #[test]
4088 fn test_query_params_are_url_encoded_when_resolving_url() {
4089 let config =
4090 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4091 let exchange = Exchange::new(Message::default());
4092
4093 let url = HttpProducer::resolve_url(&exchange, &config);
4094
4095 assert!(url.contains("q=hello+world"), "url was: {url}");
4096 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4097 }
4098
4099 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4104 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4105 let addr = listener.local_addr().unwrap();
4106 let url = format!("http://127.0.0.1:{}", addr.port());
4107
4108 let handle = tokio::spawn(async move {
4109 loop {
4110 if let Ok((mut stream, _)) = listener.accept().await {
4111 let delay = delay_ms;
4112 tokio::spawn(async move {
4113 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4114 let mut buf = vec![0u8; 4096];
4115 let _ = stream.read(&mut buf).await;
4116 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4118 let _ = stream.write_all(headers.as_bytes()).await;
4119 tokio::time::sleep(Duration::from_millis(delay)).await;
4121 let body = r#"{"status":"slow"}"#;
4122 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4123 let _ = stream.write_all(chunk.as_bytes()).await;
4124 });
4125 }
4126 }
4127 });
4128
4129 (url, handle)
4130 }
4131
4132 #[tokio::test]
4133 async fn test_http_producer_timeout() {
4134 use tower::ServiceExt;
4135
4136 let (url, _handle) = start_slow_server(500).await;
4138 let ctx = test_producer_ctx();
4139
4140 let component = HttpComponent::with_config(
4141 HttpConfig::default()
4142 .with_read_timeout_ms(100)
4143 .with_response_timeout_ms(30_000), );
4145 let endpoint_ctx = NoOpComponentContext;
4146 let endpoint = component
4147 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4148 .unwrap();
4149 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4150
4151 let exchange = Exchange::new(Message::default());
4152 let result = producer.oneshot(exchange).await;
4153
4154 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4155 let err = result.unwrap_err().to_string();
4156 assert!(
4157 err.contains("Read timeout") || err.contains("timeout"),
4158 "Error should mention timeout, got: {}",
4159 err
4160 );
4161 }
4162
4163 #[tokio::test]
4164 async fn test_http_producer_no_timeout_when_fast() {
4165 use tower::ServiceExt;
4166
4167 let (url, _handle) = start_test_server().await;
4168 let ctx = test_producer_ctx();
4169
4170 let component =
4171 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4172 let endpoint_ctx = NoOpComponentContext;
4173 let endpoint = component
4174 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4175 .unwrap();
4176 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4177
4178 let exchange = Exchange::new(Message::default());
4179 let result = producer.oneshot(exchange).await.unwrap();
4180
4181 let status = result
4182 .input
4183 .header("CamelHttpResponseCode")
4184 .and_then(|v| v.as_u64())
4185 .unwrap();
4186 assert_eq!(status, 200);
4187 }
4188
4189 #[tokio::test]
4194 async fn test_http_producer_blocks_metadata_endpoint() {
4195 use tower::ServiceExt;
4196
4197 let ctx = test_producer_ctx();
4198 let component = HttpComponent::new();
4199 let endpoint_ctx = NoOpComponentContext;
4200 let endpoint = component
4201 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4202 .unwrap();
4203 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4204
4205 let mut exchange = Exchange::new(Message::default());
4206 exchange.input.set_header(
4207 "CamelHttpUri",
4208 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4209 );
4210
4211 let result = producer.oneshot(exchange).await;
4212 assert!(result.is_err(), "Should block AWS metadata endpoint");
4213
4214 let err = result.unwrap_err();
4215 assert!(
4216 err.to_string().contains("Private IP"),
4217 "Error should mention private IP blocking, got: {}",
4218 err
4219 );
4220 }
4221
4222 #[test]
4223 fn test_ssrf_config_defaults() {
4224 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4225 assert!(
4226 !config.allow_internal,
4227 "Private IPs should be blocked by default"
4228 );
4229 assert!(
4230 config.blocked_hosts.is_empty(),
4231 "Blocked hosts should be empty by default"
4232 );
4233 }
4234
4235 #[test]
4236 fn test_ssrf_config_allow_internal() {
4237 let config =
4238 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4239 assert!(
4240 config.allow_internal,
4241 "Private IPs should be allowed when explicitly set"
4242 );
4243 }
4244
4245 #[test]
4246 fn test_ssrf_config_blocked_hosts() {
4247 let config = HttpEndpointConfig::from_uri(
4248 "http://example.com/api?blockedHosts=evil.com,malware.net",
4249 )
4250 .unwrap();
4251 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4252 }
4253
4254 #[tokio::test]
4255 async fn test_http_producer_blocks_localhost() {
4256 use tower::ServiceExt;
4257
4258 let ctx = test_producer_ctx();
4259 let component = HttpComponent::new();
4260 let endpoint_ctx = NoOpComponentContext;
4261 let endpoint = component
4262 .create_endpoint("http://example.com/api", &endpoint_ctx)
4263 .unwrap();
4264 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4265
4266 let mut exchange = Exchange::new(Message::default());
4267 exchange.input.set_header(
4268 "CamelHttpUri",
4269 serde_json::Value::String("http://localhost:8080/internal".to_string()),
4270 );
4271
4272 let result = producer.oneshot(exchange).await;
4273 assert!(result.is_err(), "Should block localhost");
4274 }
4275
4276 #[tokio::test]
4277 async fn test_http_producer_blocks_loopback_ip() {
4278 use tower::ServiceExt;
4279
4280 let ctx = test_producer_ctx();
4281 let component = HttpComponent::new();
4282 let endpoint_ctx = NoOpComponentContext;
4283 let endpoint = component
4284 .create_endpoint("http://example.com/api", &endpoint_ctx)
4285 .unwrap();
4286 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4287
4288 let mut exchange = Exchange::new(Message::default());
4289 exchange.input.set_header(
4290 "CamelHttpUri",
4291 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4292 );
4293
4294 let result = producer.oneshot(exchange).await;
4295 assert!(result.is_err(), "Should block loopback IP");
4296 }
4297
4298 #[tokio::test]
4299 async fn test_http_producer_allows_private_ip_when_enabled() {
4300 use tower::ServiceExt;
4301
4302 let ctx = test_producer_ctx();
4303 let component = HttpComponent::new();
4304 let endpoint_ctx = NoOpComponentContext;
4305 let endpoint = component
4308 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4309 .unwrap();
4310 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4311
4312 let exchange = Exchange::new(Message::default());
4313
4314 let result = producer.oneshot(exchange).await;
4317 if let Err(ref e) = result {
4319 let err_str = e.to_string();
4320 assert!(
4321 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4322 "Should not be SSRF error, got: {}",
4323 err_str
4324 );
4325 }
4326 }
4327
4328 #[test]
4333 fn test_http_server_config_parse() {
4334 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4335 assert_eq!(cfg.host, "0.0.0.0");
4336 assert_eq!(cfg.port, 8080);
4337 assert_eq!(cfg.path, "/orders");
4338 assert_eq!(cfg.max_inflight_requests, 1024);
4339 }
4340
4341 #[test]
4342 fn test_http_server_config_scheme() {
4343 assert_eq!(HttpServerConfig::scheme(), "http");
4345 }
4346
4347 #[test]
4348 fn test_http_server_config_from_components() {
4349 let components = camel_component_api::UriComponents {
4351 scheme: "https".to_string(),
4352 path: "//0.0.0.0:8443/api".to_string(),
4353 params: std::collections::HashMap::from([
4354 ("maxRequestBody".to_string(), "5242880".to_string()),
4355 ("maxInflightRequests".to_string(), "7".to_string()),
4356 ]),
4357 };
4358 let cfg = HttpServerConfig::from_components(components).unwrap();
4359 assert_eq!(cfg.host, "0.0.0.0");
4360 assert_eq!(cfg.port, 8443);
4361 assert_eq!(cfg.path, "/api");
4362 assert_eq!(cfg.max_request_body, 5242880);
4363 assert_eq!(cfg.max_inflight_requests, 7);
4364 }
4365
4366 #[test]
4367 fn test_http_server_config_default_path() {
4368 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4369 assert_eq!(cfg.path, "/");
4370 }
4371
4372 #[test]
4373 fn test_http_server_config_wrong_scheme() {
4374 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4375 }
4376
4377 #[test]
4378 fn test_http_server_config_invalid_port() {
4379 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4380 }
4381
4382 #[test]
4383 fn test_http_server_config_default_port_by_scheme() {
4384 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4386 assert_eq!(cfg_http.port, 80);
4387
4388 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4390 assert_eq!(cfg_https.port, 443);
4391 }
4392
4393 #[test]
4394 fn test_request_envelope_and_reply_are_send() {
4395 fn assert_send<T: Send>() {}
4396 assert_send::<RequestEnvelope>();
4397 assert_send::<HttpReply>();
4398 }
4399
4400 #[test]
4405 fn test_server_registry_global_is_singleton() {
4406 let r1 = ServerRegistry::global();
4407 let r2 = ServerRegistry::global();
4408 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4409 }
4410
4411 #[allow(clippy::await_holding_lock)]
4412 #[tokio::test]
4413 async fn test_concurrent_get_or_spawn_returns_same_registry() {
4414 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4415 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4416 let port = listener.local_addr().unwrap().port();
4417 drop(listener);
4418
4419 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4420 Arc::new(std::sync::Mutex::new(Vec::new()));
4421
4422 let mut handles = Vec::new();
4423 for _ in 0..4 {
4424 let results = results.clone();
4425 handles.push(tokio::spawn(async move {
4426 let registry = ServerRegistry::global()
4427 .get_or_spawn(
4428 "127.0.0.1",
4429 port,
4430 2 * 1024 * 1024,
4431 10 * 1024 * 1024,
4432 1024,
4433 test_rt(),
4434 "test-route".into(),
4435 None,
4436 )
4437 .await
4438 .unwrap();
4439 results.lock().unwrap().push(registry);
4440 }));
4441 }
4442
4443 for h in handles {
4444 h.await.unwrap();
4445 }
4446
4447 let registries = results.lock().unwrap();
4448 assert_eq!(registries.len(), 4);
4449 for i in 1..registries.len() {
4450 assert!(
4451 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
4452 "all concurrent callers should get same route registry"
4453 );
4454 }
4455 }
4456
4457 #[test]
4458 fn test_server_registry_distinguishes_host_and_port() {
4459 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4460 let rt = tokio::runtime::Runtime::new().expect("runtime");
4461 rt.block_on(async {
4462 let registry = ServerRegistry::global();
4463 let d1 = registry
4467 .get_or_spawn(
4468 "127.0.0.1",
4469 0,
4470 1024 * 1024,
4471 10 * 1024 * 1024,
4472 1024,
4473 test_rt(),
4474 "test-route-1".into(),
4475 None,
4476 )
4477 .await;
4478 let d2 = registry
4479 .get_or_spawn(
4480 "0.0.0.0",
4481 0,
4482 1024 * 1024,
4483 10 * 1024 * 1024,
4484 1024,
4485 test_rt(),
4486 "test-route-2".into(),
4487 None,
4488 )
4489 .await;
4490 assert!(d1.is_ok());
4491 assert!(d2.is_ok());
4492 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4493 });
4494 }
4495
4496 #[allow(clippy::await_holding_lock)]
4497 #[tokio::test]
4498 async fn test_shared_server_max_request_body_policy_is_deterministic() {
4499 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4500 let registry = ServerRegistry::global();
4501 let d1 = registry
4503 .get_or_spawn(
4504 "127.0.0.1",
4505 9991,
4506 1024 * 1024,
4507 10 * 1024 * 1024,
4508 1024,
4509 test_rt(),
4510 "test-route".into(),
4511 None,
4512 )
4513 .await;
4514 assert!(d1.is_ok());
4515
4516 let d2 = registry
4519 .get_or_spawn(
4520 "127.0.0.1",
4521 9991,
4522 2 * 1024 * 1024,
4523 10 * 1024 * 1024,
4524 1024,
4525 test_rt(),
4526 "test-route-2".into(),
4527 None,
4528 )
4529 .await;
4530 assert!(d2.is_err());
4531 let err = d2.unwrap_err();
4532 assert!(
4533 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4534 "Expected incompatible maxRequestBody error, got: {}",
4535 err
4536 );
4537 }
4538
4539 #[test]
4540 fn test_server_registry_reset_clears_entries() {
4541 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4542 let rt = tokio::runtime::Runtime::new().expect("runtime");
4543 rt.block_on(async {
4544 let d1 = ServerRegistry::global()
4546 .get_or_spawn(
4547 "127.0.0.1",
4548 9992,
4549 1024 * 1024,
4550 10 * 1024 * 1024,
4551 1024,
4552 test_rt(),
4553 "test-route".into(),
4554 None,
4555 )
4556 .await;
4557 assert!(d1.is_ok());
4558
4559 let guard = ServerRegistry::global().inner.lock().expect("lock");
4561 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4562 drop(guard);
4563
4564 ServerRegistry::reset();
4566
4567 let guard = ServerRegistry::global().inner.lock().expect("lock");
4569 assert!(
4570 guard.is_empty(),
4571 "registry should be empty after reset, has {} entries",
4572 guard.len()
4573 );
4574 });
4575 }
4576
4577 #[tokio::test]
4578 async fn registry_rejects_tls_on_plain_port() {
4579 ServerRegistry::reset();
4580 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4581
4582 let _r1 = ServerRegistry::global()
4584 .get_or_spawn(
4585 "127.0.0.1",
4586 0,
4587 1024,
4588 1024,
4589 16,
4590 Arc::clone(&rt),
4591 "route-1".into(),
4592 None, )
4594 .await;
4595
4596 let result = ServerRegistry::global()
4598 .get_or_spawn(
4599 "127.0.0.1",
4600 0,
4601 1024,
4602 1024,
4603 16,
4604 Arc::clone(&rt),
4605 "route-2".into(),
4606 Some(crate::config::ServerTlsConfig {
4607 cert_path: "/x.pem".into(),
4608 key_path: "/y.pem".into(),
4609 }),
4610 )
4611 .await;
4612 assert!(result.is_err(), "must reject TLS on plain port");
4613 }
4614
4615 #[allow(clippy::await_holding_lock)]
4620 #[tokio::test]
4621 async fn test_unregister_last_http_route_keeps_server_alive() {
4622 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4623 ServerRegistry::reset();
4624 let registry = ServerRegistry::global();
4625
4626 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4627 let port = listener.local_addr().unwrap().port();
4628 drop(listener); let rt = test_rt();
4630
4631 let _r1 = registry
4634 .get_or_spawn(
4635 "127.0.0.1",
4636 port,
4637 1024 * 1024,
4638 10 * 1024 * 1024,
4639 16,
4640 rt.clone(),
4641 "test-route-1".into(),
4642 None,
4643 )
4644 .await
4645 .unwrap();
4646 let _r2 = registry
4647 .get_or_spawn(
4648 "127.0.0.1",
4649 port,
4650 1024 * 1024,
4651 10 * 1024 * 1024,
4652 16,
4653 rt,
4654 "test-route-2".into(),
4655 None,
4656 )
4657 .await
4658 .unwrap();
4659
4660 let key = ("127.0.0.1".to_string(), port);
4661 let cell = {
4662 let guard = registry.inner.lock().expect("lock");
4663 guard.get(&key).expect("entry should exist").clone()
4664 };
4665
4666 registry.unregister("127.0.0.1", port).await;
4668 {
4669 let handle = cell
4670 .get()
4671 .expect("handle should still exist after first unregister");
4672 assert!(
4673 !handle.monitor_task.is_finished(),
4674 "monitor task should still be alive after first unregister"
4675 );
4676 }
4677
4678 registry.unregister("127.0.0.1", port).await;
4680 tokio::time::sleep(Duration::from_millis(20)).await;
4681 {
4682 let handle = cell
4683 .get()
4684 .expect("handle should still exist after last unregister");
4685 assert!(
4686 !handle.monitor_task.is_finished(),
4687 "monitor task should still be alive — server is process-lifetime"
4688 );
4689 }
4690
4691 {
4693 let guard = registry.inner.lock().expect("lock");
4694 assert!(
4695 guard.get(&key).is_some(),
4696 "entry should remain in registry — server kept alive for restart"
4697 );
4698 }
4699 }
4700
4701 #[tokio::test]
4706 async fn test_dispatch_handler_returns_404_for_unknown_path() {
4707 let registry = HttpRouteRegistry::new();
4708 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4710 let port = listener.local_addr().unwrap().port();
4711 tokio::spawn(run_axum_server(
4712 listener,
4713 registry,
4714 2 * 1024 * 1024,
4715 10 * 1024 * 1024,
4716 Arc::new(tokio::sync::Semaphore::new(1024)),
4717 test_rt(),
4718 "test-route".into(),
4719 ));
4720
4721 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4723
4724 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4725 .await
4726 .unwrap();
4727 assert_eq!(resp.status().as_u16(), 404);
4728 }
4729
4730 #[tokio::test]
4735 async fn test_http_consumer_start_registers_path() {
4736 use camel_component_api::ConsumerContext;
4737
4738 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4740 let port = listener.local_addr().unwrap().port();
4741 drop(listener); let consumer_cfg = HttpServerConfig {
4744 scheme: "http".to_string(),
4745 host: "127.0.0.1".to_string(),
4746 port,
4747 path: "/ping".to_string(),
4748 max_request_body: 2 * 1024 * 1024,
4749 max_response_body: 10 * 1024 * 1024,
4750 max_inflight_requests: 1024,
4751 method: None,
4752 tls_config: None,
4753 };
4754 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4755
4756 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4757 let token = tokio_util::sync::CancellationToken::new();
4758 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4759
4760 tokio::spawn(async move {
4761 consumer.start(ctx).await.unwrap();
4762 });
4763
4764 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4765
4766 let client = reqwest::Client::new();
4767 let resp_future = client
4768 .post(format!("http://127.0.0.1:{port}/ping"))
4769 .body("hello world")
4770 .send();
4771
4772 let (http_result, _) = tokio::join!(resp_future, async {
4773 if let Some(mut envelope) = rx.recv().await {
4774 envelope.exchange.input.set_header(
4776 "CamelHttpResponseCode",
4777 serde_json::Value::Number(201.into()),
4778 );
4779 if let Some(reply_tx) = envelope.reply_tx {
4780 let _ = reply_tx.send(Ok(envelope.exchange));
4781 }
4782 }
4783 });
4784
4785 let resp = http_result.unwrap();
4786 assert_eq!(resp.status().as_u16(), 201);
4787
4788 token.cancel();
4789 }
4790
4791 #[test]
4794 fn test_http_consumer_startup_mode_is_explicit() {
4795 use camel_component_api::ConsumerStartupMode;
4796 let consumer_cfg = HttpServerConfig {
4797 scheme: "http".to_string(),
4798 host: "127.0.0.1".to_string(),
4799 port: 0,
4800 path: "/x".to_string(),
4801 max_request_body: 2 * 1024 * 1024,
4802 max_response_body: 10 * 1024 * 1024,
4803 max_inflight_requests: 1024,
4804 method: None,
4805 tls_config: None,
4806 };
4807 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
4808 assert_eq!(
4809 consumer.startup_mode(),
4810 ConsumerStartupMode::Explicit,
4811 "HttpConsumer must opt into Explicit startup"
4812 );
4813 }
4814
4815 #[allow(clippy::await_holding_lock)]
4821 #[tokio::test]
4822 async fn test_http_consumer_emits_mark_ready_after_bind() {
4823 use camel_component_api::{ConsumerContext, StartupSignal};
4824
4825 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4826
4827 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4828 let port = listener.local_addr().unwrap().port();
4829 drop(listener);
4830
4831 let consumer_cfg = HttpServerConfig {
4832 scheme: "http".to_string(),
4833 host: "127.0.0.1".to_string(),
4834 port,
4835 path: "/ready-probe".to_string(),
4836 max_request_body: 2 * 1024 * 1024,
4837 max_response_body: 10 * 1024 * 1024,
4838 max_inflight_requests: 1024,
4839 method: None,
4840 tls_config: None,
4841 };
4842 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4843
4844 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4845 let token = tokio_util::sync::CancellationToken::new();
4846 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
4847
4848 let (signal, startup_rx) = StartupSignal::pair();
4850 let ctx = ctx.with_startup(signal);
4851
4852 tokio::spawn(async move {
4855 let _ = consumer.start(ctx).await;
4856 });
4857
4858 let result =
4863 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
4864 .await
4865 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
4866 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
4867
4868 token.cancel();
4870 }
4871
4872 #[tokio::test]
4873 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
4874 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4875
4876 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4877 let port = listener.local_addr().unwrap().port();
4878 drop(listener);
4879
4880 let consumer_cfg = HttpServerConfig {
4881 scheme: "http".to_string(),
4882 host: "127.0.0.1".to_string(),
4883 port,
4884 path: "/saturation".to_string(),
4885 max_request_body: 2 * 1024 * 1024,
4886 max_response_body: 10 * 1024 * 1024,
4887 max_inflight_requests: 1,
4888 method: None,
4889 tls_config: None,
4890 };
4891 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4892
4893 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4894 let token = tokio_util::sync::CancellationToken::new();
4895 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4896 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4897 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4898
4899 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
4900 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
4901
4902 tokio::spawn(async move {
4903 let mut first_seen_tx = Some(first_seen_tx);
4904 let mut unblock_first_rx = Some(unblock_first_rx);
4905
4906 while let Some(envelope) = rx.recv().await {
4907 if let Some(tx) = first_seen_tx.take() {
4908 let _ = tx.send(());
4909 if let Some(rx_unblock) = unblock_first_rx.take() {
4910 let _ = rx_unblock.await;
4911 }
4912 }
4913
4914 if let Some(reply_tx) = envelope.reply_tx {
4915 let _ = reply_tx.send(Ok(envelope.exchange));
4916 }
4917 }
4918 });
4919
4920 let client = reqwest::Client::new();
4921 let first_req = {
4922 let client = client.clone();
4923 async move {
4924 client
4925 .get(format!("http://127.0.0.1:{port}/saturation"))
4926 .send()
4927 .await
4928 .unwrap()
4929 }
4930 };
4931
4932 let first_handle = tokio::spawn(first_req);
4933 first_seen_rx.await.unwrap();
4934
4935 let second_resp = client
4936 .get(format!("http://127.0.0.1:{port}/saturation"))
4937 .send()
4938 .await
4939 .unwrap();
4940
4941 assert_eq!(second_resp.status().as_u16(), 503);
4942
4943 let _ = unblock_first_tx.send(());
4944 let first_resp = first_handle.await.unwrap();
4945 assert_eq!(first_resp.status().as_u16(), 200);
4946
4947 token.cancel();
4948 }
4949
4950 #[tokio::test]
4951 #[allow(clippy::await_holding_lock)]
4952 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
4953 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4954
4955 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4956
4957 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4958 let port = listener.local_addr().unwrap().port();
4959 drop(listener);
4960
4961 let consumer_cfg = HttpServerConfig {
4962 scheme: "http".to_string(),
4963 host: "127.0.0.1".to_string(),
4964 port,
4965 path: "/limit-bytes".to_string(),
4966 max_request_body: 2 * 1024 * 1024,
4967 max_response_body: 16,
4968 max_inflight_requests: 1024,
4969 method: None,
4970 tls_config: None,
4971 };
4972 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4973
4974 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4975 let token = tokio_util::sync::CancellationToken::new();
4976 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4977 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4978 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4979
4980 let client = reqwest::Client::new();
4981 let send_fut = client
4982 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
4983 .send();
4984
4985 let (http_result, _) = tokio::join!(send_fut, async {
4986 if let Some(mut envelope) = rx.recv().await {
4987 envelope.exchange.input.body =
4988 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
4989 if let Some(reply_tx) = envelope.reply_tx {
4990 let _ = reply_tx.send(Ok(envelope.exchange));
4991 }
4992 }
4993 });
4994
4995 let resp = http_result.unwrap();
4996 assert_eq!(resp.status().as_u16(), 500);
4997 let body = resp.text().await.unwrap();
4998 assert_eq!(body, "Response body exceeds configured limit");
4999 token.cancel();
5000 }
5001
5002 #[tokio::test]
5003 #[allow(clippy::await_holding_lock)]
5004 async fn test_http_consumer_enforces_max_response_body_for_json() {
5005 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5006
5007 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5008
5009 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5010 let port = listener.local_addr().unwrap().port();
5011 drop(listener);
5012
5013 let consumer_cfg = HttpServerConfig {
5014 scheme: "http".to_string(),
5015 host: "127.0.0.1".to_string(),
5016 port,
5017 path: "/limit-json".to_string(),
5018 max_request_body: 2 * 1024 * 1024,
5019 max_response_body: 16,
5020 max_inflight_requests: 1024,
5021 method: None,
5022 tls_config: None,
5023 };
5024 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5025
5026 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5027 let token = tokio_util::sync::CancellationToken::new();
5028 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5029 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5030 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5031
5032 let client = reqwest::Client::new();
5033 let send_fut = client
5034 .get(format!("http://127.0.0.1:{port}/limit-json"))
5035 .send();
5036
5037 let (http_result, _) = tokio::join!(send_fut, async {
5038 if let Some(mut envelope) = rx.recv().await {
5039 envelope.exchange.input.body = camel_component_api::Body::Json(
5040 serde_json::json!({"message":"this response is bigger than sixteen"}),
5041 );
5042 if let Some(reply_tx) = envelope.reply_tx {
5043 let _ = reply_tx.send(Ok(envelope.exchange));
5044 }
5045 }
5046 });
5047
5048 let resp = http_result.unwrap();
5049 assert_eq!(resp.status().as_u16(), 500);
5050 let body = resp.text().await.unwrap();
5051 assert_eq!(body, "Response body exceeds configured limit");
5052 token.cancel();
5053 }
5054
5055 #[tokio::test]
5056 #[allow(clippy::await_holding_lock)]
5057 async fn test_http_consumer_enforces_max_response_body_for_xml() {
5058 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5059
5060 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5061
5062 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5063 let port = listener.local_addr().unwrap().port();
5064 drop(listener);
5065
5066 let consumer_cfg = HttpServerConfig {
5067 scheme: "http".to_string(),
5068 host: "127.0.0.1".to_string(),
5069 port,
5070 path: "/limit-xml".to_string(),
5071 max_request_body: 2 * 1024 * 1024,
5072 max_response_body: 16,
5073 max_inflight_requests: 1024,
5074 method: None,
5075 tls_config: None,
5076 };
5077 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5078
5079 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5080 let token = tokio_util::sync::CancellationToken::new();
5081 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5082 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5083 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5084
5085 let client = reqwest::Client::new();
5086 let send_fut = client
5087 .get(format!("http://127.0.0.1:{port}/limit-xml"))
5088 .send();
5089
5090 let (http_result, _) = tokio::join!(send_fut, async {
5091 if let Some(mut envelope) = rx.recv().await {
5092 envelope.exchange.input.body = camel_component_api::Body::Xml(
5093 "<root><value>way-too-large</value></root>".into(),
5094 );
5095 if let Some(reply_tx) = envelope.reply_tx {
5096 let _ = reply_tx.send(Ok(envelope.exchange));
5097 }
5098 }
5099 });
5100
5101 let resp = http_result.unwrap();
5102 assert_eq!(resp.status().as_u16(), 500);
5103 let body = resp.text().await.unwrap();
5104 assert_eq!(body, "Response body exceeds configured limit");
5105 token.cancel();
5106 }
5107
5108 #[tokio::test]
5109 #[allow(clippy::await_holding_lock)]
5110 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5111 use camel_component_api::{
5112 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5113 };
5114 use futures::stream;
5115
5116 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5117
5118 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5119 let port = listener.local_addr().unwrap().port();
5120 drop(listener);
5121
5122 let consumer_cfg = HttpServerConfig {
5123 scheme: "http".to_string(),
5124 host: "0.0.0.0".to_string(),
5125 port,
5126 path: "/limit-stream".to_string(),
5127 max_request_body: 2 * 1024 * 1024,
5128 max_response_body: 16,
5129 max_inflight_requests: 1024,
5130 method: None,
5131 tls_config: None,
5132 };
5133 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5134
5135 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5136 let token = tokio_util::sync::CancellationToken::new();
5137 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5138 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5139 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5140
5141 let client = reqwest::Client::new();
5142 let send_fut = client
5143 .get(format!("http://127.0.0.1:{port}/limit-stream"))
5144 .send();
5145
5146 let (http_result, _) = tokio::join!(send_fut, async {
5147 if let Some(mut envelope) = rx.recv().await {
5148 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5149 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5150 let stream = Box::pin(stream::iter(chunks));
5151 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5152 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5153 metadata: StreamMetadata {
5154 size_hint: Some(32),
5155 content_type: Some("application/octet-stream".into()),
5156 origin: None,
5157 },
5158 });
5159 if let Some(reply_tx) = envelope.reply_tx {
5160 let _ = reply_tx.send(Ok(envelope.exchange));
5161 }
5162 }
5163 });
5164
5165 let resp = http_result.unwrap();
5166 assert_eq!(resp.status().as_u16(), 200);
5167 let body = resp.bytes().await.unwrap();
5168 assert_eq!(body.len(), 32);
5169 token.cancel();
5170 }
5171
5172 #[tokio::test]
5177 #[allow(clippy::await_holding_lock)]
5178 async fn test_integration_single_consumer_round_trip() {
5179 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5180
5181 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5185
5186 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5188 let port = listener.local_addr().unwrap().port();
5189 drop(listener); let component = HttpComponent::new();
5192 let endpoint_ctx = NoOpComponentContext;
5193 let endpoint = component
5194 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5195 .unwrap();
5196 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5197
5198 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5199 let token = tokio_util::sync::CancellationToken::new();
5200 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5201
5202 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5203 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5204
5205 let client = reqwest::Client::new();
5206 let send_fut = client
5207 .post(format!("http://127.0.0.1:{port}/echo"))
5208 .header("Content-Type", "text/plain")
5209 .body("ping")
5210 .send();
5211
5212 let (http_result, _) = tokio::join!(send_fut, async {
5213 if let Some(mut envelope) = rx.recv().await {
5214 assert_eq!(
5215 envelope.exchange.input.header("CamelHttpMethod"),
5216 Some(&serde_json::Value::String("POST".into()))
5217 );
5218 assert_eq!(
5219 envelope.exchange.input.header("CamelHttpPath"),
5220 Some(&serde_json::Value::String("/echo".into()))
5221 );
5222 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5223 if let Some(reply_tx) = envelope.reply_tx {
5224 let _ = reply_tx.send(Ok(envelope.exchange));
5225 }
5226 }
5227 });
5228
5229 let resp = http_result.unwrap();
5230 assert_eq!(resp.status().as_u16(), 200);
5231 let body = resp.text().await.unwrap();
5232 assert_eq!(body, "pong");
5233
5234 token.cancel();
5235 }
5236
5237 #[tokio::test]
5238 #[allow(clippy::await_holding_lock)]
5239 async fn test_integration_two_consumers_shared_port() {
5240 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5241
5242 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5243
5244 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5246 let port = listener.local_addr().unwrap().port();
5247 drop(listener);
5248
5249 let component = HttpComponent::new();
5250 let endpoint_ctx = NoOpComponentContext;
5251
5252 let endpoint_a = component
5254 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5255 .unwrap();
5256 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5257
5258 let endpoint_b = component
5260 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5261 .unwrap();
5262 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5263
5264 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5265 let token_a = tokio_util::sync::CancellationToken::new();
5266 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5267
5268 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5269 let token_b = tokio_util::sync::CancellationToken::new();
5270 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5271
5272 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5273 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5274 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5275
5276 let client = reqwest::Client::new();
5277
5278 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5280 let (resp_hello, _) = tokio::join!(fut_hello, async {
5281 if let Some(mut envelope) = rx_a.recv().await {
5282 envelope.exchange.input.body =
5283 camel_component_api::Body::Text("hello-response".to_string());
5284 if let Some(reply_tx) = envelope.reply_tx {
5285 let _ = reply_tx.send(Ok(envelope.exchange));
5286 }
5287 }
5288 });
5289
5290 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5292 let (resp_world, _) = tokio::join!(fut_world, async {
5293 if let Some(mut envelope) = rx_b.recv().await {
5294 envelope.exchange.input.body =
5295 camel_component_api::Body::Text("world-response".to_string());
5296 if let Some(reply_tx) = envelope.reply_tx {
5297 let _ = reply_tx.send(Ok(envelope.exchange));
5298 }
5299 }
5300 });
5301
5302 let body_a = resp_hello.unwrap().text().await.unwrap();
5303 let body_b = resp_world.unwrap().text().await.unwrap();
5304
5305 assert_eq!(body_a, "hello-response");
5306 assert_eq!(body_b, "world-response");
5307
5308 token_a.cancel();
5309 token_b.cancel();
5310 }
5311
5312 #[tokio::test]
5313 #[allow(clippy::await_holding_lock)]
5314 async fn test_integration_unregistered_path_returns_404() {
5315 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5316
5317 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5318
5319 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5321 let port = listener.local_addr().unwrap().port();
5322 drop(listener);
5323
5324 let component = HttpComponent::new();
5325 let endpoint_ctx = NoOpComponentContext;
5326 let endpoint = component
5327 .create_endpoint(
5328 &format!("http://127.0.0.1:{port}/registered"),
5329 &endpoint_ctx,
5330 )
5331 .unwrap();
5332 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5333
5334 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5335 let token = tokio_util::sync::CancellationToken::new();
5336 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5337
5338 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5339
5340 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5342 loop {
5343 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5344 .await
5345 .is_ok()
5346 {
5347 break;
5348 }
5349 if std::time::Instant::now() >= deadline {
5350 panic!("HTTP server did not start within 5s on port {port}");
5351 }
5352 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5353 }
5354
5355 let client = reqwest::Client::new();
5356 let resp = client
5357 .get(format!("http://127.0.0.1:{port}/not-there"))
5358 .send()
5359 .await
5360 .unwrap();
5361 assert_eq!(resp.status().as_u16(), 404);
5362
5363 token.cancel();
5364 }
5365
5366 #[test]
5367 fn test_http_consumer_declares_concurrent() {
5368 use camel_component_api::ConcurrencyModel;
5369
5370 let config = HttpServerConfig {
5371 scheme: "http".to_string(),
5372 host: "127.0.0.1".to_string(),
5373 port: 19999,
5374 path: "/test".to_string(),
5375 max_request_body: 2 * 1024 * 1024,
5376 max_response_body: 10 * 1024 * 1024,
5377 max_inflight_requests: 1024,
5378 method: None,
5379 tls_config: None,
5380 };
5381 let consumer = HttpConsumer::new(config, test_rt());
5382 assert_eq!(
5383 consumer.concurrency_model(),
5384 ConcurrencyModel::Concurrent { max: None }
5385 );
5386 }
5387
5388 #[test]
5389 fn server_config_parses_tls_cert_and_key() {
5390 let cfg = HttpServerConfig::from_uri(
5391 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5392 )
5393 .unwrap();
5394 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5395 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5396 }
5397
5398 #[test]
5399 fn server_config_no_tls_when_params_absent() {
5400 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5401 assert!(cfg.tls_config.is_none());
5402 }
5403
5404 #[tokio::test]
5409 async fn test_http_reply_body_stream_variant_exists() {
5410 use bytes::Bytes;
5411 use camel_component_api::CamelError;
5412 use futures::stream;
5413
5414 let chunks: Vec<Result<Bytes, CamelError>> =
5415 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5416 let stream = Box::pin(stream::iter(chunks));
5417 let reply_body = HttpReplyBody::Stream(stream);
5418 match reply_body {
5420 HttpReplyBody::Stream(_) => {}
5421 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5422 }
5423 }
5424
5425 #[cfg(feature = "otel")]
5430 mod otel_tests {
5431 use super::*;
5432 use camel_component_api::Message;
5433 use tower::ServiceExt;
5434
5435 #[tokio::test]
5436 async fn test_producer_injects_traceparent_header() {
5437 let (url, _handle) = start_test_server_with_header_capture().await;
5438 let ctx = test_producer_ctx();
5439
5440 let component = HttpComponent::new();
5441 let endpoint_ctx = NoOpComponentContext;
5442 let endpoint = component
5443 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5444 .unwrap();
5445 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5446
5447 let mut exchange = Exchange::new(Message::default());
5449 let mut headers = std::collections::HashMap::new();
5450 headers.insert(
5451 "traceparent".to_string(),
5452 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5453 );
5454 camel_otel::extract_into_exchange(&mut exchange, &headers);
5455
5456 let result = producer.oneshot(exchange).await.unwrap();
5457
5458 let status = result
5460 .input
5461 .header("CamelHttpResponseCode")
5462 .and_then(|v| v.as_u64())
5463 .unwrap();
5464 assert_eq!(status, 200);
5465
5466 let traceparent = result.input.header("X-Received-Traceparent");
5468 assert!(
5469 traceparent.is_some(),
5470 "traceparent header should have been sent"
5471 );
5472
5473 let traceparent_str = traceparent.unwrap().as_str().unwrap();
5474 let parts: Vec<&str> = traceparent_str.split('-').collect();
5476 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5477 assert_eq!(parts[0], "00", "version should be 00");
5478 assert_eq!(
5479 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5480 "trace-id should match"
5481 );
5482 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5483 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5484 }
5485
5486 #[tokio::test]
5487 async fn test_consumer_extracts_traceparent_header() {
5488 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5489
5490 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5492 let port = listener.local_addr().unwrap().port();
5493 drop(listener);
5494
5495 let component = HttpComponent::new();
5496 let endpoint_ctx = NoOpComponentContext;
5497 let endpoint = component
5498 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5499 .unwrap();
5500 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5501
5502 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5503 let token = tokio_util::sync::CancellationToken::new();
5504 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5505
5506 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5507 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5508
5509 let client = reqwest::Client::new();
5511 let send_fut = client
5512 .post(format!("http://127.0.0.1:{port}/trace"))
5513 .header(
5514 "traceparent",
5515 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5516 )
5517 .body("test")
5518 .send();
5519
5520 let (http_result, _) = tokio::join!(send_fut, async {
5521 if let Some(envelope) = rx.recv().await {
5522 let mut injected_headers = std::collections::HashMap::new();
5525 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5526
5527 assert!(
5528 injected_headers.contains_key("traceparent"),
5529 "Exchange should have traceparent after extraction"
5530 );
5531
5532 let traceparent = injected_headers.get("traceparent").unwrap();
5533 let parts: Vec<&str> = traceparent.split('-').collect();
5534 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5535 assert_eq!(
5536 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5537 "Trace ID should match the original traceparent header"
5538 );
5539
5540 if let Some(reply_tx) = envelope.reply_tx {
5541 let _ = reply_tx.send(Ok(envelope.exchange));
5542 }
5543 }
5544 });
5545
5546 let resp = http_result.unwrap();
5547 assert_eq!(resp.status().as_u16(), 200);
5548
5549 token.cancel();
5550 }
5551
5552 #[tokio::test]
5553 async fn test_consumer_extracts_mixed_case_traceparent_header() {
5554 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5555
5556 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5558 let port = listener.local_addr().unwrap().port();
5559 drop(listener);
5560
5561 let component = HttpComponent::new();
5562 let endpoint_ctx = NoOpComponentContext;
5563 let endpoint = component
5564 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5565 .unwrap();
5566 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5567
5568 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5569 let token = tokio_util::sync::CancellationToken::new();
5570 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5571
5572 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5573 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5574
5575 let client = reqwest::Client::new();
5577 let send_fut = client
5578 .post(format!("http://127.0.0.1:{port}/trace"))
5579 .header(
5580 "TraceParent",
5581 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5582 )
5583 .body("test")
5584 .send();
5585
5586 let (http_result, _) = tokio::join!(send_fut, async {
5587 if let Some(envelope) = rx.recv().await {
5588 let mut injected_headers = HashMap::new();
5591 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5592
5593 assert!(
5594 injected_headers.contains_key("traceparent"),
5595 "Exchange should have traceparent after extraction from mixed-case header"
5596 );
5597
5598 let traceparent = injected_headers.get("traceparent").unwrap();
5599 let parts: Vec<&str> = traceparent.split('-').collect();
5600 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5601 assert_eq!(
5602 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5603 "Trace ID should match the original mixed-case TraceParent header"
5604 );
5605
5606 if let Some(reply_tx) = envelope.reply_tx {
5607 let _ = reply_tx.send(Ok(envelope.exchange));
5608 }
5609 }
5610 });
5611
5612 let resp = http_result.unwrap();
5613 assert_eq!(resp.status().as_u16(), 200);
5614
5615 token.cancel();
5616 }
5617
5618 #[tokio::test]
5619 async fn test_producer_no_trace_context_no_crash() {
5620 let (url, _handle) = start_test_server().await;
5621 let ctx = test_producer_ctx();
5622
5623 let component = HttpComponent::new();
5624 let endpoint_ctx = NoOpComponentContext;
5625 let endpoint = component
5626 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5627 .unwrap();
5628 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5629
5630 let exchange = Exchange::new(Message::default());
5632
5633 let result = producer.oneshot(exchange).await.unwrap();
5635
5636 let status = result
5638 .input
5639 .header("CamelHttpResponseCode")
5640 .and_then(|v| v.as_u64())
5641 .unwrap();
5642 assert_eq!(status, 200);
5643 }
5644
5645 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5647 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5648 let addr = listener.local_addr().unwrap();
5649 let url = format!("http://127.0.0.1:{}", addr.port());
5650
5651 let handle = tokio::spawn(async move {
5652 loop {
5653 if let Ok((mut stream, _)) = listener.accept().await {
5654 tokio::spawn(async move {
5655 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5656 let mut buf = vec![0u8; 8192];
5657 let n = stream.read(&mut buf).await.unwrap_or(0);
5658 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5659
5660 let traceparent = request
5662 .lines()
5663 .find(|line| line.to_lowercase().starts_with("traceparent:"))
5664 .map(|line| {
5665 line.split(':')
5666 .nth(1)
5667 .map(|s| s.trim().to_string())
5668 .unwrap_or_default()
5669 })
5670 .unwrap_or_default();
5671
5672 let body =
5673 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5674 let response = format!(
5675 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5676 body.len(),
5677 traceparent,
5678 body
5679 );
5680 let _ = stream.write_all(response.as_bytes()).await;
5681 });
5682 }
5683 }
5684 });
5685
5686 (url, handle)
5687 }
5688 }
5689
5690 #[tokio::test]
5699 async fn test_request_body_arrives_as_stream() {
5700 use camel_component_api::Body;
5701 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5702
5703 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5704 let port = listener.local_addr().unwrap().port();
5705 drop(listener);
5706
5707 let component = HttpComponent::new();
5708 let endpoint_ctx = NoOpComponentContext;
5709 let endpoint = component
5710 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
5711 .unwrap();
5712 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5713
5714 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5715 let token = tokio_util::sync::CancellationToken::new();
5716 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5717
5718 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5719 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5720
5721 let client = reqwest::Client::new();
5722 let send_fut = client
5723 .post(format!("http://127.0.0.1:{port}/upload"))
5724 .body("hello streaming world")
5725 .send();
5726
5727 let (http_result, _) = tokio::join!(send_fut, async {
5728 if let Some(mut envelope) = rx.recv().await {
5729 assert!(
5731 matches!(envelope.exchange.input.body, Body::Stream(_)),
5732 "expected Body::Stream, got discriminant {:?}",
5733 std::mem::discriminant(&envelope.exchange.input.body)
5734 );
5735 let bytes = envelope
5737 .exchange
5738 .input
5739 .body
5740 .into_bytes(1024 * 1024)
5741 .await
5742 .unwrap();
5743 assert_eq!(&bytes[..], b"hello streaming world");
5744
5745 envelope.exchange.input.body = camel_component_api::Body::Empty;
5746 if let Some(reply_tx) = envelope.reply_tx {
5747 let _ = reply_tx.send(Ok(envelope.exchange));
5748 }
5749 }
5750 });
5751
5752 let resp = http_result.unwrap();
5753 assert_eq!(resp.status().as_u16(), 200);
5754
5755 token.cancel();
5756 }
5757
5758 #[tokio::test]
5763 async fn test_streaming_response_chunked() {
5764 use bytes::Bytes;
5765 use camel_component_api::Body;
5766 use camel_component_api::CamelError;
5767 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5768 use camel_component_api::{StreamBody, StreamMetadata};
5769 use futures::stream;
5770 use std::sync::Arc;
5771 use tokio::sync::Mutex;
5772
5773 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5774 let port = listener.local_addr().unwrap().port();
5775 drop(listener);
5776
5777 let component = HttpComponent::new();
5778 let endpoint_ctx = NoOpComponentContext;
5779 let endpoint = component
5780 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
5781 .unwrap();
5782 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5783
5784 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5785 let token = tokio_util::sync::CancellationToken::new();
5786 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5787
5788 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5789 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5790
5791 let client = reqwest::Client::new();
5792 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
5793
5794 let (http_result, _) = tokio::join!(send_fut, async {
5795 if let Some(mut envelope) = rx.recv().await {
5796 let chunks: Vec<Result<Bytes, CamelError>> =
5798 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
5799 let stream = Box::pin(stream::iter(chunks));
5800 envelope.exchange.input.body = Body::Stream(StreamBody {
5801 stream: Arc::new(Mutex::new(Some(stream))),
5802 metadata: StreamMetadata::default(),
5803 });
5804 if let Some(reply_tx) = envelope.reply_tx {
5805 let _ = reply_tx.send(Ok(envelope.exchange));
5806 }
5807 }
5808 });
5809
5810 let resp = http_result.unwrap();
5811 assert_eq!(resp.status().as_u16(), 200);
5812 let body = resp.text().await.unwrap();
5813 assert_eq!(body, "chunk1chunk2");
5814
5815 token.cancel();
5816 }
5817
5818 #[tokio::test]
5823 async fn test_413_when_content_length_exceeds_limit() {
5824 use camel_component_api::ConsumerContext;
5825
5826 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5827 let port = listener.local_addr().unwrap().port();
5828 drop(listener);
5829
5830 let component = HttpComponent::new();
5832 let endpoint_ctx = NoOpComponentContext;
5833 let endpoint = component
5834 .create_endpoint(
5835 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
5836 &endpoint_ctx,
5837 )
5838 .unwrap();
5839 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5840
5841 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5842 let token = tokio_util::sync::CancellationToken::new();
5843 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5844
5845 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5846 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5847
5848 let client = reqwest::Client::new();
5849 let resp = client
5850 .post(format!("http://127.0.0.1:{port}/upload"))
5851 .header("Content-Length", "1000") .body("x".repeat(1000))
5853 .send()
5854 .await
5855 .unwrap();
5856
5857 assert_eq!(resp.status().as_u16(), 413);
5858
5859 token.cancel();
5860 }
5861
5862 #[tokio::test]
5866 async fn test_chunked_upload_without_content_length_bypasses_limit() {
5867 use bytes::Bytes;
5868 use camel_component_api::Body;
5869 use camel_component_api::ConsumerContext;
5870 use futures::stream;
5871
5872 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5873 let port = listener.local_addr().unwrap().port();
5874 drop(listener);
5875
5876 let component = HttpComponent::new();
5878 let endpoint_ctx = NoOpComponentContext;
5879 let endpoint = component
5880 .create_endpoint(
5881 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
5882 &endpoint_ctx,
5883 )
5884 .unwrap();
5885 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5886
5887 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5888 let token = tokio_util::sync::CancellationToken::new();
5889 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5890
5891 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5892 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5893
5894 let client = reqwest::Client::new();
5895
5896 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
5900 Ok(Bytes::from("y".repeat(50))),
5901 Ok(Bytes::from("y".repeat(50))),
5902 ];
5903 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
5904 let send_fut = client
5905 .post(format!("http://127.0.0.1:{port}/upload"))
5906 .body(stream_body)
5907 .send();
5908
5909 let consumer_fut = async {
5910 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
5912 Ok(Some(mut envelope)) => {
5913 assert!(
5914 matches!(envelope.exchange.input.body, Body::Stream(_)),
5915 "expected Body::Stream"
5916 );
5917 envelope.exchange.input.body = camel_component_api::Body::Empty;
5918 if let Some(reply_tx) = envelope.reply_tx {
5919 let _ = reply_tx.send(Ok(envelope.exchange));
5920 }
5921 }
5922 Ok(None) => panic!("consumer channel closed unexpectedly"),
5923 Err(_) => {
5924 }
5927 }
5928 };
5929
5930 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
5931
5932 let resp = http_result.unwrap();
5933 assert_ne!(
5935 resp.status().as_u16(),
5936 413,
5937 "chunked upload must not be rejected by maxRequestBody"
5938 );
5939 assert_eq!(resp.status().as_u16(), 200);
5940
5941 token.cancel();
5942 }
5943
5944 #[test]
5945 fn test_is_private_ip_ranges() {
5946 use camel_api::is_ssrf_blocked_ip;
5947 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(
5966 &"2001:4860:4860::8888".parse().unwrap()
5967 )); }
5969
5970 #[test]
5971 fn test_title_case_header() {
5972 assert_eq!(title_case_header("content-type"), "Content-Type");
5973 assert_eq!(title_case_header("authorization"), "Authorization");
5974 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
5975 assert_eq!(title_case_header("host"), "Host");
5976 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
5977 assert_eq!(title_case_header("single"), "Single");
5978 assert_eq!(title_case_header(""), "");
5979 }
5980
5981 #[test]
5982 fn test_resolve_url_combines_path_and_query_sources() {
5983 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
5984 let mut exchange = Exchange::new(Message::default());
5985 exchange.input.set_header(
5986 "CamelHttpPath",
5987 serde_json::Value::String("next".to_string()),
5988 );
5989 let url = HttpProducer::resolve_url(&exchange, &cfg);
5990 assert!(url.starts_with("http://example.com/base/next?"));
5991 assert!(url.contains("foo=bar"));
5992
5993 exchange.input.set_header(
5994 "CamelHttpUri",
5995 serde_json::Value::String("http://other.test/root".to_string()),
5996 );
5997 exchange.input.set_header(
5998 "CamelHttpQuery",
5999 serde_json::Value::String("a=1&b=2".to_string()),
6000 );
6001
6002 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6003 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6004 }
6005
6006 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6007 let mut exchange = Exchange::new(Message::default());
6008 exchange
6009 .input
6010 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6011 exchange.input.set_header(
6012 "CamelHttpQuery",
6013 serde_json::Value::String(query.to_string()),
6014 );
6015 exchange
6016 }
6017
6018 #[test]
6019 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6020 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6021 cfg.bridge_endpoint = true;
6022 cfg.query_params
6023 .insert("token".to_string(), "secret".to_string());
6024 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6025 let url = HttpProducer::resolve_url(&exchange, &cfg);
6026 assert_eq!(url, "http://x/?token=secret");
6027 assert!(!url.contains("/foo"));
6028 assert!(!url.contains("dropme"));
6029 }
6030
6031 #[test]
6032 fn resolve_url_bridge_endpoint_false_merges_path() {
6033 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6034 cfg.bridge_endpoint = false;
6035 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6036 let url = HttpProducer::resolve_url(&exchange, &cfg);
6037 assert!(url.contains("/foo"), "url should contain /foo: {url}");
6038 assert!(
6039 url.contains("dropme=1"),
6040 "url should contain dropme=1: {url}"
6041 );
6042 }
6043
6044 #[test]
6045 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6046 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6047 cfg.bridge_endpoint = true;
6048 let mut exchange = Exchange::new(Message::default());
6049 exchange.input.set_header(
6050 "CamelHttpPath",
6051 serde_json::Value::String("/foo".to_string()),
6052 );
6053 let url = HttpProducer::resolve_url(&exchange, &cfg);
6054 assert_eq!(url, "http://x");
6055 assert!(!url.contains("/foo"));
6056 }
6057
6058 #[test]
6059 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6060 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6061 cfg.bridge_endpoint = true;
6062 let mut exchange = Exchange::new(Message::default());
6064 exchange.input.set_header(
6065 "CamelHttpUri",
6066 serde_json::Value::String("http://dest/explicit".to_string()),
6067 );
6068 exchange.input.set_header(
6069 "CamelHttpPath",
6070 serde_json::Value::String("/foo".to_string()),
6071 );
6072 exchange.input.set_header(
6073 "CamelHttpQuery",
6074 serde_json::Value::String("x=1".to_string()),
6075 );
6076 let url = HttpProducer::resolve_url(&exchange, &cfg);
6077 assert_eq!(url, "http://x");
6081 }
6082
6083 #[test]
6084 fn test_http_producer_helpers_status_and_size_boundaries() {
6085 assert!(HttpProducer::is_ok_status(200, (200, 299)));
6086 assert!(HttpProducer::is_ok_status(299, (200, 299)));
6087 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6088 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6089
6090 assert!(!exceeds_max_response_body(10, 10));
6091 assert!(exceeds_max_response_body(11, 10));
6092 }
6093
6094 async fn setup_consumer_on_free_port(
6099 path: &str,
6100 ) -> (
6101 u16,
6102 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6103 tokio_util::sync::CancellationToken,
6104 ) {
6105 use camel_component_api::ConsumerContext;
6106
6107 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6108 let port = listener.local_addr().unwrap().port();
6109 drop(listener);
6110
6111 let consumer_cfg = HttpServerConfig {
6112 scheme: "http".to_string(),
6113 host: "127.0.0.1".to_string(),
6114 port,
6115 path: path.to_string(),
6116 max_request_body: 2 * 1024 * 1024,
6117 max_response_body: 10 * 1024 * 1024,
6118 max_inflight_requests: 1024,
6119 method: None,
6120 tls_config: None,
6121 };
6122 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6123
6124 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6125 let token = tokio_util::sync::CancellationToken::new();
6126 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6127
6128 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6129 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6130
6131 (port, rx, token)
6132 }
6133
6134 #[tokio::test]
6135 async fn test_content_type_inferred_for_json_body() {
6136 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6137
6138 let client = reqwest::Client::new();
6139 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6140
6141 let (http_result, _) = tokio::join!(send_fut, async {
6142 if let Some(mut envelope) = rx.recv().await {
6143 envelope.exchange.input.body =
6144 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6145 if let Some(reply_tx) = envelope.reply_tx {
6146 let _ = reply_tx.send(Ok(envelope.exchange));
6147 }
6148 }
6149 });
6150
6151 let resp = http_result.unwrap();
6152 assert_eq!(resp.status().as_u16(), 200);
6153 let ct = resp
6154 .headers()
6155 .get("content-type")
6156 .expect("Content-Type header should be present");
6157 assert_eq!(ct, "application/json");
6158 let body = resp.text().await.unwrap();
6159 assert_eq!(body, r#"{"message":"hello"}"#);
6160
6161 token.cancel();
6162 }
6163
6164 #[tokio::test]
6165 async fn test_content_type_inferred_for_text_body() {
6166 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6167
6168 let client = reqwest::Client::new();
6169 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6170
6171 let (http_result, _) = tokio::join!(send_fut, async {
6172 if let Some(mut envelope) = rx.recv().await {
6173 envelope.exchange.input.body =
6174 camel_component_api::Body::Text("plain text response".to_string());
6175 if let Some(reply_tx) = envelope.reply_tx {
6176 let _ = reply_tx.send(Ok(envelope.exchange));
6177 }
6178 }
6179 });
6180
6181 let resp = http_result.unwrap();
6182 assert_eq!(resp.status().as_u16(), 200);
6183 let ct = resp
6184 .headers()
6185 .get("content-type")
6186 .expect("Content-Type header should be present");
6187 assert_eq!(ct, "text/plain; charset=utf-8");
6188 let body = resp.text().await.unwrap();
6189 assert_eq!(body, "plain text response");
6190
6191 token.cancel();
6192 }
6193
6194 #[tokio::test]
6195 async fn test_content_type_inferred_for_xml_body() {
6196 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6197
6198 let client = reqwest::Client::new();
6199 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6200
6201 let (http_result, _) = tokio::join!(send_fut, async {
6202 if let Some(mut envelope) = rx.recv().await {
6203 envelope.exchange.input.body =
6204 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6205 if let Some(reply_tx) = envelope.reply_tx {
6206 let _ = reply_tx.send(Ok(envelope.exchange));
6207 }
6208 }
6209 });
6210
6211 let resp = http_result.unwrap();
6212 assert_eq!(resp.status().as_u16(), 200);
6213 let ct = resp
6214 .headers()
6215 .get("content-type")
6216 .expect("Content-Type header should be present");
6217 assert_eq!(ct, "application/xml");
6218 let body = resp.text().await.unwrap();
6219 assert_eq!(body, "<root><item>value</item></root>");
6220
6221 token.cancel();
6222 }
6223
6224 #[tokio::test]
6225 async fn test_no_content_type_for_empty_body() {
6226 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6227
6228 let client = reqwest::Client::new();
6229 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6230
6231 let (http_result, _) = tokio::join!(send_fut, async {
6232 if let Some(mut envelope) = rx.recv().await {
6233 envelope.exchange.input.body = camel_component_api::Body::Empty;
6234 if let Some(reply_tx) = envelope.reply_tx {
6235 let _ = reply_tx.send(Ok(envelope.exchange));
6236 }
6237 }
6238 });
6239
6240 let resp = http_result.unwrap();
6241 assert_eq!(resp.status().as_u16(), 200);
6242 assert!(
6243 resp.headers().get("content-type").is_none(),
6244 "Empty body should not set Content-Type"
6245 );
6246
6247 token.cancel();
6248 }
6249
6250 #[tokio::test]
6251 async fn test_no_content_type_for_raw_bytes_body() {
6252 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6253
6254 let client = reqwest::Client::new();
6255 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6256
6257 let (http_result, _) = tokio::join!(send_fut, async {
6258 if let Some(mut envelope) = rx.recv().await {
6259 envelope.exchange.input.body =
6260 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6261 if let Some(reply_tx) = envelope.reply_tx {
6262 let _ = reply_tx.send(Ok(envelope.exchange));
6263 }
6264 }
6265 });
6266
6267 let resp = http_result.unwrap();
6268 assert_eq!(resp.status().as_u16(), 200);
6269 assert!(
6270 resp.headers().get("content-type").is_none(),
6271 "Raw Bytes body should not set Content-Type"
6272 );
6273
6274 token.cancel();
6275 }
6276
6277 #[tokio::test]
6278 async fn test_content_type_from_stream_metadata() {
6279 use camel_component_api::{StreamBody, StreamMetadata};
6280 use futures::stream;
6281
6282 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6283
6284 let client = reqwest::Client::new();
6285 let send_fut = client
6286 .get(format!("http://127.0.0.1:{port}/stream-ct"))
6287 .send();
6288
6289 let (http_result, _) = tokio::join!(send_fut, async {
6290 if let Some(mut envelope) = rx.recv().await {
6291 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6292 vec![Ok(bytes::Bytes::from("audio data"))];
6293 let stream = Box::pin(stream::iter(chunks));
6294 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6295 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6296 metadata: StreamMetadata {
6297 size_hint: None,
6298 content_type: Some("audio/mpeg".to_string()),
6299 origin: None,
6300 },
6301 });
6302 if let Some(reply_tx) = envelope.reply_tx {
6303 let _ = reply_tx.send(Ok(envelope.exchange));
6304 }
6305 }
6306 });
6307
6308 let resp = http_result.unwrap();
6309 assert_eq!(resp.status().as_u16(), 200);
6310 let ct = resp
6311 .headers()
6312 .get("content-type")
6313 .expect("Content-Type header should be present");
6314 assert_eq!(ct, "audio/mpeg");
6315 let body = resp.text().await.unwrap();
6316 assert_eq!(body, "audio data");
6317
6318 token.cancel();
6319 }
6320
6321 #[tokio::test]
6322 async fn test_user_content_type_overrides_inferred() {
6323 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6324
6325 let client = reqwest::Client::new();
6326 let send_fut = client
6327 .get(format!("http://127.0.0.1:{port}/override-ct"))
6328 .send();
6329
6330 let (http_result, _) = tokio::join!(send_fut, async {
6331 if let Some(mut envelope) = rx.recv().await {
6332 envelope.exchange.input.body =
6333 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6334 envelope.exchange.input.set_header(
6335 "Content-Type",
6336 serde_json::Value::String("text/html".to_string()),
6337 );
6338 if let Some(reply_tx) = envelope.reply_tx {
6339 let _ = reply_tx.send(Ok(envelope.exchange));
6340 }
6341 }
6342 });
6343
6344 let resp = http_result.unwrap();
6345 assert_eq!(resp.status().as_u16(), 200);
6346 let ct = resp
6347 .headers()
6348 .get("content-type")
6349 .expect("Content-Type header should be present");
6350 assert_eq!(
6351 ct, "text/html",
6352 "User-set Content-Type should take precedence over inferred type"
6353 );
6354
6355 token.cancel();
6356 }
6357
6358 #[tokio::test]
6359 async fn test_user_content_type_with_bytes_body() {
6360 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6361
6362 let client = reqwest::Client::new();
6363 let send_fut = client
6364 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6365 .send();
6366
6367 let (http_result, _) = tokio::join!(send_fut, async {
6368 if let Some(mut envelope) = rx.recv().await {
6369 envelope.exchange.input.body =
6370 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6371 envelope.exchange.input.set_header(
6372 "Content-Type",
6373 serde_json::Value::String("application/json".to_string()),
6374 );
6375 if let Some(reply_tx) = envelope.reply_tx {
6376 let _ = reply_tx.send(Ok(envelope.exchange));
6377 }
6378 }
6379 });
6380
6381 let resp = http_result.unwrap();
6382 assert_eq!(resp.status().as_u16(), 200);
6383 let ct = resp
6384 .headers()
6385 .get("content-type")
6386 .expect("Content-Type header should be present for Bytes body with user header");
6387 assert_eq!(
6388 ct, "application/json",
6389 "User Content-Type should be sent for Bytes body"
6390 );
6391
6392 token.cancel();
6393 }
6394
6395 #[tokio::test]
6400 async fn monitor_task_silent_on_clean_exit() {
6401 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6402 monitor_axum_task(
6404 handle,
6405 "127.0.0.1:0".to_string(),
6406 noop_rt(),
6407 "test-monitor".into(),
6408 )
6409 .await;
6410 }
6411
6412 #[tokio::test]
6413 async fn monitor_task_handles_panicked_task() {
6414 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6415 panic!("simulated server crash");
6416 });
6417 monitor_axum_task(
6419 handle,
6420 "127.0.0.1:9999".to_string(),
6421 noop_rt(),
6422 "test-monitor".into(),
6423 )
6424 .await;
6425 }
6426
6427 #[test]
6432 fn http_auth_basic_debug_redacts_password() {
6433 let auth = HttpAuth::Basic {
6434 username: "admin".to_string(),
6435 password: "hunter2".to_string(),
6436 };
6437 let debug = format!("{:?}", auth);
6438 assert!(
6439 !debug.contains("hunter2"),
6440 "password must be redacted: {debug}"
6441 );
6442 assert!(debug.contains("admin"), "username should appear: {debug}");
6443 }
6444
6445 #[test]
6446 fn http_auth_bearer_debug_redacts_token() {
6447 let auth = HttpAuth::Bearer {
6448 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6449 };
6450 let debug = format!("{:?}", auth);
6451 assert!(
6452 !debug.contains("eyJhbGci"),
6453 "token must be redacted: {debug}"
6454 );
6455 }
6456
6457 #[test]
6458 fn http_auth_none_debug_shows_variant() {
6459 let debug = format!("{:?}", HttpAuth::None);
6460 assert!(
6461 debug.contains("None"),
6462 "None variant should appear: {debug}"
6463 );
6464 }
6465
6466 #[test]
6467 fn http_endpoint_config_debug_redacts_auth_credentials() {
6468 let config = HttpEndpointConfig::from_uri(
6469 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6470 )
6471 .unwrap();
6472 let debug = format!("{:?}", config);
6473 assert!(
6474 !debug.contains("secret123"),
6475 "password must be redacted in HttpEndpointConfig debug: {debug}"
6476 );
6477 }
6478
6479 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6484 use tower_http::services::ServeDir;
6485
6486 fn make_test_registry() -> HttpRouteRegistry {
6487 HttpRouteRegistry::new()
6488 }
6489
6490 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6491 AppState {
6492 registry,
6493 max_request_body: 2 * 1024 * 1024,
6494 max_response_body: 10 * 1024 * 1024,
6495 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6496 }
6497 }
6498
6499 #[allow(clippy::await_holding_lock)]
6500 #[tokio::test]
6501 async fn test_static_file_serving_serves_file_contents() {
6502 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6503 ServerRegistry::reset();
6504
6505 let temp_dir =
6507 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6508 std::fs::create_dir_all(&temp_dir).unwrap();
6509 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6510 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6511
6512 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6513
6514 let registry = make_test_registry();
6515 let serve_dir = ServeDir::new(&canonical_dir)
6516 .precompressed_gzip()
6517 .precompressed_br()
6518 .append_index_html_on_directories(true);
6519
6520 let mount = StaticMount {
6521 mount_path: "/".to_string(),
6522 mode: MountMode::Static,
6523 dir: canonical_dir.clone(),
6524 cache_control: "public, max-age=3600".to_string(),
6525 error_pages: std::collections::HashMap::new(),
6526 serve_dir,
6527 };
6528 registry.register_static_mount(mount).await.unwrap();
6529
6530 let state = make_test_state(registry);
6531
6532 let req = Request::builder()
6534 .uri("/hello.txt")
6535 .body(AxumBody::empty())
6536 .unwrap();
6537 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6538 assert_eq!(resp.status(), StatusCode::OK);
6539 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6540 .await
6541 .unwrap();
6542 assert_eq!(&body[..], b"Hello, static world!");
6543
6544 let req = Request::builder()
6546 .uri("/style.css")
6547 .body(AxumBody::empty())
6548 .unwrap();
6549 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6550 assert_eq!(resp.status(), StatusCode::OK);
6551 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6552 .await
6553 .unwrap();
6554 assert_eq!(&body[..], b"body { color: red; }");
6555
6556 let req = Request::builder()
6558 .uri("/missing.txt")
6559 .body(AxumBody::empty())
6560 .unwrap();
6561 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6562 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6563
6564 std::fs::remove_dir_all(&temp_dir).ok();
6566 }
6567
6568 #[allow(clippy::await_holding_lock)]
6569 #[tokio::test]
6570 async fn test_spa_fallback_serves_index_for_unknown_paths() {
6571 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6572 ServerRegistry::reset();
6573
6574 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6575 std::fs::create_dir_all(&temp_dir).unwrap();
6576 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6577 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6578
6579 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6580
6581 let registry = make_test_registry();
6582 let serve_dir = ServeDir::new(&canonical_dir)
6583 .precompressed_gzip()
6584 .precompressed_br()
6585 .append_index_html_on_directories(true);
6586
6587 let mount = StaticMount {
6588 mount_path: "/".to_string(),
6589 mode: MountMode::Spa,
6590 dir: canonical_dir.clone(),
6591 cache_control: "public, max-age=0".to_string(),
6592 error_pages: std::collections::HashMap::new(),
6593 serve_dir,
6594 };
6595 registry.register_static_mount(mount).await.unwrap();
6597
6598 let state = make_test_state(registry);
6599
6600 let req = Request::builder()
6602 .method("GET")
6603 .uri("/dashboard")
6604 .header("Accept", "text/html")
6605 .body(AxumBody::empty())
6606 .unwrap();
6607 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6608 assert_eq!(resp.status(), StatusCode::OK);
6609 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6610 .await
6611 .unwrap();
6612 assert_eq!(&body[..], b"<h1>SPA App</h1>");
6613
6614 let req = Request::builder()
6616 .method("GET")
6617 .uri("/app.js")
6618 .body(AxumBody::empty())
6619 .unwrap();
6620 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6621 assert_eq!(resp.status(), StatusCode::OK);
6622 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6623 .await
6624 .unwrap();
6625 assert_eq!(&body[..], b"console.log('app')");
6626
6627 let req = Request::builder()
6629 .method("GET")
6630 .uri("/api/data")
6631 .header("Accept", "application/json")
6632 .body(AxumBody::empty())
6633 .unwrap();
6634 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6635 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6636
6637 let req = Request::builder()
6639 .method("GET")
6640 .uri("/style.css")
6641 .header("Accept", "text/html")
6642 .body(AxumBody::empty())
6643 .unwrap();
6644 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6645 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6646
6647 std::fs::remove_dir_all(&temp_dir).ok();
6649 }
6650
6651 #[allow(clippy::await_holding_lock)]
6658 async fn run_conditional_get_returns_304(mode: MountMode) {
6659 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6660 ServerRegistry::reset();
6661
6662 let temp_dir = std::env::temp_dir().join(format!(
6663 "http_cond_get_{}_{}",
6664 if mode == MountMode::Spa {
6665 "spa"
6666 } else {
6667 "static"
6668 },
6669 std::process::id()
6670 ));
6671 std::fs::create_dir_all(&temp_dir).unwrap();
6672 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6673
6674 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6675
6676 let registry = make_test_registry();
6677 let serve_dir = ServeDir::new(&canonical_dir)
6678 .precompressed_gzip()
6679 .precompressed_br()
6680 .append_index_html_on_directories(true);
6681
6682 let mount = StaticMount {
6683 mount_path: "/".to_string(),
6684 mode,
6685 dir: canonical_dir.clone(),
6686 cache_control: "public, max-age=3600".to_string(),
6687 error_pages: std::collections::HashMap::new(),
6688 serve_dir,
6689 };
6690 registry.register_static_mount(mount).await.unwrap();
6691
6692 let state = make_test_state(registry);
6693
6694 let req = Request::builder()
6696 .method("GET")
6697 .uri("/index.html")
6698 .body(AxumBody::empty())
6699 .unwrap();
6700 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6701 assert_eq!(
6702 resp.status(),
6703 StatusCode::OK,
6704 "first GET should return 200, got {}",
6705 resp.status()
6706 );
6707 assert!(
6709 resp.headers().contains_key(http::header::CACHE_CONTROL),
6710 "200 response missing Cache-Control"
6711 );
6712 let etag = resp
6713 .headers()
6714 .get(http::header::ETAG)
6715 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
6716 .clone();
6717 let last_modified = resp
6718 .headers()
6719 .get(http::header::LAST_MODIFIED)
6720 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
6721 .clone();
6722 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
6724 .await
6725 .unwrap();
6726
6727 let req = Request::builder()
6731 .method("GET")
6732 .uri("/index.html")
6733 .header(http::header::IF_NONE_MATCH, etag.clone())
6734 .body(AxumBody::empty())
6735 .unwrap();
6736 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6737 assert_eq!(
6738 resp.status(),
6739 StatusCode::NOT_MODIFIED,
6740 "If-None-Match with matching ETag should return 304, got {}",
6741 resp.status()
6742 );
6743 assert!(
6745 resp.headers().contains_key(http::header::CACHE_CONTROL),
6746 "304 (If-None-Match) missing Cache-Control"
6747 );
6748 assert_eq!(
6751 resp.headers().get(http::header::ETAG),
6752 Some(&etag),
6753 "304 (If-None-Match) must echo the ETag validator"
6754 );
6755 assert_eq!(
6756 resp.headers().get(http::header::LAST_MODIFIED),
6757 Some(&last_modified),
6758 "304 (If-None-Match) must carry Last-Modified"
6759 );
6760
6761 let req = Request::builder()
6763 .method("GET")
6764 .uri("/index.html")
6765 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
6766 .body(AxumBody::empty())
6767 .unwrap();
6768 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6769 assert_eq!(
6770 resp.status(),
6771 StatusCode::NOT_MODIFIED,
6772 "If-Modified-Since with matching timestamp should return 304, got {}",
6773 resp.status()
6774 );
6775 assert!(
6776 resp.headers().contains_key(http::header::CACHE_CONTROL),
6777 "304 (If-Modified-Since) missing Cache-Control"
6778 );
6779 assert_eq!(
6780 resp.headers().get(http::header::ETAG),
6781 Some(&etag),
6782 "304 (If-Modified-Since) must carry the ETag validator"
6783 );
6784 assert_eq!(
6785 resp.headers().get(http::header::LAST_MODIFIED),
6786 Some(&last_modified),
6787 "304 (If-Modified-Since) must echo Last-Modified"
6788 );
6789
6790 let req = Request::builder()
6796 .method("GET")
6797 .uri("/index.html")
6798 .header(
6799 http::header::IF_MODIFIED_SINCE,
6800 "Wed, 21 Oct 2000 07:28:00 GMT",
6801 )
6802 .body(AxumBody::empty())
6803 .unwrap();
6804 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6805 assert_eq!(
6806 resp.status(),
6807 StatusCode::OK,
6808 "past If-Modified-Since should return 200 (file modified after it), got {}",
6809 resp.status()
6810 );
6811
6812 std::fs::remove_dir_all(&temp_dir).ok();
6814 }
6815
6816 #[tokio::test]
6817 async fn test_conditional_get_returns_304_static_mode() {
6818 run_conditional_get_returns_304(MountMode::Static).await;
6819 }
6820
6821 #[tokio::test]
6822 async fn test_conditional_get_returns_304_spa_mode() {
6823 run_conditional_get_returns_304(MountMode::Spa).await;
6824 }
6825
6826 #[allow(clippy::await_holding_lock)]
6827 #[tokio::test]
6828 async fn test_error_page_mapping_serves_custom_404() {
6829 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6830 ServerRegistry::reset();
6831
6832 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
6833 let errors_dir = temp_dir.join("errors");
6834 std::fs::create_dir_all(&errors_dir).unwrap();
6835 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6836 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
6837
6838 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6839 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
6840
6841 let registry = make_test_registry();
6842 let serve_dir = ServeDir::new(&canonical_dir)
6843 .precompressed_gzip()
6844 .precompressed_br()
6845 .append_index_html_on_directories(true);
6846
6847 let mut error_pages = std::collections::HashMap::new();
6848 error_pages.insert(404, canonical_404);
6849
6850 let mount = StaticMount {
6851 mount_path: "/".to_string(),
6852 mode: MountMode::Static,
6853 dir: canonical_dir.clone(),
6854 cache_control: "public, max-age=0".to_string(),
6855 error_pages,
6856 serve_dir,
6857 };
6858 registry.register_static_mount(mount).await.unwrap();
6859
6860 let state = make_test_state(registry);
6861
6862 let req = Request::builder()
6864 .method("GET")
6865 .uri("/missing.html")
6866 .body(AxumBody::empty())
6867 .unwrap();
6868 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
6869 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6870 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6871 .await
6872 .unwrap();
6873 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
6874
6875 let req = Request::builder()
6877 .method("GET")
6878 .uri("/index.html")
6879 .body(AxumBody::empty())
6880 .unwrap();
6881 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6882 assert_eq!(resp.status(), StatusCode::OK);
6883 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6884 .await
6885 .unwrap();
6886 assert_eq!(&body[..], b"<h1>Home</h1>");
6887
6888 std::fs::remove_dir_all(&temp_dir).ok();
6890 }
6891
6892 #[tokio::test]
6893 async fn http_consumer_returns_body_and_code_on_stop() {
6894 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
6895 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
6896 use tower::ServiceExt;
6897
6898 let set_body_step = CompiledStep::Process {
6900 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
6901 ex.input.body = Body::Text("nope".into());
6902 Box::pin(async move { Ok(ex) })
6903 }),
6904 body_contract: None,
6905 lifecycle: None,
6906 };
6907 let set_status_step = CompiledStep::Process {
6908 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
6909 ex.input.set_header(
6910 "CamelHttpResponseCode",
6911 serde_json::Value::Number(409.into()),
6912 );
6913 Box::pin(async move { Ok(ex) })
6914 }),
6915 body_contract: None,
6916 lifecycle: None,
6917 };
6918 let pipeline = compose_pipeline_with_handler(
6919 vec![set_body_step, set_status_step, CompiledStep::Stop],
6920 None,
6921 PipelineRuntimeCtx::compile_time(),
6922 );
6923
6924 let ex = Exchange::new(Message::default());
6925 let result = pipeline.oneshot(ex).await;
6926 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
6927 let returned = result.unwrap();
6928 assert_eq!(returned.input.body.as_text(), Some("nope"));
6929 assert_eq!(
6930 returned
6931 .input
6932 .header("CamelHttpResponseCode")
6933 .and_then(|v| v.as_u64()),
6934 Some(409)
6935 );
6936 }
6937
6938 #[tokio::test]
6939 async fn http_consumer_returns_200_when_body_empty_on_stop() {
6940 use camel_api::{Exchange, Message};
6948 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
6949 use tower::ServiceExt;
6950
6951 let pipeline = compose_pipeline_with_handler(
6952 vec![CompiledStep::Stop],
6953 None,
6954 PipelineRuntimeCtx::compile_time(),
6955 );
6956 let ex = Exchange::new(Message::default());
6957 let result = pipeline.oneshot(ex).await;
6958 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
6959 }
6962
6963 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
6971 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6972 let port = listener.local_addr().unwrap().port();
6973 let registry = HttpRouteRegistry::new();
6974 tokio::spawn(run_axum_server(
6975 listener,
6976 registry.clone(),
6977 2 * 1024 * 1024,
6978 10 * 1024 * 1024,
6979 Arc::new(tokio::sync::Semaphore::new(1024)),
6980 test_rt(),
6981 "test-route".into(),
6982 ));
6983 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6985 (port, registry)
6986 }
6987
6988 fn spawn_responder(
6993 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
6994 status: u16,
6995 body: String,
6996 ) -> tokio::task::JoinHandle<()> {
6997 tokio::spawn(async move {
6998 if let Some(envelope) = rx.recv().await {
6999 let _ = envelope.reply_tx.send(HttpReply {
7000 status,
7001 headers: vec![],
7002 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7003 });
7004 }
7005 })
7006 }
7007
7008 #[tokio::test]
7009 async fn method_aware_dispatch_same_path_different_verbs() {
7010 let (port, registry) = spawn_test_server().await;
7011
7012 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7017 registry
7018 .register_rest_endpoint(
7019 "GET".into(),
7020 vec![PathSegment::Literal("users".into())],
7021 get_tx,
7022 )
7023 .await;
7024
7025 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7026 registry
7027 .register_rest_endpoint(
7028 "POST".into(),
7029 vec![PathSegment::Literal("users".into())],
7030 post_tx,
7031 )
7032 .await;
7033
7034 let get_handle = spawn_responder(get_rx, 200, "list".into());
7035 let post_handle = spawn_responder(post_rx, 201, "create".into());
7036
7037 let client = reqwest::Client::new();
7038
7039 let resp = client
7041 .get(format!("http://127.0.0.1:{port}/users"))
7042 .send()
7043 .await
7044 .unwrap();
7045 assert_eq!(resp.status().as_u16(), 200);
7046 let body = resp.text().await.unwrap();
7047 assert_eq!(body, "list");
7048
7049 let resp = client
7051 .post(format!("http://127.0.0.1:{port}/users"))
7052 .send()
7053 .await
7054 .unwrap();
7055 assert_eq!(resp.status().as_u16(), 201);
7056 let body = resp.text().await.unwrap();
7057 assert_eq!(body, "create");
7058
7059 let _ = tokio::join!(get_handle, post_handle);
7060 }
7061
7062 #[tokio::test]
7063 async fn method_aware_dispatch_templated_path_extracts_params() {
7064 let (port, registry) = spawn_test_server().await;
7065
7066 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7070 registry
7071 .register_rest_endpoint(
7072 "GET".into(),
7073 vec![
7074 PathSegment::Literal("users".into()),
7075 PathSegment::Param("id".into()),
7076 ],
7077 tx,
7078 )
7079 .await;
7080
7081 let handle = tokio::spawn(async move {
7084 if let Some(envelope) = rx.recv().await {
7085 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7086 let _ = envelope.reply_tx.send(HttpReply {
7087 status: 200,
7088 headers: vec![],
7089 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7090 });
7091 }
7092 });
7093
7094 let client = reqwest::Client::new();
7095 let resp = client
7096 .get(format!("http://127.0.0.1:{port}/users/42"))
7097 .send()
7098 .await
7099 .unwrap();
7100 assert_eq!(resp.status().as_u16(), 200);
7101 let body = resp.text().await.unwrap();
7102 assert_eq!(body, "id=42");
7103
7104 let _ = handle.await;
7105 }
7106
7107 #[tokio::test]
7108 async fn method_aware_dispatch_unmatched_method_falls_through() {
7109 let (port, _registry) = spawn_test_server().await;
7114
7115 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7117 _registry
7118 .register_rest_endpoint(
7119 "GET".into(),
7120 vec![PathSegment::Literal("users".into())],
7121 get_tx,
7122 )
7123 .await;
7124
7125 let drain = tokio::spawn(async move {
7128 let mut get_rx = get_rx;
7129 while get_rx.recv().await.is_some() {}
7130 });
7131
7132 let client = reqwest::Client::new();
7133 let resp = client
7134 .delete(format!("http://127.0.0.1:{port}/users"))
7135 .send()
7136 .await
7137 .unwrap();
7138 assert_eq!(resp.status().as_u16(), 404);
7139
7140 drop(drain);
7141 }
7142
7143 #[tokio::test]
7144 async fn regression_legacy_exact_api_route_still_works() {
7145 let (port, registry) = spawn_test_server().await;
7150
7151 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7152 registry.register_api_route("/legacy/path".into(), tx).await;
7153
7154 let handle = tokio::spawn(async move {
7155 if let Some(envelope) = rx.recv().await {
7156 let _ = envelope.reply_tx.send(HttpReply {
7157 status: 200,
7158 headers: vec![],
7159 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7160 });
7161 }
7162 });
7163
7164 let client = reqwest::Client::new();
7165 let resp = client
7166 .get(format!("http://127.0.0.1:{port}/legacy/path"))
7167 .send()
7168 .await
7169 .unwrap();
7170 assert_eq!(resp.status().as_u16(), 200);
7171 let body = resp.text().await.unwrap();
7172 assert_eq!(body, "legacy ok");
7173
7174 let _ = handle.await;
7175 }
7176
7177 #[allow(clippy::await_holding_lock)]
7178 #[tokio::test]
7179 async fn regression_static_mount_still_works() {
7180 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7184 ServerRegistry::reset();
7185
7186 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7187 std::fs::create_dir_all(&temp_dir).unwrap();
7188 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7189 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7190
7191 let registry = make_test_registry();
7192 let serve_dir = ServeDir::new(&canonical_dir)
7193 .precompressed_gzip()
7194 .precompressed_br()
7195 .append_index_html_on_directories(true);
7196 let mount = StaticMount {
7197 mount_path: "/".to_string(),
7198 mode: MountMode::Static,
7199 dir: canonical_dir.clone(),
7200 cache_control: "public, max-age=3600".to_string(),
7201 error_pages: std::collections::HashMap::new(),
7202 serve_dir,
7203 };
7204 registry.register_static_mount(mount).await.unwrap();
7205
7206 let state = make_test_state(registry);
7207 let req = Request::builder()
7208 .uri("/regress.txt")
7209 .body(AxumBody::empty())
7210 .unwrap();
7211 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7212 assert_eq!(resp.status(), StatusCode::OK);
7213 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7214 .await
7215 .unwrap();
7216 assert_eq!(&body[..], b"static works");
7217
7218 std::fs::remove_dir_all(&temp_dir).ok();
7219 }
7220
7221 #[tokio::test]
7230 async fn deregister_one_method_keeps_sibling_verbs() {
7231 let (port, registry) = spawn_test_server().await;
7235
7236 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7237 registry
7238 .register_rest_endpoint(
7239 "GET".into(),
7240 vec![PathSegment::Literal("users".into())],
7241 get_tx,
7242 )
7243 .await;
7244
7245 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7246 registry
7247 .register_rest_endpoint(
7248 "POST".into(),
7249 vec![PathSegment::Literal("users".into())],
7250 post_tx,
7251 )
7252 .await;
7253
7254 let drain = tokio::spawn(async move {
7256 let mut get_rx = get_rx;
7257 while get_rx.recv().await.is_some() {}
7258 });
7259
7260 registry.unregister_rest_endpoint("GET", "/users").await;
7262 drop(drain);
7263
7264 let post_handle = spawn_responder(post_rx, 201, "create".into());
7265
7266 let client = reqwest::Client::new();
7267 let resp = client
7269 .post(format!("http://127.0.0.1:{port}/users"))
7270 .send()
7271 .await
7272 .unwrap();
7273 assert_eq!(resp.status().as_u16(), 201);
7274 assert_eq!(resp.text().await.unwrap(), "create");
7275
7276 let _ = post_handle.await;
7277 }
7278
7279 #[tokio::test]
7280 async fn dispatch_exact_legacy_beats_rest_template() {
7281 let (port, registry) = spawn_test_server().await;
7286
7287 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7289 registry
7290 .register_api_route("/api/users".into(), exact_tx)
7291 .await;
7292 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7293
7294 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7296 registry
7297 .register_rest_endpoint(
7298 "GET".into(),
7299 vec![
7300 PathSegment::Literal("api".into()),
7301 PathSegment::Param("resource".into()),
7302 ],
7303 tpl_tx,
7304 )
7305 .await;
7306 let _tpl_drain = tokio::spawn(async move {
7312 let mut tpl_rx = tpl_rx;
7313 if let Some(env) = tpl_rx.recv().await {
7314 let _ = env.reply_tx.send(HttpReply {
7315 status: 200,
7316 headers: vec![],
7317 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7318 });
7319 }
7320 });
7321
7322 let client = reqwest::Client::new();
7323 let resp = client
7324 .get(format!("http://127.0.0.1:{port}/api/users"))
7325 .send()
7326 .await
7327 .unwrap();
7328 assert_eq!(resp.status().as_u16(), 200);
7329 assert_eq!(resp.text().await.unwrap(), "exact");
7331
7332 let _ = exact_handle.await;
7333 }
7334
7335 #[tokio::test]
7336 async fn ambiguous_rest_templates_return_500_not_silent_404() {
7337 let (port, registry) = spawn_test_server().await;
7342
7343 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7344 registry
7345 .register_rest_endpoint(
7346 "GET".into(),
7347 vec![
7348 PathSegment::Literal("users".into()),
7349 PathSegment::Param("id".into()),
7350 ],
7351 a_tx,
7352 )
7353 .await;
7354
7355 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7356 registry
7357 .register_rest_endpoint(
7358 "GET".into(),
7359 vec![
7360 PathSegment::Literal("users".into()),
7361 PathSegment::Param("name".into()),
7362 ],
7363 b_tx,
7364 )
7365 .await;
7366
7367 let client = reqwest::Client::new();
7368 let resp = client
7369 .get(format!("http://127.0.0.1:{port}/users/42"))
7370 .send()
7371 .await
7372 .unwrap();
7373 assert_eq!(resp.status().as_u16(), 500);
7375 }
7376
7377 #[test]
7378 fn from_uri_round_trips_templated_path_with_http_method() {
7379 use crate::UriConfig;
7385 let cfg =
7386 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7387 assert_eq!(cfg.host, "0.0.0.0");
7388 assert_eq!(cfg.port, 8080);
7389 assert_eq!(cfg.path, "/users/{id}");
7390 assert_eq!(cfg.method.as_deref(), Some("GET"));
7391
7392 let cfg_lc =
7394 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7395 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7396 assert_eq!(cfg_lc.path, "/orders");
7397 }
7398
7399 #[test]
7404 fn type_conversion_failed_maps_to_400() {
7405 let reply = pipeline_error_to_reply(
7406 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7407 "/api/users",
7408 );
7409 assert_eq!(reply.status, 400);
7410 let ct = reply
7412 .headers
7413 .iter()
7414 .find(|(k, _)| k == "Content-Type")
7415 .map(|(_, v)| v.as_str());
7416 assert_eq!(ct, Some("application/json"));
7417 let body = match &reply.body {
7419 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7420 _ => panic!("expected bytes body"),
7421 };
7422 assert!(body.contains("\"error\""));
7423 assert!(body.contains("bad_request"));
7424 assert!(body.contains("invalid JSON at line 1"));
7425 }
7426
7427 #[test]
7428 fn other_error_still_maps_to_500() {
7429 let reply =
7430 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7431 assert_eq!(reply.status, 500);
7432 }
7433
7434 #[test]
7435 fn unauthenticated_maps_to_401() {
7436 let reply = pipeline_error_to_reply(
7437 CamelError::Unauthenticated("no token".to_string()),
7438 "/api/users",
7439 );
7440 assert_eq!(reply.status, 401);
7441 }
7442
7443 #[test]
7444 fn unauthorized_maps_to_403() {
7445 let reply = pipeline_error_to_reply(
7446 CamelError::Unauthorized("forbidden".to_string()),
7447 "/api/users",
7448 );
7449 assert_eq!(reply.status, 403);
7450 }
7451
7452 #[test]
7453 fn validation_error_maps_to_400() {
7454 let reply = pipeline_error_to_reply(
7455 CamelError::ValidationError("body does not match schema".to_string()),
7456 "/api/users",
7457 );
7458 assert_eq!(reply.status, 400);
7459 let ct = reply
7460 .headers
7461 .iter()
7462 .find(|(k, _)| k == "Content-Type")
7463 .map(|(_, v)| v.as_str());
7464 assert_eq!(ct, Some("application/json"));
7465 let body = match &reply.body {
7466 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7467 _ => panic!("expected bytes body"),
7468 };
7469 assert!(body.contains("\"error\""));
7470 assert!(body.contains("validation_error"));
7471 assert!(body.contains("body does not match schema"));
7472 }
7473
7474 #[test]
7475 fn https_consumer_without_tls_cert_errors() {
7476 let endpoint = HttpEndpoint {
7477 uri: "https://0.0.0.0:8443/api".to_string(),
7478 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7479 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7480 client: reqwest::Client::new(),
7481 http_config: HttpConfig::default(),
7482 };
7483 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7484 let result = endpoint.create_consumer(rt);
7485 assert!(result.is_err(), "expected error for https without tls cert");
7486 if let Err(e) = result {
7487 let msg = e.to_string();
7488 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7489 }
7490 }
7491
7492 #[test]
7493 fn http_consumer_with_tls_config_errors() {
7494 let endpoint = HttpEndpoint {
7495 uri: "http://0.0.0.0:8080/api".to_string(),
7496 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7497 server_config: HttpServerConfig::from_uri(
7498 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7499 )
7500 .unwrap(),
7501 client: reqwest::Client::new(),
7502 http_config: HttpConfig::default(),
7503 };
7504 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7505 let result = endpoint.create_consumer(rt);
7506 assert!(result.is_err(), "expected error for http with tls config");
7507 if let Err(e) = result {
7508 let msg = e.to_string();
7509 assert!(msg.contains("https"), "error must mention https: {msg}");
7510 }
7511 }
7512
7513 #[test]
7514 fn https_consumer_with_partial_tls_cert_only_errors() {
7515 let server_config =
7518 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7519 assert!(
7520 server_config.tls_config.is_none(),
7521 "partial tlsCert must not create ServerTlsConfig"
7522 );
7523 let endpoint = HttpEndpoint {
7524 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7525 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7526 .unwrap(),
7527 server_config,
7528 client: reqwest::Client::new(),
7529 http_config: HttpConfig::default(),
7530 };
7531 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7532 let result = endpoint.create_consumer(rt);
7533 assert!(
7534 result.is_err(),
7535 "must error: https:// requires both tlsCert and tlsKey"
7536 );
7537 }
7538
7539 #[test]
7540 fn load_tls_config_parses_valid_pem() {
7541 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7543 use camel_component_api::test_support::tls;
7544 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7545 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7546 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7547
7548 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7549 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7550 }
7551
7552 #[tokio::test(flavor = "multi_thread")]
7553 #[allow(clippy::await_holding_lock)]
7554 async fn consumer_tls_handshake_roundtrip() {
7555 use camel_component_api::test_support::tls;
7556 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7557
7558 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7560
7561 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7563
7564 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7566 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7567 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7568 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7569
7570 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7572 let port = probe.local_addr().unwrap().port();
7573 drop(probe);
7574
7575 ServerRegistry::reset();
7576
7577 let component = HttpComponent::new();
7579 let endpoint_ctx = NoOpComponentContext;
7580 let uri = format!(
7581 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7582 cert_path.to_string_lossy(),
7583 key_path.to_string_lossy(),
7584 );
7585 let endpoint = component
7586 .create_endpoint(&uri, &endpoint_ctx)
7587 .expect("create TLS endpoint");
7588 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7589
7590 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7592 let token = tokio_util::sync::CancellationToken::new();
7593 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7594 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7595
7596 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7598
7599 let ca_bytes = std::fs::read(&ca_path).unwrap();
7601 let client = reqwest::Client::builder()
7602 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7603 .build()
7604 .unwrap();
7605
7606 let send_fut = client
7607 .post(format!("https://localhost:{port}/test"))
7608 .body("ping")
7609 .send();
7610
7611 let (http_result, _) = tokio::join!(send_fut, async {
7613 if let Some(mut envelope) = rx.recv().await {
7614 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7615 if let Some(reply_tx) = envelope.reply_tx {
7616 let _ = reply_tx.send(Ok(envelope.exchange));
7617 }
7618 }
7619 });
7620
7621 let resp = http_result.expect("TLS handshake + request must succeed");
7622
7623 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7624 let body = resp.text().await.unwrap();
7625 assert_eq!(body, "pong");
7626
7627 token.cancel();
7628 }
7629
7630 #[tokio::test(flavor = "multi_thread")]
7631 #[allow(clippy::await_holding_lock)]
7632 async fn consumer_tls_rejects_client_without_ca() {
7633 use camel_component_api::test_support::tls;
7634 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7635
7636 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7637
7638 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7640
7641 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7642 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7643 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7644
7645 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7646 let port = probe.local_addr().unwrap().port();
7647 drop(probe);
7648
7649 ServerRegistry::reset();
7650
7651 let component = HttpComponent::new();
7653 let endpoint_ctx = NoOpComponentContext;
7654 let uri = format!(
7655 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7656 cert_path.to_string_lossy(),
7657 key_path.to_string_lossy(),
7658 );
7659 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7660 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7661 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7662 let token = tokio_util::sync::CancellationToken::new();
7663 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7664 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7665
7666 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7667
7668 let client = reqwest::Client::builder().build().unwrap();
7670
7671 let result = client
7672 .get(format!("https://localhost:{port}/test"))
7673 .send()
7674 .await;
7675
7676 assert!(
7677 result.is_err(),
7678 "must reject without CA — proves real verification"
7679 );
7680
7681 token.cancel();
7682 }
7683
7684 #[test]
7685 fn server_config_partial_tls_cert_without_key() {
7686 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7688 assert!(cfg.tls_config.is_none());
7690 }
7691
7692 #[test]
7693 fn endpoint_uri_options_count_parity() {
7694 assert_eq!(
7696 HttpEndpointConfig::uri_options().len(),
7697 20,
7698 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
7699 );
7700 }
7701
7702 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
7703 pairs
7704 .iter()
7705 .map(|(k, v)| {
7706 (
7707 (*k).to_string(),
7708 serde_json::Value::String((*v).to_string()),
7709 )
7710 })
7711 .collect()
7712 }
7713
7714 #[test]
7715 fn response_emits_cache_control_via_pragma_warning() {
7716 let headers = make_headers(&[
7717 ("Cache-Control", "public, max-age=3600"),
7718 ("Via", "1.1 myproxy"),
7719 ("Pragma", "no-cache"),
7720 ("Warning", "199 misc"),
7721 ]);
7722 let selected = select_response_headers(&headers, None, None);
7723 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7724 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
7725 assert!(
7726 names.contains(&expected),
7727 "{expected} should pass through to the response"
7728 );
7729 }
7730 }
7731
7732 #[test]
7733 fn response_excludes_request_only_and_server_owned() {
7734 let headers = make_headers(&[
7735 ("User-Agent", "x"),
7736 ("Accept", "*/*"),
7737 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
7738 ]);
7739 let selected = select_response_headers(&headers, None, None);
7740 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7741 for excluded in ["User-Agent", "Accept", "Date"] {
7742 assert!(
7743 !names.contains(&excluded),
7744 "{excluded} should NOT appear in the response"
7745 );
7746 }
7747 }
7748
7749 #[test]
7750 fn response_re_derives_content_type() {
7751 let headers = make_headers(&[("Content-Type", "text/plain")]);
7752 let selected = select_response_headers(&headers, Some("application/json".into()), None);
7753 let ct_entries: Vec<&str> = selected
7754 .iter()
7755 .filter(|(k, _)| k == "Content-Type")
7756 .map(|(_, v)| v.as_str())
7757 .collect();
7758 assert_eq!(
7759 ct_entries,
7760 ["application/json"],
7761 "exactly one Content-Type entry, re-derived from user_content_type"
7762 );
7763 }
7764
7765 #[test]
7766 fn response_excludes_camel_headers() {
7767 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
7768 let selected = select_response_headers(&headers, None, None);
7769 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7770 assert!(
7771 !names.contains(&"CamelHttpPath"),
7772 "Camel-namespace headers must be excluded"
7773 );
7774 assert!(
7775 names.contains(&"Cache-Control"),
7776 "Cache-Control must pass through"
7777 );
7778 }
7779
7780 async fn start_host_capturing_destination() -> (
7792 String,
7793 Arc<std::sync::Mutex<Option<(String, String)>>>,
7794 tokio::task::JoinHandle<()>,
7795 ) {
7796 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7797 let port = listener.local_addr().unwrap().port();
7798 let url = format!("http://127.0.0.1:{port}");
7799 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
7800 Arc::new(std::sync::Mutex::new(None));
7801 let captured_clone = Arc::clone(&captured);
7802 let handle = tokio::spawn(async move {
7803 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7804 if let Ok((mut stream, _)) = listener.accept().await {
7805 let mut buf = vec![0u8; 16384];
7806 let n = stream.read(&mut buf).await.unwrap_or(0);
7807 let request = String::from_utf8_lossy(&buf[..n]).to_string();
7808 if request.contains("\r\n\r\n") {
7809 let request_line = request.lines().next().unwrap_or("").to_string();
7810 let host_value = request
7811 .lines()
7812 .find(|l| l.to_lowercase().starts_with("host:"))
7813 .and_then(|l| l.split_once(':'))
7814 .map(|(_, v)| v.trim().to_string())
7815 .unwrap_or_default();
7816 *captured_clone.lock().unwrap() = Some((host_value, request_line));
7817 }
7818 let body = r#"{"echo":"ok"}"#;
7819 let resp = format!(
7820 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
7821 body.len(),
7822 body
7823 );
7824 let _ = stream.write_all(resp.as_bytes()).await;
7825 }
7826 });
7827 (url, captured, handle)
7828 }
7829
7830 #[tokio::test]
7835 async fn bridge_proxy_outbound_host_matches_destination() {
7836 use tower::ServiceExt;
7837
7838 let (url, captured, _handle) = start_host_capturing_destination().await;
7839 let expected_host = url.strip_prefix("http://").unwrap();
7842
7843 let ctx = test_producer_ctx();
7844 let component = HttpComponent::new();
7845 let endpoint_ctx = NoOpComponentContext;
7846 let endpoint = component
7847 .create_endpoint(
7848 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
7849 &endpoint_ctx,
7850 )
7851 .unwrap();
7852 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7853
7854 let mut exchange = Exchange::new(Message::default());
7857 exchange.input.set_header("Host", "localhost");
7858 exchange.input.set_header("CamelHttpPath", "/foo");
7859
7860 let result = producer.oneshot(exchange).await;
7861 assert!(result.is_ok(), "producer call failed: {:?}", result);
7862
7863 tokio::time::sleep(Duration::from_millis(100)).await;
7864 let (host_value, request_line) = captured
7865 .lock()
7866 .unwrap()
7867 .take()
7868 .expect("destination capture mutex empty — producer did not reach the destination");
7869
7870 assert_ne!(
7871 host_value, "localhost",
7872 "bridge producer must not forward the exchange Host: localhost"
7873 );
7874 assert_eq!(
7875 host_value, expected_host,
7876 "Host must be derived from the destination authority (no scheme)"
7877 );
7878 assert!(
7879 !request_line.contains("/foo"),
7880 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
7881 );
7882 }
7883
7884 #[tokio::test]
7889 async fn bridge_proxy_route_set_response_header_survives() {
7890 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7891
7892 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7893 let port = listener.local_addr().unwrap().port();
7894 drop(listener);
7895
7896 let component = HttpComponent::new();
7897 let endpoint_ctx = NoOpComponentContext;
7898 let endpoint = component
7899 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
7900 .unwrap();
7901 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7902
7903 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7904 let token = tokio_util::sync::CancellationToken::new();
7905 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7906
7907 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7908 tokio::time::sleep(Duration::from_millis(50)).await;
7909
7910 let client = reqwest::Client::new();
7911 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
7912
7913 let (http_result, _) = tokio::join!(send_fut, async {
7917 if let Some(mut envelope) = rx.recv().await {
7918 envelope
7919 .exchange
7920 .input
7921 .set_header("Cache-Control", "public, max-age=3600");
7922 if let Some(reply_tx) = envelope.reply_tx {
7923 let _ = reply_tx.send(Ok(envelope.exchange));
7924 }
7925 }
7926 });
7927
7928 let resp = http_result.unwrap();
7929 assert_eq!(resp.status().as_u16(), 200);
7930
7931 let cache_control = resp.headers().get("cache-control");
7932 assert!(
7933 cache_control.is_some(),
7934 "Cache-Control header must survive to the wire response"
7935 );
7936 assert_eq!(
7937 cache_control.unwrap().to_str().unwrap(),
7938 "public, max-age=3600"
7939 );
7940
7941 token.cancel();
7942 }
7943
7944 use camel_api::security_policy::{AuthorizationDecision, CredentialSource, SecurityPolicy};
7963 use camel_auth::native_auth::NativeCredentialStore;
7964 use camel_auth::{RolePolicy, StaticTokenAuthenticator, TokenAuthenticator};
7965
7966 const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
7974 let mut msg = Message::default();
7975 msg.set_header(
7976 "CamelHttpMethod",
7977 serde_json::Value::String(envelope.method.clone()),
7978 );
7979 msg.set_header(
7980 "CamelHttpPath",
7981 serde_json::Value::String(envelope.path.clone()),
7982 );
7983 msg.set_header(
7984 "CamelHttpQuery",
7985 serde_json::Value::String(envelope.query.clone()),
7986 );
7987 for (k, v) in &envelope.headers {
7988 if let Ok(val_str) = v.to_str() {
7989 msg.set_header(
7990 title_case_header(k.as_str()),
7991 serde_json::Value::String(val_str.to_string()),
7992 );
7993 }
7994 }
7995 Exchange::new(msg)
7996 }
7997
7998 async fn spawn_failing_auth_route(
8003 registry: &HttpRouteRegistry,
8004 path: &str,
8005 sources: Vec<CredentialSource>,
8006 ) {
8007 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8008 NativeCredentialStore::try_new(vec![]).unwrap(),
8009 ));
8010 let policy = Arc::new(RolePolicy::new(
8011 vec!["tiles-reader".to_string()],
8012 true,
8013 false,
8014 authenticator,
8015 sources,
8016 ));
8017 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8018 registry.register_api_route(path.to_string(), tx).await;
8019 let path_owned = path.to_string();
8020 tokio::spawn(async move {
8021 while let Some(envelope) = rx.recv().await {
8022 let mut exchange = envelope_to_exchange(&envelope);
8023 let reply_tx = envelope.reply_tx;
8024 let result: Result<(), CamelError> = match policy.evaluate(&mut exchange).await {
8025 Ok(AuthorizationDecision::Granted { .. }) => Ok(()),
8026 Ok(AuthorizationDecision::Denied { reason, .. }) => {
8027 Err(CamelError::Unauthorized(format!("Access denied: {reason}")))
8028 }
8029 Err(e) => Err(e),
8030 _ => Err(CamelError::Unauthorized(
8031 "access denied by security policy".to_string(),
8032 )),
8033 };
8034 let reply = match result {
8035 Ok(()) => HttpReply {
8036 status: 200,
8037 headers: vec![],
8038 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8039 },
8040 Err(e) => pipeline_error_to_reply(e, &path_owned),
8041 };
8042 let _ = reply_tx.send(reply);
8043 }
8044 });
8045 }
8046
8047 fn captured_logs_contain(needle: &str) -> bool {
8051 let buf = tracing_test::internal::global_buf().lock().unwrap();
8052 String::from_utf8_lossy(&buf).contains(needle)
8053 }
8054
8055 #[tracing_test::traced_test]
8056 #[tokio::test]
8057 async fn error_context_redacts_query_sentinel() {
8058 let (port, registry) = spawn_test_server().await;
8059 spawn_failing_auth_route(
8060 ®istry,
8061 "/secure-query",
8062 vec![CredentialSource::QueryParam {
8063 param: "token".to_string(),
8064 }],
8065 )
8066 .await;
8067
8068 let client = reqwest::Client::new();
8069 let resp = client
8070 .get(format!(
8072 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8073 ))
8074 .send()
8075 .await
8076 .unwrap();
8077
8078 assert_eq!(resp.status().as_u16(), 401);
8079 let body = resp.text().await.unwrap();
8080 assert_eq!(body, "Unauthorized");
8081 assert!(
8082 !body.contains(SENTINEL_QRY_42),
8083 "reply body must not contain the query credential"
8084 );
8085 assert!(
8086 !captured_logs_contain(SENTINEL_QRY_42),
8087 "no tracing record during request handling may render the query credential"
8088 );
8089 assert!(
8093 captured_logs_contain("Authentication failed"),
8094 "positive control: the failed-auth warn! must be captured by the test subscriber"
8095 );
8096 }
8097
8098 #[tracing_test::traced_test]
8099 #[tokio::test]
8100 async fn error_context_redacts_cookie_sentinel() {
8101 let (port, registry) = spawn_test_server().await;
8102 spawn_failing_auth_route(
8103 ®istry,
8104 "/secure-cookie",
8105 vec![CredentialSource::Cookie {
8106 name: "session".to_string(),
8107 }],
8108 )
8109 .await;
8110
8111 let client = reqwest::Client::new();
8112 let resp = client
8113 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8114 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8115 .send()
8116 .await
8117 .unwrap();
8118
8119 assert_eq!(resp.status().as_u16(), 401);
8120 let body = resp.text().await.unwrap();
8121 assert_eq!(body, "Unauthorized");
8122 assert!(
8123 !body.contains(SENTINEL_CKY_7),
8124 "reply body must not contain the cookie credential"
8125 );
8126 assert!(
8127 !captured_logs_contain(SENTINEL_CKY_7),
8128 "no tracing record during request handling may render the cookie credential"
8129 );
8130 }
8131
8132 #[tracing_test::traced_test]
8133 #[tokio::test]
8134 async fn error_reply_no_credential_value() {
8135 let (port, registry) = spawn_test_server().await;
8136 spawn_failing_auth_route(
8137 ®istry,
8138 "/secure-bad",
8139 vec![CredentialSource::Cookie {
8140 name: "session".to_string(),
8141 }],
8142 )
8143 .await;
8144
8145 let client = reqwest::Client::new();
8146 let resp = client
8147 .get(format!("http://127.0.0.1:{port}/secure-bad"))
8148 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8149 .send()
8150 .await
8151 .unwrap();
8152
8153 assert_eq!(resp.status().as_u16(), 401);
8154 let body = resp.text().await.unwrap();
8155 assert_eq!(body, "Unauthorized");
8156 assert!(
8157 !body.contains(SENTINEL_BAD_1),
8158 "reply body must not contain the credential value"
8159 );
8160 assert!(
8161 !captured_logs_contain(SENTINEL_BAD_1),
8162 "error logs must not render the credential value"
8163 );
8164 }
8165}