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