Skip to main content

camel_component_http/
lib.rs

1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12pub(crate) mod tls_reload;
13use crate::config::parse_ok_status_code_range;
14pub use bundle::HttpBundle;
15pub use bundle::HttpStaticBundle;
16pub(crate) use client_cache::{PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache};
17pub use config::HttpConfig;
18pub use health::HttpHealthCheck;
19pub use registry::HttpRouteRegistry;
20pub use static_config::HttpStaticConfig;
21pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
22
23use std::collections::HashMap;
24use std::future::Future;
25use std::pin::Pin;
26
27use std::sync::{Arc, Mutex, OnceLock};
28use std::task::{Context, Poll};
29use std::time::Duration;
30
31use tokio::sync::OnceCell;
32use tower::Layer;
33use tower::Service;
34use tracing::debug;
35
36use axum::body::BodyDataStream;
37use camel_api::component_metadata::ComponentMetadata;
38use camel_auth::bearer_token_layer::BearerTokenLayer;
39use camel_auth::oauth2::TokenProvider;
40use camel_component_api::tls_source::ServerTlsSource;
41use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
42use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
43use camel_component_api::{UriComponents, UriConfig, parse_uri};
44use futures::TryStreamExt;
45use futures::stream::BoxStream;
46
47// ---------------------------------------------------------------------------
48// HttpEndpointConfig
49// ---------------------------------------------------------------------------
50
51/// Configuration for an HTTP client (producer) endpoint.
52///
53/// # Memory Limits
54///
55/// HTTP operations enforce conservative memory limits to prevent denial-of-service
56/// attacks from untrusted network sources. These limits are significantly lower than
57/// file component limits (100MB) because HTTP typically handles API responses rather
58/// than large file transfers, and clients may be untrusted.
59///
60/// ## Default Limits
61///
62/// - **HTTP client body**: 10MB (typical API responses)
63/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
64/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
65///
66/// ## Rationale
67///
68/// The 10MB limit for HTTP client responses is appropriate for most API interactions
69/// while providing protection against:
70/// - Malicious servers sending oversized responses
71/// - Runaway processes generating unexpectedly large payloads
72/// - Memory exhaustion attacks
73///
74/// The 2MB server request limit is even more conservative because it handles input
75/// from potentially untrusted clients on the public internet.
76///
77/// ## Overriding Limits
78///
79/// Override the default client body limit using the `maxBodySize` URI parameter:
80///
81/// ```text
82/// http://api.example.com/large-data?maxBodySize=52428800
83/// ```
84///
85/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
86///
87/// ```text
88/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
89/// ```
90///
91/// ## Behavior When Exceeded
92///
93/// When a body exceeds the configured limit:
94/// - An error is returned immediately
95/// - No memory is exhausted - the limit is checked before allocation
96/// - The HTTP connection is terminated cleanly
97///
98/// ## Security Considerations
99///
100/// HTTP endpoints should be treated with more caution than file endpoints because:
101/// - Clients may be unknown and untrusted
102/// - Network traffic can be spoofed or malicious
103/// - DoS attacks often exploit unbounded resource consumption
104///
105/// Only increase limits when you control both ends of the connection or when
106/// business requirements demand larger payloads.
107#[derive(Debug, Clone)]
108pub struct HttpEndpointConfig {
109    pub base_url: String,
110    pub http_method: Option<String>,
111    pub throw_exception_on_failure: bool,
112    pub ok_status_code_range: (u16, u16),
113    pub response_timeout: Option<Duration>,
114    pub query_params: HashMap<String, String>,
115    pub allow_internal: bool,
116    pub blocked_hosts: Vec<String>,
117    pub max_body_size: usize,
118    pub read_timeout_ms: u64,
119    pub max_response_bytes: usize,
120    pub auth: HttpAuth,
121    pub token_provider: Option<Arc<dyn TokenProvider>>,
122    pub user_agent: Option<String>,
123    pub bridge_endpoint: bool,
124    pub connection_close: bool,
125    pub skip_request_headers: Vec<String>,
126    pub skip_response_headers: Vec<String>,
127    pub follow_redirects: bool,
128    pub max_redirects: usize,
129}
130
131#[derive(Clone, PartialEq)]
132pub enum HttpAuth {
133    None,
134    Basic { username: String, password: String },
135    Bearer { token: String },
136}
137
138impl std::fmt::Debug for HttpAuth {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            HttpAuth::None => f.write_str("None"),
142            HttpAuth::Basic { username, .. } => f
143                .debug_struct("Basic")
144                .field("username", username)
145                .field("password", &"***")
146                .finish(),
147            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
148        }
149    }
150}
151
152/// Camel options that should NOT be forwarded as HTTP query params
153const HTTP_CAMEL_OPTIONS: &[&str] = &[
154    "httpMethod",
155    "throwExceptionOnFailure",
156    "okStatusCodeRange",
157    "followRedirects",
158    "maxRedirects",
159    "connectTimeout",
160    "responseTimeout",
161    "allowInternal",
162    "blockedHosts",
163    "maxBodySize",
164    "readTimeout",
165    "maxResponseBytes",
166    "authMethod",
167    "authUsername",
168    "authPassword",
169    "authBearerToken",
170    "userAgent",
171    "cookieHandling",
172    "bridgeEndpoint",
173    "connectionClose",
174    "skipRequestHeaders",
175    "skipResponseHeaders",
176];
177
178impl UriConfig for HttpEndpointConfig {
179    /// Returns "http" as the primary scheme (also accepts "https")
180    fn scheme() -> &'static str {
181        "http"
182    }
183
184    fn from_uri(uri: &str) -> Result<Self, CamelError> {
185        let parts = parse_uri(uri)?;
186        Self::from_components(parts)
187    }
188
189    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
190        // Validate scheme - accept both http and https
191        if parts.scheme != "http" && parts.scheme != "https" {
192            return Err(CamelError::InvalidUri(format!(
193                "expected scheme 'http' or 'https', got '{}'",
194                parts.scheme
195            )));
196        }
197
198        // Construct base_url from scheme + path
199        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
200        let base_url = format!("{}:{}", parts.scheme, parts.path);
201
202        let http_method = parts.params.get("httpMethod").cloned();
203
204        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
205            Some(v) => parse_bool_param_http(v).map_err(|e| {
206                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
207            })?,
208            None => true,
209        };
210
211        // Parse status code range from "start-end" format (e.g., "200-299")
212        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
213            Some(v) => parse_ok_status_code_range(v)?,
214            None => (200, 299),
215        };
216
217        let response_timeout = match parts.params.get("responseTimeout") {
218            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
219                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
220            })?),
221            None => None,
222        };
223
224        // SSRF protection settings
225        let allow_internal = match parts.params.get("allowInternal") {
226            Some(v) => parse_bool_param_http(v).map_err(|e| {
227                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
228            })?,
229            None => false, // Default: block private IPs
230        };
231
232        // Parse comma-separated blocked hosts
233        let blocked_hosts = parts
234            .params
235            .get("blockedHosts")
236            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
237            .unwrap_or_default();
238
239        let max_body_size = match parts.params.get("maxBodySize") {
240            Some(v) => v.parse::<usize>().map_err(|e| {
241                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
242            })?,
243            None => 10 * 1024 * 1024, // Default: 10MB
244        };
245
246        let read_timeout_ms = match parts.params.get("readTimeout") {
247            Some(v) => v.parse::<u64>().map_err(|e| {
248                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
249            })?,
250            None => 30_000, // Default: 30s
251        };
252
253        let max_response_bytes = match parts.params.get("maxResponseBytes") {
254            Some(v) => v.parse::<usize>().map_err(|e| {
255                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
256            })?,
257            None => 10 * 1024 * 1024, // Default: 10MB
258        };
259
260        let auth = parse_auth_from_params(&parts.params)?;
261
262        let user_agent = parts.params.get("userAgent").cloned();
263
264        if parts.params.contains_key("cookieHandling") {
265            return Err(CamelError::InvalidUri(
266                "cookieHandling is not supported".into(),
267            ));
268        }
269
270        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
271            Some(v) => parse_bool_param_http(v).map_err(|e| {
272                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
273            })?,
274            None => false,
275        };
276
277        let connection_close = match parts.params.get("connectionClose") {
278            Some(v) => parse_bool_param_http(v).map_err(|e| {
279                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
280            })?,
281            None => false,
282        };
283
284        let skip_request_headers = parts
285            .params
286            .get("skipRequestHeaders")
287            .map(|v| {
288                v.split(',')
289                    .map(str::trim)
290                    .filter(|s| !s.is_empty())
291                    .map(|s| s.to_ascii_lowercase())
292                    .collect::<Vec<_>>()
293            })
294            .unwrap_or_default();
295
296        let skip_response_headers = parts
297            .params
298            .get("skipResponseHeaders")
299            .map(|v| {
300                v.split(',')
301                    .map(str::trim)
302                    .filter(|s| !s.is_empty())
303                    .map(|s| s.to_ascii_lowercase())
304                    .collect::<Vec<_>>()
305            })
306            .unwrap_or_default();
307
308        let follow_redirects = match parts.params.get("followRedirects") {
309            Some(v) => parse_bool_param_http(v).map_err(|e| {
310                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
311            })?,
312            None => false,
313        };
314
315        let max_redirects = match parts.params.get("maxRedirects") {
316            Some(v) => v.parse::<usize>().map_err(|e| {
317                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
318            })?,
319            None => 10,
320        };
321
322        // Collect remaining params (not Camel options) as query params
323        let query_params: HashMap<String, String> = parts
324            .params
325            .into_iter()
326            .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
327            .collect();
328
329        Ok(Self {
330            base_url,
331            http_method,
332            throw_exception_on_failure,
333            ok_status_code_range,
334            response_timeout,
335            query_params,
336            allow_internal,
337            blocked_hosts,
338            max_body_size,
339            read_timeout_ms,
340            max_response_bytes,
341            auth,
342            token_provider: None,
343            user_agent,
344            bridge_endpoint,
345            connection_close,
346            skip_request_headers,
347            skip_response_headers,
348            follow_redirects,
349            max_redirects,
350        })
351    }
352}
353
354/// Private container for macro-derived `uri_options()` and `metadata()`.
355///
356/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
357/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
358/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
359/// derivation targets this inner type whose fields are all URI-param-compatible.
360#[derive(Debug, Clone, UriConfig)]
361#[allow(dead_code)]
362#[uri_scheme = "http"]
363#[uri_config(
364    skip_impl,
365    metadata(
366        scheme = "http",
367        description = "HTTP client and server component",
368        producer,
369        consumer,
370        streaming
371    ),
372    crate = "camel_component_api"
373)]
374struct HttpEndpointUriConfig {
375    #[allow(dead_code)]
376    _base_url: String,
377
378    #[uri_param(
379        name = "httpMethod",
380        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
381    )]
382    http_method: Option<String>,
383
384    #[uri_param(
385        name = "throwExceptionOnFailure",
386        default = "true",
387        desc = "Throw on non-2xx status"
388    )]
389    throw_exception_on_failure: bool,
390
391    #[uri_param(
392        name = "okStatusCodeRange",
393        default = "200-299",
394        desc = "Success status code range"
395    )]
396    ok_status_code_range: String,
397
398    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
399    response_timeout: Option<u64>,
400
401    #[uri_param(
402        name = "allowInternal",
403        default = "false",
404        desc = "Allow private/internal network destinations (SSRF)"
405    )]
406    allow_internal: bool,
407
408    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
409    blocked_hosts: Option<String>,
410
411    #[uri_param(
412        name = "maxBodySize",
413        default = "10485760",
414        desc = "Max request/response body bytes"
415    )]
416    max_body_size: u64,
417
418    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
419    read_timeout: Option<u64>,
420
421    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
422    max_response_bytes: Option<u64>,
423
424    #[uri_param(
425        name = "authMethod",
426        kind = "enum:Basic,Bearer",
427        desc = "Authentication method"
428    )]
429    auth_method: Option<String>,
430
431    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
432    auth_username: Option<String>,
433
434    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
435    auth_password: Option<String>,
436
437    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
438    auth_bearer_token: Option<String>,
439
440    #[uri_param(name = "userAgent", desc = "User-Agent header")]
441    user_agent: Option<String>,
442
443    #[uri_param(
444        name = "bridgeEndpoint",
445        default = "false",
446        desc = "Bridge endpoint mode"
447    )]
448    bridge_endpoint: bool,
449
450    #[uri_param(
451        name = "connectionClose",
452        default = "false",
453        desc = "Send Connection: close"
454    )]
455    connection_close: bool,
456
457    #[uri_param(
458        name = "skipRequestHeaders",
459        desc = "Comma-separated request headers to skip"
460    )]
461    skip_request_headers: Option<String>,
462
463    #[uri_param(
464        name = "skipResponseHeaders",
465        desc = "Comma-separated response headers to skip"
466    )]
467    skip_response_headers: Option<String>,
468
469    #[uri_param(
470        name = "followRedirects",
471        default = "false",
472        desc = "Follow HTTP redirects"
473    )]
474    follow_redirects: bool,
475
476    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
477    max_redirects: u64,
478}
479
480impl HttpEndpointConfig {
481    /// Component metadata for the http/https scheme, derived from the
482    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
483    pub fn metadata() -> ComponentMetadata {
484        HttpEndpointUriConfig::metadata()
485    }
486
487    /// URI option definitions, derived from `#[uri_param]` fields.
488    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
489        HttpEndpointUriConfig::uri_options()
490    }
491}
492
493fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
494    let Some(method) = params.get("authMethod") else {
495        return Ok(HttpAuth::None);
496    };
497
498    if method.eq_ignore_ascii_case("none") {
499        return Ok(HttpAuth::None);
500    }
501
502    if method.eq_ignore_ascii_case("basic") {
503        let username = params.get("authUsername").cloned().ok_or_else(|| {
504            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
505        })?;
506        let password = params.get("authPassword").cloned().ok_or_else(|| {
507            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
508        })?;
509        return Ok(HttpAuth::Basic { username, password });
510    }
511
512    if method.eq_ignore_ascii_case("bearer") {
513        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
514            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
515        })?;
516        return Ok(HttpAuth::Bearer { token });
517    }
518
519    Err(CamelError::InvalidUri(format!(
520        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
521    )))
522}
523
524fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
525    match value.to_ascii_lowercase().as_str() {
526        "true" | "1" | "yes" => Ok(true),
527        "false" | "0" | "no" => Ok(false),
528        _ => Err(CamelError::InvalidUri(format!(
529            "invalid boolean value: '{value}'"
530        ))),
531    }
532}
533
534impl HttpEndpointConfig {
535    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
536        let parts = parse_uri(uri)?;
537        let mut endpoint = Self::from_components(parts.clone())?;
538        if endpoint.response_timeout.is_none() {
539            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
540        }
541        if !parts.params.contains_key("allowInternal") {
542            endpoint.allow_internal = config.allow_internal;
543        }
544        if !parts.params.contains_key("blockedHosts") {
545            endpoint.blocked_hosts = config.blocked_hosts.clone();
546        }
547        if !parts.params.contains_key("maxBodySize") {
548            endpoint.max_body_size = config.max_body_size;
549        }
550        if !parts.params.contains_key("readTimeout") {
551            endpoint.read_timeout_ms = config.read_timeout_ms;
552        }
553        if !parts.params.contains_key("maxResponseBytes") {
554            endpoint.max_response_bytes = config.max_response_bytes;
555        }
556        if !parts.params.contains_key("okStatusCodeRange")
557            && let Some(range) = &config.ok_status_code_range
558        {
559            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
560        }
561        if !parts.params.contains_key("followRedirects") {
562            endpoint.follow_redirects = config.follow_redirects;
563        }
564        if !parts.params.contains_key("maxRedirects") {
565            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
566        }
567
568        Ok(endpoint)
569    }
570}
571
572// ---------------------------------------------------------------------------
573// HttpServerConfig
574// ---------------------------------------------------------------------------
575
576/// Configuration for an HTTP server (consumer) endpoint.
577#[derive(Debug, Clone)]
578pub struct HttpServerConfig {
579    /// URI scheme ("http" or "https") parsed from the endpoint URI.
580    pub scheme: String,
581    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
582    pub host: String,
583    /// TCP port to listen on.
584    pub port: u16,
585    /// URL path this consumer handles, e.g. "/orders".
586    pub path: String,
587    /// Maximum request body size in bytes.
588    pub max_request_body: usize,
589    /// Maximum response body size for materializing streams in bytes.
590    pub max_response_body: usize,
591    /// Maximum number of in-flight requests handled concurrently by this server.
592    pub max_inflight_requests: usize,
593    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
594    /// the consumer registers as a method-aware REST endpoint and the
595    /// path is treated as a template (e.g. `/users/{id}` is matched
596    /// against any `/users/<value>`). When `None`, the consumer
597    /// registers in the legacy path-only `api_routes` registry.
598    /// Extracted from the `httpMethod=` URI param at config build time.
599    pub method: Option<String>,
600    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
601    /// `None` for plain HTTP servers.
602    pub tls_config: Option<crate::config::ServerTlsConfig>,
603}
604
605impl UriConfig for HttpServerConfig {
606    /// Returns "http" as the primary scheme (also accepts "https")
607    fn scheme() -> &'static str {
608        "http"
609    }
610
611    fn from_uri(uri: &str) -> Result<Self, CamelError> {
612        let parts = parse_uri(uri)?;
613        Self::from_components(parts)
614    }
615
616    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
617        // Validate scheme - accept both http and https
618        if parts.scheme != "http" && parts.scheme != "https" {
619            return Err(CamelError::InvalidUri(format!(
620                "expected scheme 'http' or 'https', got '{}'",
621                parts.scheme
622            )));
623        }
624
625        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
626        // Strip leading "//"
627        let authority_and_path = parts.path.trim_start_matches('/');
628
629        // Split on the first "/" to separate "host:port" from "/path"
630        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
631            (&authority_and_path[..idx], &authority_and_path[idx..])
632        } else {
633            (authority_and_path, "/")
634        };
635
636        let path = if path_suffix.is_empty() {
637            "/"
638        } else {
639            path_suffix
640        }
641        .to_string();
642
643        // Parse host:port from authority
644        let (host, port) = if let Some(colon) = authority.rfind(':') {
645            let port_str = &authority[colon + 1..];
646            match port_str.parse::<u16>() {
647                Ok(p) => (authority[..colon].to_string(), p),
648                Err(_) => {
649                    return Err(CamelError::InvalidUri(format!(
650                        "invalid port '{}' in authority",
651                        port_str
652                    )));
653                }
654            }
655        } else {
656            // Default port based on scheme: 443 for https, 80 for http
657            let default_port = if parts.scheme == "https" { 443 } else { 80 };
658            (authority.to_string(), default_port)
659        };
660
661        let max_request_body = parts
662            .params
663            .get("maxRequestBody")
664            .and_then(|v| v.parse::<usize>().ok())
665            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
666
667        let max_response_body = parts
668            .params
669            .get("maxResponseBody")
670            .and_then(|v| v.parse::<usize>().ok())
671            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
672
673        let max_inflight_requests = parts
674            .params
675            .get("maxInflightRequests")
676            .and_then(|v| v.parse::<usize>().ok())
677            .unwrap_or(1024);
678
679        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
680        // uppercase method the dispatcher compares against (axum's
681        // `req.method().to_string()` yields "GET"). Without this, a
682        // lower-case `httpMethod` would never match and silently 404.
683        // Review I5.
684        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
685
686        Ok(Self {
687            scheme: parts.scheme,
688            host,
689            port,
690            path,
691            max_request_body,
692            max_response_body,
693            max_inflight_requests,
694            method,
695            tls_config: {
696                let cert = parts.params.get("tlsCert").cloned();
697                let key = parts.params.get("tlsKey").cloned();
698                match (cert, key) {
699                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
700                        cert_path: c,
701                        key_path: k,
702                    }),
703                    (None, None) => None,
704                    _ => None, // partial — enforced in create_consumer, not here
705                }
706            },
707        })
708    }
709}
710
711impl HttpServerConfig {
712    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
713        let parts = parse_uri(uri)?;
714        let mut server = Self::from_components(parts.clone())?;
715        if !parts.params.contains_key("maxRequestBody") {
716            server.max_request_body = config.max_request_body;
717        }
718        if !parts.params.contains_key("maxResponseBody") {
719            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
720            server.max_response_body = config.max_body_size;
721        }
722        Ok(server)
723    }
724}
725
726// ---------------------------------------------------------------------------
727// RequestEnvelope / HttpReply
728// ---------------------------------------------------------------------------
729
730/// Body of the HTTP response: already-materialized bytes or a lazy stream.
731///
732/// **Internal plumbing** — subject to change without notice.
733pub enum HttpReplyBody {
734    Bytes(bytes::Bytes),
735    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
736}
737
738/// An inbound HTTP request sent from the Axum dispatch handler to an
739/// `HttpConsumer` receive loop.
740///
741/// **Internal plumbing** — subject to change without notice.
742pub struct RequestEnvelope {
743    pub method: String,
744    pub path: String,
745    pub query: String,
746    pub headers: http::HeaderMap,
747    pub body: StreamBody,
748    /// Path parameters extracted from a REST template match, e.g.
749    /// `id=42` for a request to `/users/42` matched against
750    /// `/users/{id}`. Empty for non-REST requests or for literal
751    /// template matches. The consumer turns these into
752    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
753    pub path_params: std::collections::HashMap<String, String>,
754    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
755}
756
757/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
758///
759/// **Internal plumbing** — subject to change without notice.
760pub struct HttpReply {
761    pub status: u16,
762    pub headers: Vec<(String, String)>,
763    pub body: HttpReplyBody,
764}
765
766// ---------------------------------------------------------------------------
767// HttpRouteRegistry / ServerRegistry
768// ---------------------------------------------------------------------------
769
770type ServerKey = (String, u16);
771
772/// Handle to a running Axum server on one interface/port.
773struct ServerHandle {
774    registry: HttpRouteRegistry,
775    max_request_body: usize,
776    max_response_body: usize,
777    max_inflight_requests: usize,
778    is_tls: bool,
779    tls_cert_path: Option<String>,
780    tls_key_path: Option<String>,
781    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
782    /// dead-server eviction signal in `get_or_spawn`.
783    monitor_task: tokio::task::JoinHandle<()>,
784    // Retained so the reload handler (Task 7) can call reload_from_config()
785    // to hot-swap certs without restarting the server.
786    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
787    tls_source: Option<ServerTlsSource>,
788}
789
790/// Process-global registry mapping (host, port) → running Axum server handle.
791pub struct ServerRegistry {
792    inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
793}
794
795impl ServerRegistry {
796    /// Returns the global singleton.
797    pub fn global() -> &'static Self {
798        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
799        INSTANCE.get_or_init(|| ServerRegistry {
800            inner: Mutex::new(HashMap::new()),
801        })
802    }
803
804    /// Returns route registry for `port`, spawning new Axum server if
805    /// none is running on that port yet.
806    #[allow(clippy::too_many_arguments)]
807    pub async fn get_or_spawn(
808        &'static self,
809        host: &str,
810        port: u16,
811        max_request_body: usize,
812        max_response_body: usize,
813        max_inflight_requests: usize,
814        runtime: Arc<dyn RuntimeObservability>,
815        route_id: String,
816        tls_config: Option<crate::config::ServerTlsConfig>,
817    ) -> Result<HttpRouteRegistry, CamelError> {
818        let host_owned = host.to_string();
819
820        let cell = {
821            let mut guard = self.inner.lock().map_err(|_| {
822                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
823            })?;
824            let key = (host.to_string(), port);
825            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
826            // The monitor task awaits the server task, so monitor_task.is_finished()
827            // is a reliable proxy for the server being gone (either crashed or aborted).
828            if let Some(existing) = guard.get(&key)
829                && let Some(handle) = existing.get()
830                && handle.monitor_task.is_finished()
831            {
832                // Deregister TLS reload handler so a respawned HTTPS server
833                // doesn't reload stale cert config from the crashed handler.
834                if handle.is_tls {
835                    let scheme = if handle.is_tls { "https" } else { "http" };
836                    camel_component_api::tls_source::TlsReloadRegistry::global()
837                        .unregister(scheme, host, port);
838                }
839                guard.remove(&key);
840            }
841            guard
842                .entry(key)
843                .or_insert_with(|| Arc::new(OnceCell::new()))
844                .clone()
845        };
846
847        if let Some(existing) = cell.get()
848            && existing.max_request_body != max_request_body
849        {
850            return Err(CamelError::EndpointCreationFailed(format!(
851                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
852                existing.max_request_body, max_request_body
853            )));
854        }
855
856        if let Some(existing) = cell.get()
857            && existing.max_response_body != max_response_body
858        {
859            return Err(CamelError::EndpointCreationFailed(format!(
860                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
861                existing.max_response_body, max_response_body
862            )));
863        }
864
865        if let Some(existing) = cell.get()
866            && existing.max_inflight_requests != max_inflight_requests
867        {
868            return Err(CamelError::EndpointCreationFailed(format!(
869                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
870                existing.max_inflight_requests, max_inflight_requests
871            )));
872        }
873
874        // TLS mode mismatch: plain vs TLS
875        if let Some(existing) = cell.get()
876            && existing.is_tls != tls_config.is_some()
877        {
878            return Err(CamelError::EndpointCreationFailed(format!(
879                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
880                existing.is_tls,
881                tls_config.is_some()
882            )));
883        }
884
885        // TLS cert/key mismatch: different cert on same TLS port
886        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
887            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
888                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
889        {
890            return Err(CamelError::EndpointCreationFailed(format!(
891                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
892            )));
893        }
894
895        let handle = cell
896            .get_or_try_init(|| {
897                let rt = Arc::clone(&runtime);
898                let rid = route_id.clone();
899                async move {
900                    let addr = format!("{host_owned}:{port}");
901                    let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
902                        CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
903                    })?;
904                    let registry = HttpRouteRegistry::new();
905                    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
906                    // Constructed once in the TLS branch so they can be retained
907                    // on ServerHandle for the reload handler (Task 7).
908                    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
909                    let tls_source: Option<ServerTlsSource>;
910                    let server_task = if let Some(ref tls) = tls_config {
911                        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
912                        let source = ServerTlsSource {
913                            cert_path: std::path::PathBuf::from(&tls.cert_path),
914                            key_path: std::path::PathBuf::from(&tls.key_path),
915                            client_ca_path: None,
916                        };
917                        // Build the RustlsConfig once — clone() is cheap (Arc
918                        // internally) and shares the ArcSwap the reload handler
919                        // will mutate via reload_from_config().
920                        let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
921                            std::sync::Arc::new(rustls_config),
922                        );
923                        tls_rustls_cfg = Some(rustls_cfg.clone());
924                        tls_source = Some(source);
925                        // Convert tokio listener to std for axum-server
926                        let std_listener = listener.into_std().map_err(|e| {
927                            CamelError::EndpointCreationFailed(format!(
928                                "TLS listener conversion: {e}"
929                            ))
930                        })?;
931                        tokio::spawn(run_axum_server_tls(
932                            std_listener,
933                            rustls_cfg,
934                            registry.clone(),
935                            max_request_body,
936                            max_response_body,
937                            Arc::clone(&inflight),
938                            Arc::clone(&rt),
939                            rid.clone(),
940                        ))
941                    } else {
942                        tls_rustls_cfg = None;
943                        tls_source = None;
944                        tokio::spawn(run_axum_server(
945                            listener,
946                            registry.clone(),
947                            max_request_body,
948                            max_response_body,
949                            Arc::clone(&inflight),
950                            Arc::clone(&rt),
951                            rid.clone(),
952                        ))
953                    };
954                    let addr_for_monitor = format!("{host_owned}:{port}");
955                    let monitor_task = tokio::spawn(monitor_axum_task(
956                        server_task,
957                        addr_for_monitor,
958                        Arc::clone(&rt),
959                        rid,
960                    ));
961                    let handle = ServerHandle {
962                        registry,
963                        max_request_body,
964                        max_response_body,
965                        max_inflight_requests,
966                        is_tls: tls_config.is_some(),
967                        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
968                        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
969                        monitor_task,
970                        tls_config: tls_rustls_cfg,
971                        tls_source,
972                    };
973                    // Register reload handler (exactly-once: inside OnceCell init closure).
974                    // Note: HTTP servers are process-lifetime (no release/eviction path),
975                    // so handlers are never unregistered. If eviction is added later,
976                    // add TlsReloadRegistry::global().unregister() there.
977                    if let (Some(tls_cfg), Some(source)) =
978                        (handle.tls_config.as_ref(), handle.tls_source.as_ref())
979                    {
980                        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
981                            tls_cfg.clone(),
982                            source.clone(),
983                            host_owned.clone(),
984                            port,
985                        ));
986                        camel_component_api::tls_source::TlsReloadRegistry::global()
987                            .register(handler);
988                    }
989                    Ok::<ServerHandle, CamelError>(handle)
990                }
991            })
992            .await?;
993
994        Ok(handle.registry.clone())
995    }
996
997    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
998    /// the server stays in the registry for potential restart. Path
999    /// deregistration happens separately in the consumer's cleanup.
1000    pub async fn unregister(&self, host: &str, port: u16) {
1001        debug!(
1002            host = host,
1003            port = port,
1004            "consumer unregistered from HTTP server"
1005        );
1006    }
1007
1008    /// Reset the global registry — **test-only**.
1009    ///
1010    /// Clears all registered server handles so that tests can start from a clean
1011    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1012    /// process-global singleton in production and resetting it would break
1013    /// running servers.
1014    #[cfg(test)]
1015    pub fn reset() {
1016        let instance = Self::global();
1017        let mut guard = instance
1018            .inner
1019            .lock()
1020            .expect("ServerRegistry lock poisoned during test reset");
1021        guard.clear();
1022    }
1023}
1024
1025// ---------------------------------------------------------------------------
1026// Axum server
1027// ---------------------------------------------------------------------------
1028
1029use axum::{
1030    Router,
1031    body::Body as AxumBody,
1032    extract::{Request, State},
1033    http::{Response, StatusCode},
1034    response::IntoResponse,
1035};
1036
1037#[derive(Clone)]
1038pub(crate) struct AppState {
1039    registry: HttpRouteRegistry,
1040    max_request_body: usize,
1041    max_response_body: usize,
1042    inflight: Arc<tokio::sync::Semaphore>,
1043}
1044
1045async fn run_axum_server(
1046    listener: tokio::net::TcpListener,
1047    registry: HttpRouteRegistry,
1048    max_request_body: usize,
1049    max_response_body: usize,
1050    inflight: Arc<tokio::sync::Semaphore>,
1051    runtime: Arc<dyn RuntimeObservability>,
1052    route_id: String,
1053) {
1054    let state = AppState {
1055        registry,
1056        max_request_body,
1057        max_response_body,
1058        inflight,
1059    };
1060    let app = Router::new().fallback(dispatch_handler).with_state(state);
1061
1062    axum::serve(listener, app).await.unwrap_or_else(|e| {
1063        runtime
1064            .metrics()
1065            .increment_errors(&route_id, "e:http:accept");
1066        // log-policy: outside-contract
1067        tracing::error!(error = %e, "Axum server error");
1068    });
1069}
1070
1071#[allow(clippy::too_many_arguments)]
1072async fn run_axum_server_tls(
1073    listener: std::net::TcpListener,
1074    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1075    registry: HttpRouteRegistry,
1076    max_request_body: usize,
1077    max_response_body: usize,
1078    inflight: Arc<tokio::sync::Semaphore>,
1079    runtime: Arc<dyn RuntimeObservability>,
1080    route_id: String,
1081) {
1082    let state = AppState {
1083        registry,
1084        max_request_body,
1085        max_response_body,
1086        inflight,
1087    };
1088    let app = Router::new().fallback(dispatch_handler).with_state(state);
1089
1090    // RustlsConfig is now constructed once in get_or_spawn and retained on
1091    // ServerHandle so the reload handler can call reload_from_config() on it.
1092
1093    axum_server::from_tcp_rustls(listener, tls_cfg)
1094        .serve(app.into_make_service())
1095        .await
1096        .unwrap_or_else(|e| {
1097            runtime
1098                .metrics()
1099                .increment_errors(&route_id, "e:http:accept-tls");
1100            // log-policy: outside-contract
1101            tracing::error!(error = %e, "Axum TLS server error");
1102        });
1103}
1104
1105/// Monitors an Axum server task and emits a structured error event if it
1106/// exits unexpectedly.
1107///
1108/// # Limitations
1109/// The HTTP server is shared across all routes on a port. Full per-route
1110/// CrashNotification propagation is deferred — this provides observable
1111/// structured logging as a first guard.
1112async fn monitor_axum_task(
1113    handle: tokio::task::JoinHandle<()>,
1114    addr: String,
1115    runtime: Arc<dyn RuntimeObservability>,
1116    route_id: String,
1117) {
1118    match handle.await {
1119        Ok(()) => {
1120            // Clean exit (process shutdown or normal stop)
1121        }
1122        Err(join_err) => {
1123            runtime
1124                .metrics()
1125                .increment_errors(&route_id, "e:http:server-task-exited");
1126            // log-policy: outside-contract
1127            tracing::error!(
1128                addr = %addr,
1129                error = %join_err,
1130                "Axum server task exited unexpectedly — all routes on this port are now dead"
1131            );
1132        }
1133    }
1134}
1135
1136/// Load a rustls ServerConfig from PEM cert/key files.
1137/// Adapted from camel-ws lib.rs load_tls_config.
1138fn load_tls_config(
1139    cert_path: &str,
1140    key_path: &str,
1141) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1142    use std::fs::File;
1143    use std::io::BufReader;
1144
1145    let cert_file = File::open(cert_path)
1146        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1147    let key_file = File::open(key_path)
1148        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1149
1150    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1151        .collect::<Result<Vec<_>, _>>()
1152        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1153
1154    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1155        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1156        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1157
1158    tokio_rustls::rustls::ServerConfig::builder()
1159        .with_no_client_auth()
1160        .with_single_cert(certs, key)
1161        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1162}
1163
1164async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1165    let path = req.uri().path().to_owned();
1166    let method = req.method().to_string();
1167
1168    // Dispatch precedence (spec §7.2 / ADR-0009):
1169    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1170    //   2. Templated API path match (REST, method-aware, by specificity)
1171    //   3. Static mount longest-prefix
1172    //   4. SPA fallback
1173    //
1174    // Legacy exact runs first: it is a cheap HashMap get, and the two
1175    // registries are mutually exclusive per route — a legacy route carries
1176    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1177    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1178    // exact hit can never shadow a REST route that should have matched,
1179    // and running exact-first honours the documented precedence (the prior
1180    // REST-first order let a templated `GET /api/{resource}` steal a
1181    // request meant for an exact `GET /api/users`). Intra-REST method
1182    // disambiguation is handled inside `match_endpoint`, not by this
1183    // ordering. Review C2.
1184    let api_sender = {
1185        let inner = state.registry.inner.read().await;
1186        inner.api_routes.get(&path).cloned()
1187    }; // lock released BEFORE any IO
1188
1189    let (rest_sender, path_params) = if api_sender.is_some() {
1190        // Exact legacy match won — skip the templated scan entirely.
1191        (None, Default::default())
1192    } else {
1193        let inner = state.registry.inner.read().await;
1194        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1195            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1196            rest_match::MatchOutcome::Ambiguous => {
1197                // Ambiguous registration should have been rejected at
1198                // lowering time (rest.rs). Reaching here means two
1199                // equal-specificity templates matched one request —
1200                // surface a loud error rather than a silent 404. Review C3.
1201                // log-policy: handler-owned
1202                tracing::warn!(
1203                    method = %method,
1204                    path = %path,
1205                    "ambiguous REST template match — returning 500"
1206                );
1207                return Response::builder()
1208                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1209                    .body(AxumBody::from("Internal Server Error"))
1210                    .expect("infallible"); // allow-unwrap
1211            }
1212            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1213        }
1214    }; // lock released BEFORE any IO
1215
1216    let sender = api_sender.or(rest_sender);
1217
1218    if let Some(sender) = sender {
1219        let query = req.uri().query().unwrap_or("").to_string();
1220        let headers = req.headers().clone();
1221
1222        // Check Content-Length against limit BEFORE opening the stream
1223        let content_length: Option<u64> = headers
1224            .get(http::header::CONTENT_LENGTH)
1225            .and_then(|v| v.to_str().ok())
1226            .and_then(|s| s.parse().ok());
1227
1228        if let Some(len) = content_length
1229            && len > state.max_request_body as u64
1230        {
1231            return Response::builder()
1232                .status(StatusCode::PAYLOAD_TOO_LARGE)
1233                .body(AxumBody::from("Request body exceeds configured limit"))
1234                .expect("infallible"); // allow-unwrap
1235        }
1236
1237        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1238            Ok(permit) => permit,
1239            Err(_) => {
1240                return Response::builder()
1241                    .status(StatusCode::SERVICE_UNAVAILABLE)
1242                    .body(AxumBody::from("Service Unavailable"))
1243                    .expect("infallible"); // allow-unwrap
1244            }
1245        };
1246
1247        // Build StreamBody from Axum body WITHOUT materializing
1248        let content_type = headers
1249            .get(http::header::CONTENT_TYPE)
1250            .and_then(|v| v.to_str().ok())
1251            .map(|s| s.to_string());
1252
1253        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1254        let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1255        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1256
1257        let stream_body = StreamBody {
1258            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1259            metadata: StreamMetadata {
1260                size_hint: content_length,
1261                content_type,
1262                origin: None,
1263            },
1264        };
1265
1266        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1267        let envelope = RequestEnvelope {
1268            method,
1269            path,
1270            query,
1271            headers,
1272            body: stream_body,
1273            path_params,
1274            reply_tx,
1275        };
1276
1277        if sender.send(envelope).await.is_err() {
1278            return Response::builder()
1279                .status(StatusCode::SERVICE_UNAVAILABLE)
1280                .body(AxumBody::from("Consumer unavailable"))
1281                .expect("infallible"); // allow-unwrap
1282        }
1283
1284        match reply_rx.await {
1285            Ok(reply) => {
1286                let reply = match reply.body {
1287                    HttpReplyBody::Bytes(b)
1288                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1289                    {
1290                        HttpReply {
1291                            status: 500,
1292                            headers: vec![],
1293                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1294                                "Response body exceeds configured limit",
1295                            )),
1296                        }
1297                    }
1298                    _ => reply,
1299                };
1300
1301                let status =
1302                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1303                let mut builder = Response::builder().status(status);
1304                for (k, v) in &reply.headers {
1305                    builder = builder.header(k.as_str(), v.as_str());
1306                }
1307                match reply.body {
1308                    HttpReplyBody::Bytes(b) => {
1309                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1310                            Response::builder()
1311                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1312                                .body(AxumBody::from("Invalid response headers from consumer"))
1313                                .expect("infallible") // allow-unwrap
1314                        })
1315                    }
1316                    HttpReplyBody::Stream(stream) => builder
1317                        .body(AxumBody::from_stream(stream))
1318                        .unwrap_or_else(|_| {
1319                            Response::builder()
1320                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1321                                .body(AxumBody::from("Invalid response headers from consumer"))
1322                                .expect("infallible") // allow-unwrap
1323                        }),
1324                }
1325            }
1326            Err(_) => Response::builder()
1327                .status(StatusCode::INTERNAL_SERVER_ERROR)
1328                .body(AxumBody::from("Pipeline error"))
1329                .expect("infallible"), // allow-unwrap
1330        }
1331    } else {
1332        // No API route matched — try static mounts
1333        static_dispatch::dispatch_static(&state, req, &path).await
1334    }
1335}
1336
1337fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1338    len > max
1339}
1340
1341fn title_case_header(name: &str) -> String {
1342    name.split('-')
1343        .map(|part| {
1344            let mut chars = part.chars();
1345            match chars.next() {
1346                None => String::new(),
1347                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1348            }
1349        })
1350        .collect::<Vec<_>>()
1351        .join("-")
1352}
1353
1354// ---------------------------------------------------------------------------
1355// HttpConsumer
1356// ---------------------------------------------------------------------------
1357
1358/// Kernel authentication state captured from a route's [`SecurityContext`]
1359/// (`unify-transport-auth`, Task 2.9).
1360///
1361/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1362/// the compiled plan and the provider registry arrive via
1363/// `Consumer::set_security_context` before `start()` accepts requests. A
1364/// context lacking either piece keeps `kernel = None` — a plan without
1365/// providers can never mint a principal (fail-closed, never a silently
1366/// unauthenticated route: the controller's strict-mode dispatch check then
1367/// denies carrier-less Exchanges on non-Public plans).
1368pub(crate) struct HttpKernelAuth {
1369    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1370    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1371}
1372
1373impl HttpKernelAuth {
1374    /// Capture the kernel state from a route's security context.
1375    ///
1376    /// `None` unless both the compiled plan and the provider registry are
1377    /// present.
1378    pub(crate) fn from_security_context(
1379        ctx: &camel_component_api::SecurityContext,
1380    ) -> Option<Self> {
1381        Some(Self {
1382            plan: ctx.plan.clone()?,
1383            providers: ctx.providers.clone()?,
1384        })
1385    }
1386}
1387
1388/// Capacity for the per-route RequestEnvelope channel.
1389///
1390/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1391/// permit from before `send()` until its reply, so at most N envelopes can be
1392/// outstanding at any time. A buffer of N therefore can never fill before the
1393/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1394/// and the semaphore stays the single, URI-configurable backpressure point.
1395/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1396/// (rc-3y6j: 64 vs default 1024 permits).
1397///
1398/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1399/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1400/// start panic-free (the empty semaphore still 503s every request).
1401fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1402    max_inflight_requests.max(1)
1403}
1404
1405pub struct HttpConsumer {
1406    config: HttpServerConfig,
1407    /// Runtime observability handle for ADR-0012 metrics and health calls.
1408    runtime: Arc<dyn RuntimeObservability>,
1409    /// Kernel authentication state (plan + providers), set via
1410    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1411    /// without route-level security (Public under the per-bind gate).
1412    kernel: Option<Arc<HttpKernelAuth>>,
1413}
1414
1415impl HttpConsumer {
1416    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1417        Self {
1418            config,
1419            runtime,
1420            kernel: None,
1421        }
1422    }
1423}
1424
1425#[async_trait::async_trait]
1426impl Consumer for HttpConsumer {
1427    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1428        use camel_component_api::{Body, Exchange, Message};
1429
1430        let registry = ServerRegistry::global()
1431            .get_or_spawn(
1432                &self.config.host,
1433                self.config.port,
1434                self.config.max_request_body,
1435                self.config.max_response_body,
1436                self.config.max_inflight_requests,
1437                self.runtime.clone(),
1438                ctx.route_id().to_string(),
1439                self.config.tls_config.clone(),
1440            )
1441            .await?;
1442
1443        // Create channel for this path and register it. Capacity matches the
1444        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1445        // the channel can never become a second backpressure point.
1446        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1447            envelope_channel_capacity(self.config.max_inflight_requests),
1448        );
1449        // When the from-URI carries `httpMethod=...` (REST-lowered
1450        // route), register the consumer as a method-aware REST endpoint
1451        // so the dispatcher can route by (method, path template).
1452        // Otherwise fall back to the legacy path-only api_routes
1453        // registry. The two registries never overlap for the same
1454        // route: each consumer registers in exactly one of them.
1455        if let Some(method) = self.config.method.clone() {
1456            let segments = rest_match::parse_path_template(&self.config.path);
1457            registry
1458                .register_rest_endpoint(method, segments, env_tx)
1459                .await;
1460        } else {
1461            registry
1462                .register_api_route(self.config.path.clone(), env_tx)
1463                .await;
1464        }
1465
1466        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1467        // (inside get_or_spawn above), (2) the axum server task was spawned,
1468        // and (3) this route's path/REST endpoint was registered. At this
1469        // point the listener is genuinely accepting connections and any
1470        // request to this route will be dispatched (not 404'd). The runtime
1471        // uses this signal to publish RouteStarted and to release
1472        // ctx.start() so external benchmarks can emit a reliable
1473        // listener-bound marker.
1474        ctx.mark_ready();
1475
1476        let path = self.config.path.clone();
1477        let registry_for_cleanup = registry.clone();
1478        let cancel_token = ctx.cancel_token();
1479        let kernel = self.kernel.clone();
1480        loop {
1481            tokio::select! {
1482                _ = ctx.cancelled() => {
1483                    break;
1484                }
1485                envelope = env_rx.recv() => {
1486                    let Some(envelope) = envelope else { break; };
1487
1488                    // Build Exchange from HTTP request
1489                    let mut msg = Message::default();
1490
1491                    // Set standard Camel HTTP headers
1492                    msg.set_header("CamelHttpMethod",
1493                        serde_json::Value::String(envelope.method.clone()));
1494                    msg.set_header("CamelHttpPath",
1495                        serde_json::Value::String(envelope.path.clone()));
1496                    msg.set_header("CamelHttpQuery",
1497                        serde_json::Value::String(envelope.query.clone()));
1498
1499                    // Set path-parameter headers from REST template
1500                    // match. Expert guidance E2: the consumer is
1501                    // responsible for translating the dispatcher's
1502                    // matched params into `CamelHttpPath_<param>`
1503                    // headers on the Exchange, matching the convention
1504                    // used by Camel HTTP for templated routes.
1505                    for (param_name, param_value) in &envelope.path_params {
1506                        msg.set_header(
1507                            format!("CamelHttpPath_{param_name}"),
1508                            serde_json::Value::String(param_value.clone()),
1509                        );
1510                    }
1511
1512                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1513                    for (k, v) in &envelope.headers {
1514                        if let Ok(val_str) = v.to_str() {
1515                            msg.set_header(
1516                                title_case_header(k.as_str()),
1517                                serde_json::Value::String(val_str.to_string()),
1518                            );
1519                        }
1520                    }
1521
1522                    // Body: always arrives as Body::Stream (native streaming)
1523                    // Routes can call into_bytes() if they need to materialize
1524                    msg.body = Body::Stream(envelope.body);
1525
1526                    #[allow(unused_mut)]
1527                    let mut exchange = Exchange::new(msg);
1528
1529                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1530                    #[cfg(feature = "otel")]
1531                    {
1532                        let headers: HashMap<String, String> = envelope
1533                            .headers
1534                            .iter()
1535                            .filter_map(|(k, v)| {
1536                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1537                            })
1538                            .collect();
1539                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1540                    }
1541
1542                    let reply_tx = envelope.reply_tx;
1543                    let sender = ctx.sender().clone();
1544                    let path_clone = path.clone();
1545                    let cancel = cancel_token.clone();
1546                    // Task 2.9 boundary-auth inputs: the raw header map and
1547                    // the request URI (path + query) feed kernel credential
1548                    // extraction inside the per-request task.
1549                    let auth_headers = envelope.headers.clone();
1550                    let auth_uri: http::Uri = {
1551                        let full = if envelope.query.is_empty() {
1552                            envelope.path.clone()
1553                        } else {
1554                            format!("{}?{}", envelope.path, envelope.query)
1555                        };
1556                        // A malformed path cannot become a valid `Uri`; the
1557                        // empty default then carries no credentials, so
1558                        // extraction finds nothing and authn fails closed.
1559                        full.parse().unwrap_or_default()
1560                    };
1561                    let kernel = kernel.clone();
1562
1563                    // Spawn a task to handle this request concurrently
1564                    //
1565                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1566                    // true concurrent request processing. This change was introduced as part of the
1567                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1568                    //
1569                    // Rationale:
1570                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1571                    //    the consumer's main loop until the pipeline processing completes
1572                    // 2. This blocking would prevent multiple HTTP requests from being processed
1573                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1574                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1575                    //    defeating the purpose of pipeline-side concurrency
1576                    // 4. By spawning a task per request, we allow the consumer loop to continue
1577                    //    accepting new requests while existing ones are processed in the pipeline
1578                    //
1579                    // This approach effectively decouples request acceptance from pipeline processing,
1580                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1581                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1582                    tokio::spawn(async move {
1583                        // Check for cancellation before sending to pipeline.
1584                        // Returns 503 (Service Unavailable) instead of letting the request
1585                        // enter a shutting-down pipeline. This is a behavioral change from
1586                        // the pre-concurrency implementation where cancellation during
1587                        // processing would result in a 500 (Internal Server Error).
1588                        // 503 is more semantically correct: the server is temporarily
1589                        // unable to handle the request due to shutdown.
1590                        if cancel.is_cancelled() {
1591                            let _ = reply_tx.send(HttpReply {
1592                                status: 503,
1593                                headers: vec![],
1594                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1595                            });
1596                            return;
1597                        }
1598
1599                        // ADR-0061 Task 2.9: kernel authentication at the
1600                        // request boundary. A `Public` plan passes through
1601                        // with no extraction; any other mode extracts per
1602                        // the plan's sources, authenticates through the
1603                        // kernel, and installs the typed carrier BEFORE the
1604                        // pipeline runs. A denial renders in the HTTP idiom
1605                        // (401 via `pipeline_error_to_reply`) and the route
1606                        // body never sees the request.
1607                        if let Some(kernel) = kernel.as_ref()
1608                            && !matches!(
1609                                kernel.plan.access_mode,
1610                                camel_api::security_policy::AccessMode::Public
1611                            )
1612                        {
1613                            let principal = match camel_auth::extract_token_multi(
1614                                &auth_headers,
1615                                &auth_uri,
1616                                &kernel.plan.credential_sources,
1617                            ) {
1618                                Some(extracted) => {
1619                                    match camel_auth::kernel_authenticate(
1620                                        &kernel.plan,
1621                                        &kernel.providers,
1622                                        &extracted,
1623                                    )
1624                                    .await
1625                                    {
1626                                        Ok(principal) => principal,
1627                                        Err(e) => {
1628                                            // log-policy: handler-owned
1629                                            tracing::warn!(
1630                                                path = %path_clone,
1631                                                error = %e,
1632                                                "HTTP request authentication failed"
1633                                            );
1634                                            let _ = reply_tx.send(pipeline_error_to_reply(
1635                                                e,
1636                                                &path_clone,
1637                                            ));
1638                                            return;
1639                                        }
1640                                    }
1641                                }
1642                                None => {
1643                                    // log-policy: handler-owned
1644                                    tracing::warn!(
1645                                        path = %path_clone,
1646                                        "HTTP request rejected: no credential found in any source"
1647                                    );
1648                                    let _ = reply_tx.send(pipeline_error_to_reply(
1649                                        CamelError::Unauthenticated(
1650                                            "no credential found in any source".to_string(),
1651                                        ),
1652                                        &path_clone,
1653                                    ));
1654                                    return;
1655                                }
1656                            };
1657                            camel_auth::install_carrier(&mut exchange, &principal);
1658                        }
1659
1660                        // Send through pipeline and await result
1661                        let (tx, rx) = tokio::sync::oneshot::channel();
1662                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1663                            exchange,
1664                            reply_tx: Some(tx),
1665                        };
1666
1667                        let result = match sender.send(envelope).await {
1668                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1669                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1670                        }
1671                        .and_then(|r| r);
1672
1673                        let reply = match result {
1674                            Ok(out) => {
1675                                let status = out
1676                                    .input
1677                                    .header("CamelHttpResponseCode")
1678                                    .and_then(|v| {
1679                                        let raw = v.as_u64()
1680                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1681                                        let code = raw as u16;
1682                                        (100..1000).contains(&code).then_some(code)
1683                                    })
1684                                    .unwrap_or(200);
1685
1686                                let user_content_type = out
1687                                    .input
1688                                    .header("Content-Type")
1689                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1690
1691                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1692                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1693                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1694                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1695                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1696                                        v.to_string().into_bytes(),
1697                                    )), Some("application/json".to_string())),
1698                                    Body::Stream(s) => {
1699                                        let ct = s.metadata.content_type.clone();
1700                                        match s.stream.lock().await.take() {
1701                                            Some(stream) => (
1702                                                HttpReplyBody::Stream(stream),
1703                                                ct,
1704                                            ),
1705                                            None => {
1706                                                // log-policy: system-broken
1707                                                tracing::error!(
1708                                                    "Body::Stream already consumed before HTTP reply — returning 500"
1709                                                );
1710                                                let error_reply = HttpReply {
1711                                                    status: 500,
1712                                                    headers: vec![],
1713                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1714                                                };
1715                                                if reply_tx.send(error_reply).is_err() {
1716                                                    debug!("reply_tx dropped before error reply could be sent");
1717                                                }
1718                                                return;
1719                                            }
1720                                        }
1721                                    }
1722                                    // Empty and future variants produce an empty reply body.
1723                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1724                                };
1725
1726                                let resp_headers = select_response_headers(
1727                                    &out.input.headers,
1728                                    user_content_type,
1729                                    inferred_content_type,
1730                                );
1731
1732                                HttpReply {
1733                                    status,
1734                                    headers: resp_headers,
1735                                    body: reply_body,
1736                                }
1737                            }
1738                            Err(e) => {
1739                                pipeline_error_to_reply(e, &path_clone)
1740                            }
1741                        };
1742
1743                        // Reply to Axum handler (ignore error if client disconnected)
1744                        let _ = reply_tx.send(reply);
1745                    });
1746                }
1747            }
1748        }
1749
1750        // Deregister this consumer. Mirror the registration choice:
1751        // REST-registered consumers remove their (method, path) endpoint
1752        // WITHOUT touching sibling verbs on the same template (review C1);
1753        // legacy consumers clean up api_routes.
1754        if let Some(method) = &self.config.method {
1755            registry_for_cleanup
1756                .unregister_rest_endpoint(method, &path)
1757                .await;
1758        } else {
1759            registry_for_cleanup.unregister_api_route(&path).await;
1760        }
1761
1762        // D-L10: decrement the shared server's refcount. When the last
1763        // consumer on this (host, port) leaves, the server + monitor tasks
1764        // are aborted and the registry entry is removed.
1765        ServerRegistry::global()
1766            .unregister(&self.config.host, self.config.port)
1767            .await;
1768
1769        Ok(())
1770    }
1771
1772    async fn stop(&mut self) -> Result<(), CamelError> {
1773        Ok(())
1774    }
1775
1776    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1777        camel_component_api::ConcurrencyModel::Concurrent { max: None }
1778    }
1779
1780    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
1781    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
1782    // Opting into Explicit startup makes ctx.start() await the bind+register
1783    // completion so listeners fail fast on bind errors (previously a silent
1784    // background log) and external markers can reliably detect listener-bound
1785    // state.
1786    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1787        camel_component_api::ConsumerStartupMode::Explicit
1788    }
1789
1790    // Task 2.9: capture the kernel state (compiled plan + provider registry)
1791    // wired by the route controller before start(). See `HttpKernelAuth`.
1792    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1793        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
1794    }
1795}
1796
1797// ---------------------------------------------------------------------------
1798// HttpComponent / HttpsComponent
1799// ---------------------------------------------------------------------------
1800
1801pub struct HttpComponent {
1802    config: HttpConfig,
1803}
1804
1805pub(crate) fn build_client(
1806    config: &HttpConfig,
1807    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1808) -> reqwest::Client {
1809    let mut builder = reqwest::Client::builder()
1810        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
1811        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1812        .pool_max_idle_per_host(config.pool_max_idle_per_host)
1813        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1814
1815    // Redirects are always handled manually in the producer's send path
1816    // so that each hop can be SSRF-validated. reqwest's built-in redirect
1817    // policy is sync and cannot perform async DNS resolution or SSRF checks.
1818    builder = builder.redirect(reqwest::redirect::Policy::none());
1819
1820    if let Some((host, addrs)) = resolve_override {
1821        builder = builder.resolve_to_addrs(host, addrs);
1822    }
1823
1824    if let Some(tls) = &config.tls
1825        && tls.enabled
1826    {
1827        if tls.insecure || !tls.verify_peer {
1828            // log-policy: handler-owned
1829            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1830            builder = builder.danger_accept_invalid_certs(true);
1831        }
1832
1833        if let Some(ca_path) = &tls.ca_cert_path
1834            && let Ok(ca_bytes) = std::fs::read(ca_path)
1835        {
1836            let cert = reqwest::Certificate::from_pem(&ca_bytes)
1837                .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1838            if let Ok(ca_cert) = cert {
1839                builder = builder.add_root_certificate(ca_cert);
1840            }
1841        }
1842
1843        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1844            && let (Ok(cert_bytes), Ok(key_bytes)) =
1845                (std::fs::read(cert_path), std::fs::read(key_path))
1846        {
1847            let mut identity_pem = cert_bytes;
1848            identity_pem.extend_from_slice(&key_bytes);
1849            if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1850                builder = builder.identity(identity);
1851            }
1852        }
1853    }
1854
1855    builder
1856        .build()
1857        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
1858}
1859
1860impl HttpComponent {
1861    pub fn new() -> Self {
1862        let config = HttpConfig::default();
1863        Self { config }
1864    }
1865
1866    pub fn with_config(config: HttpConfig) -> Self {
1867        Self { config }
1868    }
1869
1870    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1871        match config {
1872            Some(cfg) => Self::with_config(cfg),
1873            None => Self::new(),
1874        }
1875    }
1876}
1877
1878impl Default for HttpComponent {
1879    fn default() -> Self {
1880        Self::new()
1881    }
1882}
1883
1884impl Component for HttpComponent {
1885    fn scheme(&self) -> &str {
1886        "http"
1887    }
1888
1889    fn metadata(&self) -> ComponentMetadata {
1890        HttpEndpointConfig::metadata()
1891    }
1892
1893    fn create_endpoint(
1894        &self,
1895        uri: &str,
1896        ctx: &dyn camel_component_api::ComponentContext,
1897    ) -> Result<Box<dyn Endpoint>, CamelError> {
1898        self.config.validate()?;
1899        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1900        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1901        let client = build_client(&self.config, None);
1902        let pinned_cache = std::sync::Arc::new(PinnedClientCache::new(
1903            PINNED_CLIENT_TTL,
1904            PINNED_CLIENT_MAX_ENTRIES,
1905        ));
1906        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1907            server_config.host.clone(),
1908            server_config.port,
1909        )));
1910        Ok(Box::new(HttpEndpoint {
1911            uri: uri.to_string(),
1912            config,
1913            server_config,
1914            client,
1915            pinned_cache,
1916            http_config: self.config.clone(),
1917        }))
1918    }
1919}
1920
1921pub struct HttpsComponent {
1922    config: HttpConfig,
1923}
1924
1925impl HttpsComponent {
1926    pub fn new() -> Self {
1927        let config = HttpConfig::default();
1928        Self { config }
1929    }
1930
1931    pub fn with_config(config: HttpConfig) -> Self {
1932        Self { config }
1933    }
1934
1935    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1936        match config {
1937            Some(cfg) => Self::with_config(cfg),
1938            None => Self::new(),
1939        }
1940    }
1941}
1942
1943impl Default for HttpsComponent {
1944    fn default() -> Self {
1945        Self::new()
1946    }
1947}
1948
1949impl Component for HttpsComponent {
1950    fn scheme(&self) -> &str {
1951        "https"
1952    }
1953
1954    fn metadata(&self) -> ComponentMetadata {
1955        // HTTPS shares the same URI option surface and capabilities as HTTP.
1956        // Only the scheme and description differ.
1957        let mut meta = HttpEndpointConfig::metadata();
1958        meta.scheme = "https".to_string();
1959        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
1960        meta
1961    }
1962
1963    fn create_endpoint(
1964        &self,
1965        uri: &str,
1966        ctx: &dyn camel_component_api::ComponentContext,
1967    ) -> Result<Box<dyn Endpoint>, CamelError> {
1968        self.config.validate()?;
1969        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1970        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1971        let client = build_client(&self.config, None);
1972        let pinned_cache = std::sync::Arc::new(PinnedClientCache::new(
1973            PINNED_CLIENT_TTL,
1974            PINNED_CLIENT_MAX_ENTRIES,
1975        ));
1976        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1977            server_config.host.clone(),
1978            server_config.port,
1979        )));
1980        Ok(Box::new(HttpEndpoint {
1981            uri: uri.to_string(),
1982            config,
1983            server_config,
1984            client,
1985            pinned_cache,
1986            http_config: self.config.clone(),
1987        }))
1988    }
1989}
1990
1991// ---------------------------------------------------------------------------
1992// HttpEndpoint
1993// ---------------------------------------------------------------------------
1994
1995struct HttpEndpoint {
1996    uri: String,
1997    config: HttpEndpointConfig,
1998    server_config: HttpServerConfig,
1999    client: reqwest::Client,
2000    pinned_cache: std::sync::Arc<PinnedClientCache>,
2001    http_config: HttpConfig,
2002}
2003
2004impl Endpoint for HttpEndpoint {
2005    fn uri(&self) -> &str {
2006        &self.uri
2007    }
2008
2009    fn create_consumer(
2010        &self,
2011        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2012    ) -> Result<Box<dyn Consumer>, CamelError> {
2013        // Scheme/config consistency check (spec §5) — uses parsed scheme
2014        // from HttpServerConfig, not a fragile port-443 heuristic.
2015        let scheme_is_https = self.server_config.scheme == "https";
2016        let has_tls = self.server_config.tls_config.is_some();
2017
2018        if scheme_is_https && !has_tls {
2019            return Err(CamelError::EndpointCreationFailed(
2020                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2021            ));
2022        }
2023        if !scheme_is_https && has_tls {
2024            return Err(CamelError::EndpointCreationFailed(
2025                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2026            ));
2027        }
2028        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2029    }
2030
2031    fn create_producer(
2032        &self,
2033        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
2034        _ctx: &ProducerContext,
2035    ) -> Result<BoxProcessor, CamelError> {
2036        let producer = HttpProducer {
2037            config: Arc::new(self.config.clone()),
2038            client: self.client.clone(),
2039            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2040            http_config: Arc::new(self.http_config.clone()),
2041        };
2042        if let Some(ref provider) = self.config.token_provider {
2043            let layer = BearerTokenLayer::new(Arc::clone(provider));
2044            Ok(BoxProcessor::new(layer.layer(producer)))
2045        } else {
2046            Ok(BoxProcessor::new(producer))
2047        }
2048    }
2049}
2050
2051// ---------------------------------------------------------------------------
2052// HttpProducer
2053// ---------------------------------------------------------------------------
2054
2055#[derive(Clone)]
2056struct HttpProducer {
2057    config: Arc<HttpEndpointConfig>,
2058    client: reqwest::Client,
2059    pinned_cache: std::sync::Arc<PinnedClientCache>,
2060    http_config: Arc<HttpConfig>,
2061}
2062
2063impl HttpProducer {
2064    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2065        if let Some(ref method) = config.http_method {
2066            return method.to_uppercase();
2067        }
2068        if let Some(method) = exchange
2069            .input
2070            .header("CamelHttpMethod")
2071            .and_then(|v| v.as_str())
2072        {
2073            return method.to_uppercase();
2074        }
2075        if !exchange.input.body.is_empty() {
2076            return "POST".to_string();
2077        }
2078        "GET".to_string()
2079    }
2080
2081    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2082        // bridgeEndpoint=true: emit the endpoint base URL verbatim and ignore
2083        // ALL exchange URL headers (CamelHttpUri, CamelHttpPath,
2084        // CamelHttpQuery) per Apache Camel bridging semantics. Only
2085        // configured query_params are applied. This check MUST come before the
2086        // CamelHttpUri override so bridging wins over that header.
2087        if config.bridge_endpoint {
2088            let url = config.base_url.clone();
2089            if config.query_params.is_empty() {
2090                return url;
2091            }
2092            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
2093            for (k, v) in &config.query_params {
2094                parsed.query_pairs_mut().append_pair(k, v);
2095            }
2096            return parsed.to_string();
2097        }
2098
2099        if let Some(uri) = exchange
2100            .input
2101            .header("CamelHttpUri")
2102            .and_then(|v| v.as_str())
2103        {
2104            let mut url = uri.to_string();
2105            if let Some(path) = exchange
2106                .input
2107                .header("CamelHttpPath")
2108                .and_then(|v| v.as_str())
2109            {
2110                if !url.ends_with('/') && !path.starts_with('/') {
2111                    url.push('/');
2112                }
2113                url.push_str(path);
2114            }
2115            if let Some(query) = exchange
2116                .input
2117                .header("CamelHttpQuery")
2118                .and_then(|v| v.as_str())
2119            {
2120                url.push('?');
2121                url.push_str(query);
2122            }
2123            return url;
2124        }
2125
2126        let mut url = config.base_url.clone();
2127
2128        if let Some(path) = exchange
2129            .input
2130            .header("CamelHttpPath")
2131            .and_then(|v| v.as_str())
2132        {
2133            if !url.ends_with('/') && !path.starts_with('/') {
2134                url.push('/');
2135            }
2136            url.push_str(path);
2137        }
2138
2139        if let Some(query) = exchange
2140            .input
2141            .header("CamelHttpQuery")
2142            .and_then(|v| v.as_str())
2143        {
2144            url.push('?');
2145            url.push_str(query);
2146        } else if !config.query_params.is_empty() {
2147            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
2148            for (k, v) in &config.query_params {
2149                parsed.query_pairs_mut().append_pair(k, v);
2150            }
2151            url = parsed.to_string();
2152        }
2153
2154        url
2155    }
2156
2157    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2158        status >= range.0 && status <= range.1
2159    }
2160
2161    /// Whether the HTTP method is entity-enclosing (may carry a request
2162    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
2163    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
2164    /// §9.3.1/§9.3.2).
2165    fn is_entity_enclosing(method: &str) -> bool {
2166        matches!(method, "POST" | "PUT" | "PATCH")
2167    }
2168}
2169
2170impl Service<Exchange> for HttpProducer {
2171    type Response = Exchange;
2172    type Error = CamelError;
2173    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2174
2175    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2176        Poll::Ready(Ok(()))
2177    }
2178
2179    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2180        let config = self.config.clone();
2181        let shared_client = self.client.clone();
2182        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2183        let http_config = self.http_config.clone();
2184
2185        Box::pin(async move {
2186            let method_str = HttpProducer::resolve_method(&exchange, &config);
2187            // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
2188            // and PATCH may carry a request body. Any other resolved method
2189            // drops the exchange body before the request is built (Apache
2190            // Camel `HttpMethods` parity).
2191            let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2192            let url = HttpProducer::resolve_url(&exchange, &config);
2193
2194            // SECURITY: Validate URL for SSRF
2195            ssrf::validate_url_for_ssrf(&url, &config)?;
2196
2197            // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
2198            // (L-H2). When the URL uses a domain name and SSRF protection is active,
2199            // reuse the endpoint's cached DNS-pinned client for that validated
2200            // (host, addrs) pair — built once with resolve_to_addrs, then shared so
2201            // repeated requests keep one connection pool without re-resolving DNS.
2202            // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
2203            // URLs use the endpoint's unpinned shared client.
2204            let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2205            let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2206                pinned_cache
2207                    .get_or_build(host.as_str(), addrs, || {
2208                        build_client(&http_config, Some((host.as_str(), addrs)))
2209                    })
2210                    .await
2211            } else {
2212                shared_client.clone()
2213            };
2214
2215            debug!(
2216                correlation_id = %exchange.correlation_id(),
2217                method = %method_str,
2218                url = %url,
2219                "HTTP request"
2220            );
2221
2222            let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2223                CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2224            })?;
2225
2226            // Collect headers for potential redirect replay
2227            let mut collected_headers: Vec<(
2228                reqwest::header::HeaderName,
2229                reqwest::header::HeaderValue,
2230            )> = Vec::new();
2231
2232            if let Some(user_agent) = &config.user_agent
2233                && !config.bridge_endpoint
2234                && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2235            {
2236                collected_headers.push((reqwest::header::USER_AGENT, val));
2237            }
2238
2239            // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
2240            #[cfg(feature = "otel")]
2241            let should_inject_otel = !config.bridge_endpoint;
2242            #[cfg(feature = "otel")]
2243            if should_inject_otel {
2244                let mut otel_headers = HashMap::new();
2245                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2246                for (k, v) in otel_headers {
2247                    if let (Ok(name), Ok(val)) = (
2248                        reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2249                        reqwest::header::HeaderValue::from_str(&v),
2250                    ) {
2251                        collected_headers.push((name, val));
2252                    }
2253                }
2254            }
2255
2256            let conn_tokens = header_policy::connection_tokens(
2257                exchange
2258                    .input
2259                    .headers
2260                    .iter()
2261                    .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2262                    .filter_map(|(_, v)| v.as_str()),
2263            );
2264
2265            for (key, value) in &exchange.input.headers {
2266                if !key.starts_with("Camel")
2267                    && !config
2268                        .skip_request_headers
2269                        .iter()
2270                        .any(|h| h.eq_ignore_ascii_case(key))
2271                    && !header_policy::excluded_outbound(key, &conn_tokens)
2272                    && let Some(val_str) = value.as_str()
2273                    && let (Ok(name), Ok(val)) = (
2274                        reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2275                        reqwest::header::HeaderValue::from_str(val_str),
2276                    )
2277                {
2278                    collected_headers.push((name, val));
2279                }
2280            }
2281
2282            // Auth headers
2283            if !config.bridge_endpoint {
2284                match &config.auth {
2285                    HttpAuth::None => {}
2286                    HttpAuth::Basic { username, password } => {
2287                        use base64::Engine;
2288                        // allow-secret: credentials combined for base64 Basic auth header
2289                        let credentials = format!("{username}:{password}");
2290                        let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2291                        if let Ok(val) =
2292                            reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2293                        {
2294                            collected_headers.push((reqwest::header::AUTHORIZATION, val));
2295                        }
2296                    }
2297                    HttpAuth::Bearer { token } => {
2298                        // allow-secret: Bearer token in Authorization header
2299                        let bearer = format!("Bearer {token}");
2300                        if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2301                            collected_headers.push((reqwest::header::AUTHORIZATION, val));
2302                        }
2303                    }
2304                }
2305
2306                if config.connection_close
2307                    && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2308                {
2309                    collected_headers.push((reqwest::header::CONNECTION, val));
2310                }
2311            }
2312
2313            // Materialize body
2314            let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2315            let materialized_body: Option<Vec<u8>> = if is_stream_body {
2316                if suppress_body {
2317                    // A stream body dropped under a non-entity-enclosing
2318                    // method always warns (its emptiness is unknowable) and
2319                    // stays consumed (mem::take). The stream attach arm below
2320                    // still runs its outer flag check, but the inner `if let
2321                    // Body::Stream` re-match fails on the now-Empty body, so
2322                    // no stream is attached and no AlreadyConsumed error can
2323                    // fire.
2324                    std::mem::take(&mut exchange.input.body);
2325                    // log-policy: handler-owned
2326                    tracing::warn!(
2327                        correlation_id = %exchange.correlation_id(),
2328                        method = %method_str,
2329                        "dropping request body for non-entity-enclosing HTTP method"
2330                    );
2331                }
2332                None // Streams can't be replayed on redirect
2333            } else {
2334                let body = std::mem::take(&mut exchange.input.body);
2335                let bytes = body.into_bytes(config.max_body_size).await?;
2336                if bytes.is_empty() {
2337                    // Empty body: nothing to send and nothing to warn about.
2338                    None
2339                } else if suppress_body {
2340                    // log-policy: handler-owned
2341                    tracing::warn!(
2342                        correlation_id = %exchange.correlation_id(),
2343                        method = %method_str,
2344                        "dropping request body for non-entity-enclosing HTTP method"
2345                    );
2346                    None
2347                } else {
2348                    Some(bytes.to_vec())
2349                }
2350            };
2351
2352            let response = if config.follow_redirects && !is_stream_body {
2353                // Use manual redirect loop with per-hop SSRF validation.
2354                // `client` is the pinned-or-shared binding for the initial
2355                // request (a hostname initial request keeps its DNS-pinned
2356                // client); `shared_client` is the unpinned endpoint client
2357                // reused by IP-literal redirect hops.
2358                ssrf::send_with_ssrf_safe_redirects(
2359                    &client,
2360                    &shared_client,
2361                    &pinned_cache,
2362                    &http_config,
2363                    &config,
2364                    method,
2365                    &url,
2366                    collected_headers,
2367                    materialized_body,
2368                    config.max_redirects,
2369                    config.response_timeout,
2370                )
2371                .await?
2372            } else {
2373                // Direct send (no redirect following, or streaming body)
2374                let mut request = client.request(method, &url);
2375
2376                if let Some(timeout) = config.response_timeout {
2377                    request = request.timeout(timeout);
2378                }
2379
2380                for (name, value) in &collected_headers {
2381                    request = request.header(name, value);
2382                }
2383
2384                if is_stream_body {
2385                    if let Body::Stream(ref s) = exchange.input.body {
2386                        let mut stream_lock = s.stream.lock().await;
2387                        if let Some(stream) = stream_lock.take() {
2388                            request = request.body(reqwest::Body::wrap_stream(stream));
2389                        } else {
2390                            return Err(CamelError::AlreadyConsumed);
2391                        }
2392                    }
2393                } else if let Some(ref body_bytes) = materialized_body {
2394                    request = request.body(body_bytes.clone());
2395                }
2396
2397                request
2398                    .send()
2399                    .await
2400                    .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2401            };
2402
2403            let status_code = response.status().as_u16();
2404            let status_text = response
2405                .status()
2406                .canonical_reason()
2407                .unwrap_or("Unknown")
2408                .to_string();
2409
2410            for (key, value) in response.headers() {
2411                if config
2412                    .skip_response_headers
2413                    .iter()
2414                    .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2415                {
2416                    continue;
2417                }
2418                if let Ok(val_str) = value.to_str() {
2419                    exchange.input.set_header(
2420                        title_case_header(key.as_str()),
2421                        serde_json::Value::String(val_str.to_string()),
2422                    );
2423                }
2424            }
2425
2426            exchange.input.set_header(
2427                "CamelHttpResponseCode",
2428                serde_json::Value::Number(status_code.into()),
2429            );
2430            exchange.input.set_header(
2431                "CamelHttpResponseText",
2432                serde_json::Value::String(status_text.clone()),
2433            );
2434
2435            // Read response body with timeout and size guard (HTTP-004, HTTP-005)
2436            let read_timeout = Duration::from_millis(config.read_timeout_ms);
2437            let response_body = tokio::time::timeout(read_timeout, async {
2438                // Check Content-Length header before allocating
2439                if let Some(content_len) = response.content_length()
2440                    && content_len > config.max_response_bytes as u64
2441                {
2442                    return Err(CamelError::ProcessorError(format!(
2443                        "Response body too large: {} bytes exceeds limit of {} bytes",
2444                        content_len, config.max_response_bytes
2445                    )));
2446                }
2447                // Use bytes_stream() for lazy streaming with size guard
2448                use futures::TryStreamExt;
2449                let mut stream = response.bytes_stream();
2450                let mut total: usize = 0;
2451                let mut collected = Vec::new();
2452                while let Some(chunk) = stream.try_next().await.map_err(|e| {
2453                    CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2454                })? {
2455                    total += chunk.len();
2456                    if total > config.max_response_bytes {
2457                        return Err(CamelError::ProcessorError(format!(
2458                            "Response body too large: {} bytes exceeds limit of {} bytes",
2459                            total, config.max_response_bytes
2460                        )));
2461                    }
2462                    collected.push(chunk);
2463                }
2464                let mut result = bytes::BytesMut::with_capacity(total);
2465                for chunk in collected {
2466                    result.extend_from_slice(&chunk);
2467                }
2468                Ok::<bytes::Bytes, CamelError>(result.freeze())
2469            })
2470            .await
2471            .map_err(|_| {
2472                CamelError::ProcessorError(format!(
2473                    "Read timeout after {}ms",
2474                    config.read_timeout_ms
2475                ))
2476            })??;
2477
2478            if config.throw_exception_on_failure
2479                && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2480            {
2481                return Err(CamelError::HttpOperationFailed {
2482                    method: method_str,
2483                    url,
2484                    status_code,
2485                    status_text,
2486                    response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2487                });
2488            }
2489
2490            if !response_body.is_empty() {
2491                exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2492            }
2493
2494            debug!(
2495                correlation_id = %exchange.correlation_id(),
2496                status = status_code,
2497                url = %url,
2498                "HTTP response"
2499            );
2500            Ok(exchange)
2501        })
2502    }
2503}
2504
2505/// Serializes tests that mutate or depend on the global `ServerRegistry`.
2506///
2507/// `ServerRegistry::global()` is a process-wide singleton that persists
2508/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
2509/// with another test that has a live server on a fixed port (e.g. 9991),
2510/// the registry entry is removed while the OS socket is still bound, so
2511/// the next `get_or_spawn` call on that port fails with "Address already
2512/// in use". Holding this mutex for the full body of each affected test
2513/// prevents the race without requiring `--test-threads=1`.
2514#[cfg(test)]
2515pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2516
2517/// Map a pipeline error to an HTTP reply.
2518///
2519/// Extracted from the inline `match` in `dispatch_handler` for unit
2520/// testability (rc-1dk4). `TypeConversionFailed` (e.g. malformed JSON
2521/// body) maps to `400 Bad Request` with a structured JSON error body;
2522/// `Unauthenticated`/`Unauthorized` keep their existing `401`/`403`
2523/// mappings; all other errors map to `500 Internal Server Error`.
2524fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2525    match e {
2526        CamelError::Unauthenticated(msg) => {
2527            tracing::warn!(error = %msg, path = %path, "Authentication failed");
2528            HttpReply {
2529                status: 401,
2530                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2531                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2532            }
2533        }
2534        CamelError::Unauthorized(msg) => {
2535            tracing::warn!(error = %msg, path = %path, "Authorization failed");
2536            HttpReply {
2537                status: 403,
2538                headers: vec![],
2539                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2540            }
2541        }
2542        CamelError::TypeConversionFailed(msg) => {
2543            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2544            let body = serde_json::to_string(&serde_json::json!({
2545                "error": "bad_request",
2546                "message": msg,
2547            }))
2548            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2549            HttpReply {
2550                status: 400,
2551                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2552                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2553            }
2554        }
2555        CamelError::ValidationError(msg) => {
2556            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2557            let body = serde_json::to_string(&serde_json::json!({
2558                "error": "validation_error",
2559                "message": msg,
2560            }))
2561            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2562            HttpReply {
2563                status: 400,
2564                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2565                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2566            }
2567        }
2568        CamelError::ConsumerStopping => {
2569            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2570            HttpReply {
2571                status: 503,
2572                headers: vec![],
2573                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2574            }
2575        }
2576        e => {
2577            // log-policy: handler-owned
2578            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2579            HttpReply {
2580                status: 500,
2581                headers: vec![],
2582                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2583            }
2584        }
2585    }
2586}
2587
2588/// Select the HTTP response headers emitted by the consumer reply finaliser
2589/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
2590/// `dispatch_handler` for unit testability.
2591///
2592/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
2593/// server-owned headers, plus `content-length`/`content-type` (re-derived),
2594/// and any header named by a `Connection` token. Appends a single
2595/// `Content-Type` from `user_content_type` falling back to
2596/// `inferred_content_type` when either is present.
2597fn select_response_headers(
2598    headers: &HashMap<String, serde_json::Value>,
2599    user_content_type: Option<String>,
2600    inferred_content_type: Option<String>,
2601) -> Vec<(String, String)> {
2602    let conn_tokens = header_policy::connection_tokens(
2603        headers
2604            .iter()
2605            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2606            .filter_map(|(_, v)| v.as_str()),
2607    );
2608    let mut selected: Vec<(String, String)> = headers
2609        .iter()
2610        .filter(|(k, _)| !k.starts_with("Camel"))
2611        .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2612        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2613        .collect();
2614    if let Some(ct) = user_content_type.or(inferred_content_type) {
2615        selected.push(("Content-Type".to_string(), ct));
2616    }
2617    selected
2618}
2619
2620#[cfg(test)]
2621mod tests {
2622    use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2623    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2624        std::sync::Arc::new(PanicRuntimeObservability)
2625    }
2626    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2627        std::sync::Arc::new(PanicRuntimeObservability)
2628    }
2629    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2630        std::sync::Arc::new(NoopRuntimeObservability)
2631    }
2632
2633    use super::*;
2634    use crate::rest_match::PathSegment;
2635    use camel_component_api::{Message, NoOpComponentContext};
2636    use std::sync::Arc;
2637    use std::time::Duration;
2638
2639    fn test_producer_ctx() -> ProducerContext {
2640        ProducerContext::new()
2641    }
2642
2643    #[test]
2644    fn test_http_config_defaults() {
2645        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2646        assert_eq!(config.base_url, "http://localhost:8080/api");
2647        assert!(config.http_method.is_none());
2648        assert!(config.throw_exception_on_failure);
2649        assert_eq!(config.ok_status_code_range, (200, 299));
2650        assert!(config.response_timeout.is_none());
2651        assert!(matches!(config.auth, HttpAuth::None));
2652        assert!(!config.bridge_endpoint);
2653        assert!(!config.connection_close);
2654    }
2655
2656    #[test]
2657    fn test_http_config_scheme() {
2658        // UriConfig trait method returns "http" as primary scheme
2659        assert_eq!(HttpEndpointConfig::scheme(), "http");
2660    }
2661
2662    #[test]
2663    fn test_http_config_from_components() {
2664        // Test from_components directly (trait method)
2665        let components = camel_component_api::UriComponents {
2666            scheme: "https".to_string(),
2667            path: "//api.example.com/v1".to_string(),
2668            params: std::collections::HashMap::from([(
2669                "httpMethod".to_string(),
2670                "POST".to_string(),
2671            )]),
2672        };
2673        let config = HttpEndpointConfig::from_components(components).unwrap();
2674        assert_eq!(config.base_url, "https://api.example.com/v1");
2675        assert_eq!(config.http_method, Some("POST".to_string()));
2676    }
2677
2678    #[test]
2679    fn test_http_config_with_options() {
2680        let config = HttpEndpointConfig::from_uri(
2681            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2682        ).unwrap();
2683        assert_eq!(config.base_url, "https://api.example.com/v1");
2684        assert_eq!(config.http_method, Some("PUT".to_string()));
2685        assert!(!config.throw_exception_on_failure);
2686        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2687    }
2688
2689    #[test]
2690    fn test_http_endpoint_config_auth_and_headers_options() {
2691        let config = HttpEndpointConfig::from_uri(
2692            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2693        )
2694        .unwrap();
2695
2696        assert!(matches!(
2697            config.auth,
2698            HttpAuth::Basic { username, password } if username == "u" && password == "p"
2699        ));
2700        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2701        assert!(config.bridge_endpoint);
2702        assert!(config.connection_close);
2703        assert_eq!(
2704            config.skip_request_headers,
2705            vec!["authorization".to_string(), "x-secret".to_string()]
2706        );
2707        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2708    }
2709
2710    #[test]
2711    fn test_http_endpoint_config_bearer_auth() {
2712        let config = HttpEndpointConfig::from_uri(
2713            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2714        )
2715        .unwrap();
2716        assert!(matches!(
2717            config.auth,
2718            HttpAuth::Bearer { token } if token == "t"
2719        ));
2720    }
2721
2722    #[test]
2723    fn rejects_cookie_handling_inmemory() {
2724        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2725        match result {
2726            Err(CamelError::InvalidUri(msg)) => {
2727                assert!(
2728                    msg.contains("cookieHandling is not supported"),
2729                    "expected rejection message, got: {msg}"
2730                );
2731            }
2732            other => panic!("expected InvalidUri error, got: {other:?}"),
2733        }
2734    }
2735
2736    #[test]
2737    fn rejects_cookie_handling_disabled() {
2738        let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2739        match result {
2740            Err(CamelError::InvalidUri(msg)) => {
2741                assert!(
2742                    msg.contains("cookieHandling is not supported"),
2743                    "expected rejection message, got: {msg}"
2744                );
2745            }
2746            other => panic!("expected InvalidUri error, got: {other:?}"),
2747        }
2748    }
2749
2750    #[test]
2751    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2752        let config = HttpConfig::default()
2753            .with_response_timeout_ms(999)
2754            .with_allow_internal(true)
2755            .with_blocked_hosts(vec!["evil.com".to_string()])
2756            .with_max_body_size(12345);
2757        let endpoint =
2758            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2759        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2760        assert!(endpoint.allow_internal);
2761        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2762        assert_eq!(endpoint.max_body_size, 12345);
2763    }
2764
2765    #[test]
2766    fn test_from_uri_with_defaults_uri_overrides_config() {
2767        let config = HttpConfig::default()
2768            .with_response_timeout_ms(999)
2769            .with_allow_internal(true)
2770            .with_blocked_hosts(vec!["evil.com".to_string()])
2771            .with_max_body_size(12345);
2772        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2773            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2774            &config,
2775        )
2776        .unwrap();
2777        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2778        assert!(!endpoint.allow_internal);
2779        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2780        assert_eq!(endpoint.max_body_size, 99);
2781    }
2782
2783    #[test]
2784    fn test_http_config_ok_status_range() {
2785        let config =
2786            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2787        assert_eq!(config.ok_status_code_range, (200, 204));
2788    }
2789
2790    #[test]
2791    fn test_http_config_wrong_scheme() {
2792        let result = HttpEndpointConfig::from_uri("file:/tmp");
2793        assert!(result.is_err());
2794    }
2795
2796    #[test]
2797    fn test_http_component_scheme() {
2798        let component = HttpComponent::new();
2799        assert_eq!(component.scheme(), "http");
2800    }
2801
2802    #[test]
2803    fn test_https_component_scheme() {
2804        let component = HttpsComponent::new();
2805        assert_eq!(component.scheme(), "https");
2806    }
2807
2808    #[test]
2809    fn test_http_endpoint_creates_consumer() {
2810        let component = HttpComponent::new();
2811        let ctx = NoOpComponentContext;
2812        let endpoint = component
2813            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2814            .unwrap();
2815        assert!(endpoint.create_consumer(rt()).is_ok());
2816    }
2817
2818    #[test]
2819    fn test_https_endpoint_creates_consumer_errors_without_tls() {
2820        let component = HttpsComponent::new();
2821        let ctx = NoOpComponentContext;
2822        let endpoint = component
2823            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2824            .unwrap();
2825        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
2826        assert!(endpoint.create_consumer(rt()).is_err());
2827    }
2828
2829    #[test]
2830    fn test_http_endpoint_creates_producer() {
2831        let ctx = test_producer_ctx();
2832        let component = HttpComponent::new();
2833        let endpoint_ctx = NoOpComponentContext;
2834        let endpoint = component
2835            .create_endpoint("http://localhost/api", &endpoint_ctx)
2836            .unwrap();
2837        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2838    }
2839
2840    // -----------------------------------------------------------------------
2841    // Producer tests
2842    // -----------------------------------------------------------------------
2843
2844    #[tokio::test]
2845    async fn test_producer_with_token_provider() {
2846        use camel_auth::oauth2::TokenProvider;
2847        use tower::ServiceExt;
2848
2849        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2850            Arc::new(std::sync::Mutex::new(None));
2851        let captured_clone = Arc::clone(&captured_auth);
2852
2853        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2854        let port = listener.local_addr().unwrap().port();
2855
2856        let _handle = tokio::spawn(async move {
2857            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2858            if let Ok((mut stream, _)) = listener.accept().await {
2859                let mut buf = vec![0u8; 8192];
2860                let n = stream.read(&mut buf).await.unwrap_or(0);
2861                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2862                let auth = request
2863                    .lines()
2864                    .find(|l| l.to_lowercase().starts_with("authorization:"))
2865                    .map(|l| {
2866                        l.split(':')
2867                            .nth(1)
2868                            .map(|s| s.trim().to_string())
2869                            .unwrap_or_default()
2870                    });
2871                *captured_clone.lock().unwrap() = auth;
2872                let body = r#"{"echo":"ok"}"#;
2873                let resp = format!(
2874                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2875                    body.len(),
2876                    body
2877                );
2878                let _ = stream.write_all(resp.as_bytes()).await;
2879            }
2880        });
2881
2882        #[derive(Debug)]
2883        struct StaticProvider;
2884        #[async_trait::async_trait]
2885        impl TokenProvider for StaticProvider {
2886            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2887                Ok("injected-token".into())
2888            }
2889        }
2890
2891        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2892        let ctx = test_producer_ctx();
2893        let component = HttpComponent::new();
2894        let endpoint_ctx = NoOpComponentContext;
2895        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2896        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2897
2898        let exchange = Exchange::new(Message::new("hello"));
2899
2900        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2901        let mut layered = layer.layer(producer);
2902        let result = layered.ready().await.unwrap().call(exchange).await;
2903        assert!(result.is_ok(), "producer call failed: {:?}", result);
2904
2905        tokio::time::sleep(Duration::from_millis(100)).await;
2906        let auth = captured_auth.lock().unwrap().take();
2907        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2908    }
2909
2910    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2911        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2912        let addr = listener.local_addr().unwrap();
2913        let url = format!("http://127.0.0.1:{}", addr.port());
2914
2915        let handle = tokio::spawn(async move {
2916            loop {
2917                if let Ok((mut stream, _)) = listener.accept().await {
2918                    tokio::spawn(async move {
2919                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2920                        let mut buf = vec![0u8; 4096];
2921                        let n = stream.read(&mut buf).await.unwrap_or(0);
2922                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2923
2924                        let method = request.split_whitespace().next().unwrap_or("GET");
2925
2926                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2927                        let response = format!(
2928                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2929                            body.len(),
2930                            body
2931                        );
2932                        let _ = stream.write_all(response.as_bytes()).await;
2933                    });
2934                }
2935            }
2936        });
2937
2938        (url, handle)
2939    }
2940
2941    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2942        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2943        let addr = listener.local_addr().unwrap();
2944        let url = format!("http://127.0.0.1:{}", addr.port());
2945
2946        let handle = tokio::spawn(async move {
2947            loop {
2948                if let Ok((mut stream, _)) = listener.accept().await {
2949                    let status = status;
2950                    tokio::spawn(async move {
2951                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2952                        let mut buf = vec![0u8; 4096];
2953                        let _ = stream.read(&mut buf).await;
2954
2955                        let status_text = match status {
2956                            404 => "Not Found",
2957                            500 => "Internal Server Error",
2958                            _ => "Error",
2959                        };
2960                        let body = "error body";
2961                        let response = format!(
2962                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2963                            status,
2964                            status_text,
2965                            body.len(),
2966                            body
2967                        );
2968                        let _ = stream.write_all(response.as_bytes()).await;
2969                    });
2970                }
2971            }
2972        });
2973
2974        (url, handle)
2975    }
2976
2977    async fn start_request_capturing_server() -> (
2978        String,
2979        Arc<std::sync::Mutex<Option<String>>>,
2980        tokio::task::JoinHandle<()>,
2981    ) {
2982        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2983        let port = listener.local_addr().unwrap().port();
2984        let url = format!("http://127.0.0.1:{port}");
2985        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2986        let captured_clone = Arc::clone(&captured);
2987        let handle = tokio::spawn(async move {
2988            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2989            if let Ok((mut stream, _)) = listener.accept().await {
2990                let mut buf = vec![0u8; 16384];
2991                let n = stream.read(&mut buf).await.unwrap_or(0);
2992                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2993                if request.contains("\r\n\r\n") {
2994                    *captured_clone.lock().unwrap() = Some(request);
2995                }
2996                let body = r#"{"echo":"ok"}"#;
2997                let resp = format!(
2998                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2999                    body.len(),
3000                    body
3001                );
3002                let _ = stream.write_all(resp.as_bytes()).await;
3003            }
3004        });
3005        (url, captured, handle)
3006    }
3007
3008    #[tokio::test]
3009    async fn test_http_producer_get_request() {
3010        use tower::ServiceExt;
3011
3012        let (url, _handle) = start_test_server().await;
3013        let ctx = test_producer_ctx();
3014
3015        let component = HttpComponent::new();
3016        let endpoint_ctx = NoOpComponentContext;
3017        let endpoint = component
3018            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3019            .unwrap();
3020        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3021
3022        let exchange = Exchange::new(Message::default());
3023        let result = producer.oneshot(exchange).await.unwrap();
3024
3025        let status = result
3026            .input
3027            .header("CamelHttpResponseCode")
3028            .and_then(|v| v.as_u64())
3029            .unwrap();
3030        assert_eq!(status, 200);
3031
3032        assert!(!result.input.body.is_empty());
3033    }
3034
3035    #[tokio::test]
3036    async fn producer_excludes_host_and_framing() {
3037        use tower::ServiceExt;
3038
3039        let (url, captured, _handle) = start_request_capturing_server().await;
3040        let ctx = test_producer_ctx();
3041        let component = HttpComponent::new();
3042        let endpoint_ctx = NoOpComponentContext;
3043        let endpoint = component
3044            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3045            .unwrap();
3046        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3047
3048        let mut exchange = Exchange::new(Message::default());
3049        exchange.input.set_header("Host", "localhost");
3050        exchange.input.set_header("Content-Length", "42");
3051        exchange.input.set_header("Connection", "keep-alive");
3052        exchange.input.set_header("Upgrade", "h2c");
3053
3054        let result = producer.oneshot(exchange).await;
3055        assert!(result.is_ok(), "producer call failed: {:?}", result);
3056
3057        tokio::time::sleep(Duration::from_millis(100)).await;
3058        let request = captured
3059            .lock()
3060            .unwrap()
3061            .take()
3062            .expect("no outbound request captured");
3063        let lower = request.to_ascii_lowercase();
3064        assert!(
3065            !lower.contains("\r\nhost: localhost"),
3066            "forwarded Host: localhost must be stripped\n{request}"
3067        );
3068        assert!(
3069            !lower.contains("content-length: 42"),
3070            "exchange Content-Length must not be copied\n{request}"
3071        );
3072        assert!(
3073            !lower.lines().any(|l| l.starts_with("connection:")),
3074            "Connection header must not be forwarded\n{request}"
3075        );
3076        assert!(
3077            !lower.lines().any(|l| l.starts_with("upgrade:")),
3078            "Upgrade header must not be forwarded\n{request}"
3079        );
3080        let host_header = lower
3081            .lines()
3082            .find(|l| l.starts_with("host:"))
3083            .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3084            .expect("outbound Host header must be set by reqwest");
3085        assert!(
3086            host_header.starts_with("127.0.0.1:"),
3087            "outbound Host '{host_header}' must match the capture-server address"
3088        );
3089    }
3090
3091    #[tokio::test]
3092    async fn producer_forwards_request_only_headers() {
3093        use tower::ServiceExt;
3094
3095        let (url, captured, _handle) = start_request_capturing_server().await;
3096        let ctx = test_producer_ctx();
3097        let component = HttpComponent::new();
3098        let endpoint_ctx = NoOpComponentContext;
3099        let endpoint = component
3100            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3101            .unwrap();
3102        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3103
3104        let mut exchange = Exchange::new(Message::default());
3105        exchange.input.set_header("Accept", "application/json");
3106        exchange.input.set_header("User-Agent", "myclient/1.0");
3107
3108        let result = producer.oneshot(exchange).await;
3109        assert!(result.is_ok(), "producer call failed: {:?}", result);
3110
3111        tokio::time::sleep(Duration::from_millis(100)).await;
3112        let request = captured
3113            .lock()
3114            .unwrap()
3115            .take()
3116            .expect("no outbound request captured");
3117        let lower = request.to_ascii_lowercase();
3118        assert!(
3119            lower.contains("accept: application/json"),
3120            "request-only Accept header must be forwarded\n{request}"
3121        );
3122        assert!(
3123            lower.contains("user-agent: myclient/1.0"),
3124            "request-only User-Agent header must be forwarded\n{request}"
3125        );
3126    }
3127
3128    #[tokio::test]
3129    async fn producer_honours_skip_request_headers() {
3130        use tower::ServiceExt;
3131
3132        let (url, captured, _handle) = start_request_capturing_server().await;
3133        let ctx = test_producer_ctx();
3134        let component = HttpComponent::new();
3135        let endpoint_ctx = NoOpComponentContext;
3136        let endpoint = component
3137            .create_endpoint(
3138                &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3139                &endpoint_ctx,
3140            )
3141            .unwrap();
3142        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3143
3144        let mut exchange = Exchange::new(Message::default());
3145        exchange.input.set_header("Authorization", "Bearer x");
3146
3147        let result = producer.oneshot(exchange).await;
3148        assert!(result.is_ok(), "producer call failed: {:?}", result);
3149
3150        tokio::time::sleep(Duration::from_millis(100)).await;
3151        let request = captured
3152            .lock()
3153            .unwrap()
3154            .take()
3155            .expect("no outbound request captured");
3156        assert!(
3157            !request.to_ascii_lowercase().contains("authorization"),
3158            "Authorization must be stripped by skipRequestHeaders\n{request}"
3159        );
3160    }
3161
3162    #[tokio::test]
3163    async fn test_http_producer_post_with_body() {
3164        use tower::ServiceExt;
3165
3166        let (url, _handle) = start_test_server().await;
3167        let ctx = test_producer_ctx();
3168
3169        let component = HttpComponent::new();
3170        let endpoint_ctx = NoOpComponentContext;
3171        let endpoint = component
3172            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3173            .unwrap();
3174        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3175
3176        let exchange = Exchange::new(Message::new("request body"));
3177        let result = producer.oneshot(exchange).await.unwrap();
3178
3179        let status = result
3180            .input
3181            .header("CamelHttpResponseCode")
3182            .and_then(|v| v.as_u64())
3183            .unwrap();
3184        assert_eq!(status, 200);
3185    }
3186
3187    #[tokio::test]
3188    async fn test_http_producer_method_from_header() {
3189        use tower::ServiceExt;
3190
3191        let (url, _handle) = start_test_server().await;
3192        let ctx = test_producer_ctx();
3193
3194        let component = HttpComponent::new();
3195        let endpoint_ctx = NoOpComponentContext;
3196        let endpoint = component
3197            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3198            .unwrap();
3199        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3200
3201        let mut exchange = Exchange::new(Message::default());
3202        exchange.input.set_header(
3203            "CamelHttpMethod",
3204            serde_json::Value::String("DELETE".to_string()),
3205        );
3206
3207        let result = producer.oneshot(exchange).await.unwrap();
3208        let status = result
3209            .input
3210            .header("CamelHttpResponseCode")
3211            .and_then(|v| v.as_u64())
3212            .unwrap();
3213        assert_eq!(status, 200);
3214    }
3215
3216    #[tokio::test]
3217    async fn test_http_producer_forced_method() {
3218        use tower::ServiceExt;
3219
3220        let (url, _handle) = start_test_server().await;
3221        let ctx = test_producer_ctx();
3222
3223        let component = HttpComponent::new();
3224        let endpoint_ctx = NoOpComponentContext;
3225        let endpoint = component
3226            .create_endpoint(
3227                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3228                &endpoint_ctx,
3229            )
3230            .unwrap();
3231        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3232
3233        let exchange = Exchange::new(Message::default());
3234        let result = producer.oneshot(exchange).await.unwrap();
3235
3236        let status = result
3237            .input
3238            .header("CamelHttpResponseCode")
3239            .and_then(|v| v.as_u64())
3240            .unwrap();
3241        assert_eq!(status, 200);
3242    }
3243
3244    #[tokio::test]
3245    async fn test_http_producer_throw_exception_on_failure() {
3246        use tower::ServiceExt;
3247
3248        let (url, _handle) = start_status_server(404).await;
3249        let ctx = test_producer_ctx();
3250
3251        let component = HttpComponent::new();
3252        let endpoint_ctx = NoOpComponentContext;
3253        let endpoint = component
3254            .create_endpoint(
3255                &format!("{url}/not-found?allowInternal=true"),
3256                &endpoint_ctx,
3257            )
3258            .unwrap();
3259        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3260
3261        let exchange = Exchange::new(Message::default());
3262        let result = producer.oneshot(exchange).await;
3263        assert!(result.is_err());
3264
3265        match result.unwrap_err() {
3266            CamelError::HttpOperationFailed { status_code, .. } => {
3267                assert_eq!(status_code, 404);
3268            }
3269            e => panic!("Expected HttpOperationFailed, got: {e}"),
3270        }
3271    }
3272
3273    #[tokio::test]
3274    async fn test_http_producer_no_throw_on_failure() {
3275        use tower::ServiceExt;
3276
3277        let (url, _handle) = start_status_server(500).await;
3278        let ctx = test_producer_ctx();
3279
3280        let component = HttpComponent::new();
3281        let endpoint_ctx = NoOpComponentContext;
3282        let endpoint = component
3283            .create_endpoint(
3284                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3285                &endpoint_ctx,
3286            )
3287            .unwrap();
3288        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3289
3290        let exchange = Exchange::new(Message::default());
3291        let result = producer.oneshot(exchange).await.unwrap();
3292
3293        let status = result
3294            .input
3295            .header("CamelHttpResponseCode")
3296            .and_then(|v| v.as_u64())
3297            .unwrap();
3298        assert_eq!(status, 500);
3299    }
3300
3301    #[tokio::test]
3302    async fn test_http_producer_uri_override() {
3303        use tower::ServiceExt;
3304
3305        let (url, _handle) = start_test_server().await;
3306        let ctx = test_producer_ctx();
3307
3308        let component = HttpComponent::new();
3309        let endpoint_ctx = NoOpComponentContext;
3310        let endpoint = component
3311            .create_endpoint(
3312                "http://localhost:1/does-not-exist?allowInternal=true",
3313                &endpoint_ctx,
3314            )
3315            .unwrap();
3316        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3317
3318        let mut exchange = Exchange::new(Message::default());
3319        exchange.input.set_header(
3320            "CamelHttpUri",
3321            serde_json::Value::String(format!("{url}/api")),
3322        );
3323
3324        let result = producer.oneshot(exchange).await.unwrap();
3325        let status = result
3326            .input
3327            .header("CamelHttpResponseCode")
3328            .and_then(|v| v.as_u64())
3329            .unwrap();
3330        assert_eq!(status, 200);
3331    }
3332
3333    #[tokio::test]
3334    async fn test_http_producer_response_headers_mapped() {
3335        use tower::ServiceExt;
3336
3337        let (url, _handle) = start_test_server().await;
3338        let ctx = test_producer_ctx();
3339
3340        let component = HttpComponent::new();
3341        let endpoint_ctx = NoOpComponentContext;
3342        let endpoint = component
3343            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3344            .unwrap();
3345        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3346
3347        let exchange = Exchange::new(Message::default());
3348        let result = producer.oneshot(exchange).await.unwrap();
3349
3350        assert!(
3351            result.input.header("Content-Type").is_some(),
3352            "Response should have Content-Type header"
3353        );
3354        assert!(result.input.header("CamelHttpResponseText").is_some());
3355    }
3356
3357    // -----------------------------------------------------------------------
3358    // Bug fix tests: Client configuration per-endpoint
3359    // -----------------------------------------------------------------------
3360
3361    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3362        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3363        let addr = listener.local_addr().unwrap();
3364        let url = format!("http://127.0.0.1:{}", addr.port());
3365
3366        let handle = tokio::spawn(async move {
3367            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3368            loop {
3369                if let Ok((mut stream, _)) = listener.accept().await {
3370                    tokio::spawn(async move {
3371                        let mut buf = vec![0u8; 4096];
3372                        let n = stream.read(&mut buf).await.unwrap_or(0);
3373                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
3374
3375                        // Check if this is a request to /final
3376                        if request.contains("GET /final") {
3377                            let body = r#"{"status":"final"}"#;
3378                            let response = format!(
3379                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3380                                body.len(),
3381                                body
3382                            );
3383                            let _ = stream.write_all(response.as_bytes()).await;
3384                        } else {
3385                            // Redirect to /final
3386                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3387                            let _ = stream.write_all(response.as_bytes()).await;
3388                        }
3389                    });
3390                }
3391            }
3392        });
3393
3394        (url, handle)
3395    }
3396
3397    struct CapturedRequest {
3398        method: String,
3399        path: String,
3400        body: Vec<u8>,
3401        content_length: Option<String>,
3402        transfer_encoding: Option<String>,
3403    }
3404
3405    /// Parse a request head plus its Content-Length-driven body from a freshly
3406    /// accepted connection. Returns `None` if the client closes before sending
3407    /// a complete request head. Does NOT read until EOF/shutdown (reqwest pools
3408    /// keep-alive connections and never sends FIN) and does NOT rely on a
3409    /// single fixed-size read (a segmented small body would flake).
3410    async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3411        use tokio::io::AsyncReadExt;
3412
3413        // Read the request head (up to and including the terminating CRLF CRLF).
3414        let mut buf: Vec<u8> = Vec::new();
3415        let mut chunk = [0u8; 4096];
3416        let head_end: usize;
3417        loop {
3418            let n = stream.read(&mut chunk).await.unwrap_or(0);
3419            if n == 0 {
3420                return None;
3421            }
3422            buf.extend_from_slice(&chunk[..n]);
3423            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3424                head_end = pos + 4;
3425                break;
3426            }
3427        }
3428
3429        // Parse the request head.
3430        let head = String::from_utf8_lossy(&buf[..head_end]);
3431        let mut lines = head.split("\r\n");
3432        let request_line = lines.next().unwrap_or("");
3433        let mut parts = request_line.split_whitespace();
3434        let method = parts.next().unwrap_or("").to_string();
3435        let path = parts.next().unwrap_or("").to_string();
3436
3437        let mut content_length: Option<String> = None;
3438        let mut transfer_encoding: Option<String> = None;
3439        for line in lines {
3440            if let Some((name, value)) = line.split_once(':') {
3441                let name = name.trim().to_ascii_lowercase();
3442                let value = value.trim().to_string();
3443                if name == "content-length" {
3444                    content_length = Some(value);
3445                } else if name == "transfer-encoding" {
3446                    transfer_encoding = Some(value);
3447                }
3448            }
3449        }
3450
3451        // Content-Length-driven exact read. A missing header means a 0-length body.
3452        let body_len: usize = content_length
3453            .as_deref()
3454            .and_then(|v| v.parse::<usize>().ok())
3455            .unwrap_or(0);
3456
3457        let mut body: Vec<u8> = buf[head_end..].to_vec();
3458        while body.len() < body_len {
3459            let n = stream.read(&mut chunk).await.unwrap_or(0);
3460            if n == 0 {
3461                break;
3462            }
3463            body.extend_from_slice(&chunk[..n]);
3464        }
3465        body.truncate(body_len);
3466
3467        Some(CapturedRequest {
3468            method,
3469            path,
3470            body,
3471            content_length,
3472            transfer_encoding,
3473        })
3474    }
3475
3476    /// A raw-TCP capture server. Each connection parses the request head, then
3477    /// performs a Content-Length-driven exact read of the body (see
3478    /// [`capture_request`]). Each connection is dropped after the response so
3479    /// every hop opens a fresh connection.
3480    async fn start_capture_server() -> (
3481        String,
3482        tokio::task::JoinHandle<()>,
3483        Arc<Mutex<Vec<CapturedRequest>>>,
3484    ) {
3485        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3486        let addr = listener.local_addr().unwrap();
3487        let url = format!("http://127.0.0.1:{}", addr.port());
3488
3489        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3490        let captured_for_return = Arc::clone(&captured);
3491
3492        let handle = tokio::spawn(async move {
3493            use tokio::io::AsyncWriteExt;
3494            loop {
3495                if let Ok((mut stream, _)) = listener.accept().await {
3496                    let captured = Arc::clone(&captured);
3497                    tokio::spawn(async move {
3498                        let Some(req) = capture_request(&mut stream).await else {
3499                            return;
3500                        };
3501                        captured.lock().unwrap().push(req);
3502
3503                        // 200 OK with Content-Length: 0 and no body, then drop
3504                        // the stream so the client opens a fresh connection.
3505                        let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3506                        let _ = stream.write_all(response.as_bytes()).await;
3507                    });
3508                }
3509            }
3510        });
3511
3512        (url, handle, captured_for_return)
3513    }
3514
3515    /// A raw-TCP capture server whose `/hop307` and `/hop308` paths answer with
3516    /// `307 Temporary Redirect` / `308 Permanent Redirect` to `/final`, and
3517    /// whose `/final` path answers `200 OK` with an empty body. Every hop
3518    /// records a `CapturedRequest` (Content-Length-driven exact read) and drops
3519    /// the connection after responding so each hop is a fresh connection.
3520    async fn start_redirect_capture_server() -> (
3521        String,
3522        tokio::task::JoinHandle<()>,
3523        Arc<Mutex<Vec<CapturedRequest>>>,
3524    ) {
3525        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3526        let addr = listener.local_addr().unwrap();
3527        let url = format!("http://127.0.0.1:{}", addr.port());
3528
3529        let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3530        let captured_for_return = Arc::clone(&captured);
3531
3532        let handle = tokio::spawn(async move {
3533            use tokio::io::AsyncWriteExt;
3534            loop {
3535                if let Ok((mut stream, _)) = listener.accept().await {
3536                    let captured = Arc::clone(&captured);
3537                    tokio::spawn(async move {
3538                        let Some(req) = capture_request(&mut stream).await else {
3539                            return;
3540                        };
3541                        let path = req.path.clone();
3542                        captured.lock().unwrap().push(req);
3543
3544                        let (status_line, location) = match path.as_str() {
3545                            "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3546                            "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3547                            "/final" => ("HTTP/1.1 200 OK", None),
3548                            _ => ("HTTP/1.1 404 Not Found", None),
3549                        };
3550
3551                        let response = match location {
3552                            Some(loc) => format!(
3553                                "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3554                            ),
3555                            None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3556                        };
3557                        let _ = stream.write_all(response.as_bytes()).await;
3558                    });
3559                }
3560            }
3561        });
3562
3563        (url, handle, captured_for_return)
3564    }
3565
3566    #[tokio::test]
3567    async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3568        use tower::ServiceExt;
3569
3570        let (url, _handle, captured) = start_capture_server().await;
3571        let ctx = test_producer_ctx();
3572
3573        let component = HttpComponent::with_config(HttpConfig::default());
3574        let endpoint_ctx = NoOpComponentContext;
3575        let endpoint = component
3576            .create_endpoint(
3577                &format!("{url}?httpMethod=GET&allowInternal=true"),
3578                &endpoint_ctx,
3579            )
3580            .unwrap();
3581        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3582
3583        let mut exchange = Exchange::new(Message::default());
3584        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3585
3586        let result = producer.oneshot(exchange).await.unwrap();
3587
3588        let status = result
3589            .input
3590            .header("CamelHttpResponseCode")
3591            .and_then(|v| v.as_u64())
3592            .unwrap();
3593        assert_eq!(status, 200);
3594
3595        let captured = captured.lock().unwrap();
3596        assert_eq!(captured.len(), 1, "expected exactly one captured request");
3597        let req = &captured[0];
3598        assert_eq!(req.method, "GET");
3599        // `httpMethod`/`allowInternal` are URI options, not request-target
3600        // query params, so the origin-form target is just "/".
3601        assert_eq!(req.path, "/");
3602        assert!(req.body.is_empty(), "GET must not carry a body");
3603        assert!(
3604            req.content_length.is_none(),
3605            "suppressed request must not carry Content-Length"
3606        );
3607        assert!(
3608            req.transfer_encoding.is_none(),
3609            "suppressed request must not carry Transfer-Encoding"
3610        );
3611
3612        // The exchange body is consumed by the producer (std::mem::take).
3613        assert!(
3614            result.input.body.is_empty(),
3615            "exchange body must be consumed"
3616        );
3617    }
3618
3619    #[tokio::test]
3620    async fn test_head_with_body_suppressed_via_header() {
3621        use tower::ServiceExt;
3622
3623        let (url, _handle, captured) = start_capture_server().await;
3624        let ctx = test_producer_ctx();
3625
3626        let component = HttpComponent::with_config(HttpConfig::default());
3627        let endpoint_ctx = NoOpComponentContext;
3628        let endpoint = component
3629            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3630            .unwrap();
3631        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3632
3633        let mut exchange = Exchange::new(Message::default());
3634        exchange.input.set_header(
3635            "CamelHttpMethod",
3636            serde_json::Value::String("HEAD".to_string()),
3637        );
3638        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3639
3640        let result = producer.oneshot(exchange).await.unwrap();
3641        let status = result
3642            .input
3643            .header("CamelHttpResponseCode")
3644            .and_then(|v| v.as_u64())
3645            .unwrap();
3646        assert_eq!(status, 200);
3647
3648        let captured = captured.lock().unwrap();
3649        assert_eq!(captured.len(), 1);
3650        let req = &captured[0];
3651        assert_eq!(req.method, "HEAD");
3652        assert!(req.body.is_empty(), "HEAD must not carry a body");
3653    }
3654
3655    #[tokio::test]
3656    async fn test_delete_options_trace_with_body_suppressed() {
3657        use tower::ServiceExt;
3658
3659        let (url, _handle, captured) = start_capture_server().await;
3660        let ctx = test_producer_ctx();
3661        let component = HttpComponent::with_config(HttpConfig::default());
3662        let endpoint_ctx = NoOpComponentContext;
3663
3664        for method in ["DELETE", "OPTIONS", "TRACE"] {
3665            let endpoint = component
3666                .create_endpoint(
3667                    &format!("{url}?httpMethod={method}&allowInternal=true"),
3668                    &endpoint_ctx,
3669                )
3670                .unwrap();
3671            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3672
3673            let mut exchange = Exchange::new(Message::default());
3674            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3675
3676            let result = producer.oneshot(exchange).await.unwrap();
3677            let status = result
3678                .input
3679                .header("CamelHttpResponseCode")
3680                .and_then(|v| v.as_u64())
3681                .unwrap();
3682            assert_eq!(status, 200, "method {method} should succeed");
3683        }
3684
3685        let captured = captured.lock().unwrap();
3686        assert_eq!(captured.len(), 3, "expected three captured requests");
3687        for method in ["DELETE", "OPTIONS", "TRACE"] {
3688            let req = captured
3689                .iter()
3690                .find(|r| r.method == method)
3691                .unwrap_or_else(|| panic!("missing captured request for {method}"));
3692            assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3693        }
3694    }
3695
3696    #[tokio::test]
3697    async fn test_post_put_patch_with_body_still_sent() {
3698        use tower::ServiceExt;
3699
3700        let (url, _handle, captured) = start_capture_server().await;
3701        let ctx = test_producer_ctx();
3702        let component = HttpComponent::with_config(HttpConfig::default());
3703        let endpoint_ctx = NoOpComponentContext;
3704
3705        for method in ["POST", "PUT", "PATCH"] {
3706            let endpoint = component
3707                .create_endpoint(
3708                    &format!("{url}?httpMethod={method}&allowInternal=true"),
3709                    &endpoint_ctx,
3710                )
3711                .unwrap();
3712            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3713
3714            let payload = format!("body-for-{method}");
3715            let mut exchange = Exchange::new(Message::default());
3716            exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3717
3718            let result = producer.oneshot(exchange).await.unwrap();
3719            let status = result
3720                .input
3721                .header("CamelHttpResponseCode")
3722                .and_then(|v| v.as_u64())
3723                .unwrap();
3724            assert_eq!(status, 200, "method {method} should succeed");
3725        }
3726
3727        let captured = captured.lock().unwrap();
3728        assert_eq!(captured.len(), 3, "expected three captured requests");
3729        for method in ["POST", "PUT", "PATCH"] {
3730            let req = captured
3731                .iter()
3732                .find(|r| r.method == method)
3733                .unwrap_or_else(|| panic!("missing captured request for {method}"));
3734            let expected = format!("body-for-{method}");
3735            assert!(!req.body.is_empty(), "{method} must still carry its body");
3736            assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3737        }
3738    }
3739
3740    /// A GET with a stream body must not attach the stream: the entity-enclosing
3741    /// gate drops the stream (mem::take) before the request is built, leaving
3742    /// the exchange body Empty instead of a partially-consumed Body::Stream.
3743    #[tokio::test]
3744    async fn test_stream_body_under_get_not_attached() {
3745        use tower::ServiceExt;
3746
3747        let (url, _handle, captured) = start_capture_server().await;
3748        let ctx = test_producer_ctx();
3749
3750        let component = HttpComponent::with_config(HttpConfig::default());
3751        let endpoint_ctx = NoOpComponentContext;
3752        let endpoint = component
3753            .create_endpoint(
3754                &format!("{url}?httpMethod=GET&allowInternal=true"),
3755                &endpoint_ctx,
3756            )
3757            .unwrap();
3758        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3759
3760        let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3761            vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3762        let stream = Box::pin(futures::stream::iter(chunks));
3763        let mut exchange = Exchange::new(Message::default());
3764        exchange.input.body = Body::Stream(StreamBody {
3765            stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3766            metadata: StreamMetadata::default(),
3767        });
3768
3769        let result = producer.oneshot(exchange).await.unwrap();
3770
3771        let status = result
3772            .input
3773            .header("CamelHttpResponseCode")
3774            .and_then(|v| v.as_u64())
3775            .unwrap();
3776        assert_eq!(status, 200);
3777
3778        let captured = captured.lock().unwrap();
3779        assert_eq!(captured.len(), 1, "expected exactly one captured request");
3780        assert!(
3781            captured[0].body.is_empty(),
3782            "GET must not carry a stream body"
3783        );
3784        assert!(
3785            captured[0].transfer_encoding.is_none(),
3786            "suppressed request must not carry Transfer-Encoding"
3787        );
3788        assert!(
3789            captured[0].content_length.is_none(),
3790            "suppressed request must not carry Content-Length"
3791        );
3792        assert!(
3793            result.input.body.is_empty(),
3794            "exchange body must be consumed to Empty, not left as a stream"
3795        );
3796    }
3797
3798    /// A suppressed body must never be replayed across 307/308 redirect hops:
3799    /// the gate empties `materialized_body` before the redirect loop runs, so
3800    /// neither the first hop nor the final hop carries the body.
3801    #[tokio::test]
3802    async fn test_redirect_hops_never_replay_suppressed_body() {
3803        use tower::ServiceExt;
3804
3805        let (url, _handle, captured) = start_redirect_capture_server().await;
3806        let ctx = test_producer_ctx();
3807
3808        let component =
3809            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3810        let endpoint_ctx = NoOpComponentContext;
3811
3812        for path in ["/hop307", "/hop308"] {
3813            let endpoint = component
3814                .create_endpoint(
3815                    &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3816                    &endpoint_ctx,
3817                )
3818                .unwrap();
3819            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3820
3821            let mut exchange = Exchange::new(Message::default());
3822            exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3823
3824            let result = producer.oneshot(exchange).await.unwrap();
3825            let status = result
3826                .input
3827                .header("CamelHttpResponseCode")
3828                .and_then(|v| v.as_u64())
3829                .unwrap();
3830            assert_eq!(
3831                status, 200,
3832                "redirect chain for {path} should end at /final"
3833            );
3834        }
3835
3836        // Two chains (307 and 308), each with two hops (redirect + final).
3837        let captured = captured.lock().unwrap();
3838        assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3839        for req in captured.iter() {
3840            assert!(
3841                req.body.is_empty(),
3842                "hop {} {} must not carry a body",
3843                req.method,
3844                req.path
3845            );
3846        }
3847    }
3848
3849    /// The warn! emitted on a suppressed body renders three distinguishable
3850    /// substrings in the log line (tracing-subscriber default field format):
3851    ///   - the message:       "dropping request body ..."
3852    ///   - `method = %method_str`            → `method=GET`
3853    ///   - `correlation_id = %exchange.correlation_id()` → `correlation_id=<uuid>`
3854    /// The closure matches all three so exactly one warn per suppressed
3855    /// request is required (the "HTTP request" debug! also carries
3856    /// `method=GET` and the same `correlation_id=`, but not the message).
3857    #[tracing_test::traced_test]
3858    #[tokio::test]
3859    async fn test_suppressed_body_logs_exactly_one_warn() {
3860        use tower::ServiceExt;
3861
3862        let (url, _handle, _captured) = start_capture_server().await;
3863        let ctx = test_producer_ctx();
3864
3865        let component = HttpComponent::with_config(HttpConfig::default());
3866        let endpoint_ctx = NoOpComponentContext;
3867        let endpoint = component
3868            .create_endpoint(
3869                &format!("{url}?httpMethod=GET&allowInternal=true"),
3870                &endpoint_ctx,
3871            )
3872            .unwrap();
3873        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3874
3875        let mut exchange = Exchange::new(Message::default());
3876        exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3877        let correlation_id = exchange.correlation_id().to_string();
3878
3879        let result = producer.oneshot(exchange).await.unwrap();
3880        let status = result
3881            .input
3882            .header("CamelHttpResponseCode")
3883            .and_then(|v| v.as_u64())
3884            .unwrap();
3885        assert_eq!(status, 200);
3886
3887        logs_assert(|lines: &[&str]| {
3888            let hits = lines
3889                .iter()
3890                .filter(|l| {
3891                    l.contains("dropping request body")
3892                        && l.contains("method=GET")
3893                        && l.contains(&format!("correlation_id={correlation_id}"))
3894                })
3895                .count();
3896            match hits {
3897                1 => Ok(()),
3898                n => Err(format!("expected exactly one body-drop warn, found {n}")),
3899            }
3900        });
3901    }
3902
3903    #[tracing_test::traced_test]
3904    #[tokio::test]
3905    async fn test_empty_body_get_emits_no_warn() {
3906        use tower::ServiceExt;
3907
3908        let (url, _handle, _captured) = start_capture_server().await;
3909        let ctx = test_producer_ctx();
3910
3911        let component = HttpComponent::with_config(HttpConfig::default());
3912        let endpoint_ctx = NoOpComponentContext;
3913        let endpoint = component
3914            .create_endpoint(
3915                &format!("{url}?httpMethod=GET&allowInternal=true"),
3916                &endpoint_ctx,
3917            )
3918            .unwrap();
3919        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3920
3921        let exchange = Exchange::new(Message::default());
3922        let result = producer.oneshot(exchange).await.unwrap();
3923        let status = result
3924            .input
3925            .header("CamelHttpResponseCode")
3926            .and_then(|v| v.as_u64())
3927            .unwrap();
3928        assert_eq!(status, 200);
3929
3930        logs_assert(|lines: &[&str]| {
3931            let hits = lines
3932                .iter()
3933                .filter(|l| l.contains("dropping request body"))
3934                .count();
3935            match hits {
3936                0 => Ok(()),
3937                n => Err(format!("expected no body-drop warn, found {n}")),
3938            }
3939        });
3940    }
3941
3942    #[tokio::test]
3943    async fn test_follow_redirects_false_does_not_follow() {
3944        use tower::ServiceExt;
3945
3946        let (url, _handle) = start_redirect_server().await;
3947        let ctx = test_producer_ctx();
3948
3949        let component =
3950            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
3951        let endpoint_ctx = NoOpComponentContext;
3952        let endpoint = component
3953            .create_endpoint(
3954                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
3955                &endpoint_ctx,
3956            )
3957            .unwrap();
3958        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3959
3960        let exchange = Exchange::new(Message::default());
3961        let result = producer.oneshot(exchange).await.unwrap();
3962
3963        // Should get 302, NOT follow redirect to 200
3964        let status = result
3965            .input
3966            .header("CamelHttpResponseCode")
3967            .and_then(|v| v.as_u64())
3968            .unwrap();
3969        assert_eq!(
3970            status, 302,
3971            "Should NOT follow redirect when followRedirects=false"
3972        );
3973    }
3974
3975    #[tokio::test]
3976    async fn test_follow_redirects_true_follows_redirect() {
3977        use tower::ServiceExt;
3978
3979        let (url, _handle) = start_redirect_server().await;
3980        let ctx = test_producer_ctx();
3981
3982        let component =
3983            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3984        let endpoint_ctx = NoOpComponentContext;
3985        let endpoint = component
3986            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3987            .unwrap();
3988        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3989
3990        let exchange = Exchange::new(Message::default());
3991        let result = producer.oneshot(exchange).await.unwrap();
3992
3993        // Should follow redirect and get 200
3994        let status = result
3995            .input
3996            .header("CamelHttpResponseCode")
3997            .and_then(|v| v.as_u64())
3998            .unwrap();
3999        assert_eq!(
4000            status, 200,
4001            "Should follow redirect when followRedirects=true"
4002        );
4003    }
4004
4005    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
4006    /// This verifies the manual redirect loop executes correctly.
4007    #[tokio::test]
4008    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4009        use tower::ServiceExt;
4010
4011        // Use the existing redirect server which redirects to /final on the same server
4012        let (url, _handle) = start_redirect_server().await;
4013        let ctx = test_producer_ctx();
4014
4015        let component =
4016            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4017        let endpoint_ctx = NoOpComponentContext;
4018        let endpoint = component
4019            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4020            .unwrap();
4021        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4022
4023        let exchange = Exchange::new(Message::default());
4024        let result = producer.oneshot(exchange).await;
4025
4026        // With allowInternal=true, the redirect should succeed
4027        assert!(
4028            result.is_ok(),
4029            "Redirect should succeed with allowInternal=true, got: {:?}",
4030            result
4031        );
4032        let exchange = result.unwrap();
4033        let status = exchange
4034            .input
4035            .header("CamelHttpResponseCode")
4036            .and_then(|v| v.as_u64())
4037            .unwrap();
4038        assert_eq!(status, 200, "Should follow redirect to /final");
4039    }
4040
4041    /// With allowInternal=true, redirects to private IPs should be followed.
4042    #[tokio::test]
4043    async fn test_redirect_to_private_ip_allowed_when_configured() {
4044        use tower::ServiceExt;
4045
4046        // Start a server that redirects to /final on the same server (127.0.0.1)
4047        let (url, _handle) = start_redirect_server().await;
4048        let ctx = test_producer_ctx();
4049
4050        let component =
4051            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4052        let endpoint_ctx = NoOpComponentContext;
4053        let endpoint = component
4054            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4055            .unwrap();
4056        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4057
4058        let exchange = Exchange::new(Message::default());
4059        let result = producer.oneshot(exchange).await.unwrap();
4060
4061        let status = result
4062            .input
4063            .header("CamelHttpResponseCode")
4064            .and_then(|v| v.as_u64())
4065            .unwrap();
4066        assert_eq!(
4067            status, 200,
4068            "Should follow redirect to private IP when allowInternal=true"
4069        );
4070    }
4071
4072    /// Integration test: with allowInternal=false (default), a redirect to a
4073    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
4074    #[tokio::test]
4075    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4076        use tower::ServiceExt;
4077
4078        // Server that redirects to the AWS metadata endpoint (link-local private IP)
4079        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4080        let addr = listener.local_addr().unwrap();
4081        let url = format!("http://127.0.0.1:{}", addr.port());
4082
4083        let handle = tokio::spawn(async move {
4084            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4085            loop {
4086                if let Ok((mut stream, _)) = listener.accept().await {
4087                    tokio::spawn(async move {
4088                        let mut buf = vec![0u8; 4096];
4089                        let _ = stream.read(&mut buf).await;
4090                        // Always redirect to the metadata endpoint
4091                        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";
4092                        let _ = stream.write_all(response.as_bytes()).await;
4093                    });
4094                }
4095            }
4096        });
4097
4098        let ctx = test_producer_ctx();
4099        let component =
4100            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4101        let endpoint_ctx = NoOpComponentContext;
4102        // allowInternal=false is the default — do NOT set it
4103        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4104        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4105
4106        let exchange = Exchange::new(Message::default());
4107        let result = producer.oneshot(exchange).await;
4108
4109        // Must be an error — SSRF guard blocks the redirect target
4110        assert!(
4111            result.is_err(),
4112            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4113        );
4114        let err = result.unwrap_err().to_string();
4115        assert!(
4116            err.contains("blocked IP")
4117                || err.contains("private IP")
4118                || err.contains("SSRF")
4119                || err.contains("not allowed"),
4120            "Error should mention SSRF/IP blocking, got: {err}"
4121        );
4122
4123        handle.abort();
4124    }
4125
4126    /// Integration test: exceeding maxRedirects produces a clear error.
4127    #[tokio::test]
4128    async fn test_too_many_redirects_returns_error() {
4129        use tower::ServiceExt;
4130
4131        // Server that always redirects to itself (infinite loop)
4132        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4133        let addr = listener.local_addr().unwrap();
4134        let url = format!("http://127.0.0.1:{}", addr.port());
4135
4136        let handle = tokio::spawn(async move {
4137            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4138            loop {
4139                if let Ok((mut stream, _)) = listener.accept().await {
4140                    tokio::spawn(async move {
4141                        let mut buf = vec![0u8; 4096];
4142                        let _ = stream.read(&mut buf).await;
4143                        // Always redirect to /loop
4144                        let response =
4145                            "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
4146                        let _ = stream.write_all(response.as_bytes()).await;
4147                    });
4148                }
4149            }
4150        });
4151
4152        let ctx = test_producer_ctx();
4153        let component =
4154            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4155        let endpoint_ctx = NoOpComponentContext;
4156        let endpoint = component
4157            .create_endpoint(
4158                &format!("{url}?allowInternal=true&maxRedirects=2"),
4159                &endpoint_ctx,
4160            )
4161            .unwrap();
4162        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4163
4164        let exchange = Exchange::new(Message::default());
4165        let result = producer.oneshot(exchange).await;
4166
4167        // With the fix, exceeding max redirects returns the redirect response
4168        // as-is instead of erroring. The 302 redirect response is returned
4169        // after followRedirects exhausts the allowed redirect count (2).
4170        // Disable throwExceptionOnFailure to inspect the raw response status.
4171        //
4172        // Old behavior: Err("Too many redirects (max 2)")
4173        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
4174        match result {
4175            Err(e) => {
4176                // If throw_exception_on_failure is on, we get HttpOperationFailed
4177                let msg = e.to_string();
4178                assert!(
4179                    msg.contains("HTTP operation failed") || msg.contains("302"),
4180                    "expected redirect-after-exhaustion error, got: {msg}"
4181                );
4182            }
4183            Ok(ex) => {
4184                let response_code = ex
4185                    .input
4186                    .header("CamelHttpResponseCode")
4187                    .and_then(|v| v.as_u64());
4188                assert_eq!(
4189                    response_code,
4190                    Some(302),
4191                    "expected 302 after exhausting redirects"
4192                );
4193            }
4194        }
4195
4196        handle.abort();
4197    }
4198
4199    #[tokio::test]
4200    async fn test_query_params_forwarded_to_http_request() {
4201        use tower::ServiceExt;
4202
4203        let (url, _handle) = start_test_server().await;
4204        let ctx = test_producer_ctx();
4205
4206        let component = HttpComponent::new();
4207        let endpoint_ctx = NoOpComponentContext;
4208        // apiKey is NOT a Camel option, should be forwarded as query param
4209        let endpoint = component
4210            .create_endpoint(
4211                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4212                &endpoint_ctx,
4213            )
4214            .unwrap();
4215        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4216
4217        let exchange = Exchange::new(Message::default());
4218        let result = producer.oneshot(exchange).await.unwrap();
4219
4220        // The test server returns the request info in response
4221        // We just verify it succeeds (the query param was sent)
4222        let status = result
4223            .input
4224            .header("CamelHttpResponseCode")
4225            .and_then(|v| v.as_u64())
4226            .unwrap();
4227        assert_eq!(status, 200);
4228    }
4229
4230    #[tokio::test]
4231    async fn test_non_camel_query_params_are_forwarded() {
4232        // This test verifies Bug #3 fix: non-Camel options should be forwarded
4233        // We'll test the config parsing, not the actual HTTP call
4234        let config = HttpEndpointConfig::from_uri(
4235            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4236        )
4237        .unwrap();
4238
4239        // apiKey and token are NOT Camel options, should be forwarded
4240        assert!(
4241            config.query_params.contains_key("apiKey"),
4242            "apiKey should be preserved"
4243        );
4244        assert!(
4245            config.query_params.contains_key("token"),
4246            "token should be preserved"
4247        );
4248        assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4249        assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4250
4251        // httpMethod IS a Camel option, should NOT be in query_params
4252        assert!(
4253            !config.query_params.contains_key("httpMethod"),
4254            "httpMethod should not be forwarded"
4255        );
4256    }
4257
4258    #[test]
4259    fn test_query_params_are_url_encoded_when_resolving_url() {
4260        let config =
4261            HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4262        let exchange = Exchange::new(Message::default());
4263
4264        let url = HttpProducer::resolve_url(&exchange, &config);
4265
4266        assert!(url.contains("q=hello+world"), "url was: {url}");
4267        assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4268    }
4269
4270    // -----------------------------------------------------------------------
4271    // Timeout tests (HTTP-004)
4272    // -----------------------------------------------------------------------
4273
4274    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4275        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4276        let addr = listener.local_addr().unwrap();
4277        let url = format!("http://127.0.0.1:{}", addr.port());
4278
4279        let handle = tokio::spawn(async move {
4280            loop {
4281                if let Ok((mut stream, _)) = listener.accept().await {
4282                    let delay = delay_ms;
4283                    tokio::spawn(async move {
4284                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4285                        let mut buf = vec![0u8; 4096];
4286                        let _ = stream.read(&mut buf).await;
4287                        // Send headers immediately (no Content-Length → chunked)
4288                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4289                        let _ = stream.write_all(headers.as_bytes()).await;
4290                        // Delay before sending body chunk
4291                        tokio::time::sleep(Duration::from_millis(delay)).await;
4292                        let body = r#"{"status":"slow"}"#;
4293                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4294                        let _ = stream.write_all(chunk.as_bytes()).await;
4295                    });
4296                }
4297            }
4298        });
4299
4300        (url, handle)
4301    }
4302
4303    #[tokio::test]
4304    async fn test_http_producer_timeout() {
4305        use tower::ServiceExt;
4306
4307        // Server delays 500ms, client timeout is 100ms → should timeout
4308        let (url, _handle) = start_slow_server(500).await;
4309        let ctx = test_producer_ctx();
4310
4311        let component = HttpComponent::with_config(
4312            HttpConfig::default()
4313                .with_read_timeout_ms(100)
4314                .with_response_timeout_ms(30_000), // generous response timeout
4315        );
4316        let endpoint_ctx = NoOpComponentContext;
4317        let endpoint = component
4318            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4319            .unwrap();
4320        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4321
4322        let exchange = Exchange::new(Message::default());
4323        let result = producer.oneshot(exchange).await;
4324
4325        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4326        let err = result.unwrap_err().to_string();
4327        assert!(
4328            err.contains("Read timeout") || err.contains("timeout"),
4329            "Error should mention timeout, got: {}",
4330            err
4331        );
4332    }
4333
4334    #[tokio::test]
4335    async fn test_http_producer_no_timeout_when_fast() {
4336        use tower::ServiceExt;
4337
4338        let (url, _handle) = start_test_server().await;
4339        let ctx = test_producer_ctx();
4340
4341        let component =
4342            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4343        let endpoint_ctx = NoOpComponentContext;
4344        let endpoint = component
4345            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4346            .unwrap();
4347        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4348
4349        let exchange = Exchange::new(Message::default());
4350        let result = producer.oneshot(exchange).await.unwrap();
4351
4352        let status = result
4353            .input
4354            .header("CamelHttpResponseCode")
4355            .and_then(|v| v.as_u64())
4356            .unwrap();
4357        assert_eq!(status, 200);
4358    }
4359
4360    // -----------------------------------------------------------------------
4361    // SSRF Protection tests
4362    // -----------------------------------------------------------------------
4363
4364    #[tokio::test]
4365    async fn test_http_producer_blocks_metadata_endpoint() {
4366        use tower::ServiceExt;
4367
4368        let ctx = test_producer_ctx();
4369        let component = HttpComponent::new();
4370        let endpoint_ctx = NoOpComponentContext;
4371        let endpoint = component
4372            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4373            .unwrap();
4374        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4375
4376        let mut exchange = Exchange::new(Message::default());
4377        exchange.input.set_header(
4378            "CamelHttpUri",
4379            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4380        );
4381
4382        let result = producer.oneshot(exchange).await;
4383        assert!(result.is_err(), "Should block AWS metadata endpoint");
4384
4385        let err = result.unwrap_err();
4386        assert!(
4387            err.to_string().contains("Private IP"),
4388            "Error should mention private IP blocking, got: {}",
4389            err
4390        );
4391    }
4392
4393    #[test]
4394    fn test_ssrf_config_defaults() {
4395        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4396        assert!(
4397            !config.allow_internal,
4398            "Private IPs should be blocked by default"
4399        );
4400        assert!(
4401            config.blocked_hosts.is_empty(),
4402            "Blocked hosts should be empty by default"
4403        );
4404    }
4405
4406    #[test]
4407    fn test_ssrf_config_allow_internal() {
4408        let config =
4409            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4410        assert!(
4411            config.allow_internal,
4412            "Private IPs should be allowed when explicitly set"
4413        );
4414    }
4415
4416    #[test]
4417    fn test_ssrf_config_blocked_hosts() {
4418        let config = HttpEndpointConfig::from_uri(
4419            "http://example.com/api?blockedHosts=evil.com,malware.net",
4420        )
4421        .unwrap();
4422        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4423    }
4424
4425    #[tokio::test]
4426    async fn test_http_producer_blocks_localhost() {
4427        use tower::ServiceExt;
4428
4429        let ctx = test_producer_ctx();
4430        let component = HttpComponent::new();
4431        let endpoint_ctx = NoOpComponentContext;
4432        let endpoint = component
4433            .create_endpoint("http://example.com/api", &endpoint_ctx)
4434            .unwrap();
4435        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4436
4437        let mut exchange = Exchange::new(Message::default());
4438        exchange.input.set_header(
4439            "CamelHttpUri",
4440            serde_json::Value::String("http://localhost:8080/internal".to_string()),
4441        );
4442
4443        let result = producer.oneshot(exchange).await;
4444        assert!(result.is_err(), "Should block localhost");
4445    }
4446
4447    #[tokio::test]
4448    async fn test_http_producer_blocks_loopback_ip() {
4449        use tower::ServiceExt;
4450
4451        let ctx = test_producer_ctx();
4452        let component = HttpComponent::new();
4453        let endpoint_ctx = NoOpComponentContext;
4454        let endpoint = component
4455            .create_endpoint("http://example.com/api", &endpoint_ctx)
4456            .unwrap();
4457        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4458
4459        let mut exchange = Exchange::new(Message::default());
4460        exchange.input.set_header(
4461            "CamelHttpUri",
4462            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4463        );
4464
4465        let result = producer.oneshot(exchange).await;
4466        assert!(result.is_err(), "Should block loopback IP");
4467    }
4468
4469    #[tokio::test]
4470    async fn test_http_producer_allows_private_ip_when_enabled() {
4471        use tower::ServiceExt;
4472
4473        let ctx = test_producer_ctx();
4474        let component = HttpComponent::new();
4475        let endpoint_ctx = NoOpComponentContext;
4476        // With allowInternal=true, the validation should pass
4477        // (actual connection will fail, but that's expected)
4478        let endpoint = component
4479            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4480            .unwrap();
4481        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4482
4483        let exchange = Exchange::new(Message::default());
4484
4485        // The request will fail because we can't connect, but it should NOT fail
4486        // due to SSRF protection
4487        let result = producer.oneshot(exchange).await;
4488        // We expect connection error, not SSRF error
4489        if let Err(ref e) = result {
4490            let err_str = e.to_string();
4491            assert!(
4492                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4493                "Should not be SSRF error, got: {}",
4494                err_str
4495            );
4496        }
4497    }
4498
4499    // -----------------------------------------------------------------------
4500    // HttpServerConfig tests
4501    // -----------------------------------------------------------------------
4502
4503    #[test]
4504    fn test_http_server_config_parse() {
4505        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4506        assert_eq!(cfg.host, "0.0.0.0");
4507        assert_eq!(cfg.port, 8080);
4508        assert_eq!(cfg.path, "/orders");
4509        assert_eq!(cfg.max_inflight_requests, 1024);
4510    }
4511
4512    #[test]
4513    fn test_http_server_config_scheme() {
4514        // UriConfig trait method returns "http" as primary scheme
4515        assert_eq!(HttpServerConfig::scheme(), "http");
4516    }
4517
4518    #[test]
4519    fn test_http_server_config_from_components() {
4520        // Test from_components directly (trait method)
4521        let components = camel_component_api::UriComponents {
4522            scheme: "https".to_string(),
4523            path: "//0.0.0.0:8443/api".to_string(),
4524            params: std::collections::HashMap::from([
4525                ("maxRequestBody".to_string(), "5242880".to_string()),
4526                ("maxInflightRequests".to_string(), "7".to_string()),
4527            ]),
4528        };
4529        let cfg = HttpServerConfig::from_components(components).unwrap();
4530        assert_eq!(cfg.host, "0.0.0.0");
4531        assert_eq!(cfg.port, 8443);
4532        assert_eq!(cfg.path, "/api");
4533        assert_eq!(cfg.max_request_body, 5242880);
4534        assert_eq!(cfg.max_inflight_requests, 7);
4535    }
4536
4537    #[test]
4538    fn test_http_server_config_default_path() {
4539        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4540        assert_eq!(cfg.path, "/");
4541    }
4542
4543    #[test]
4544    fn test_http_server_config_wrong_scheme() {
4545        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4546    }
4547
4548    #[test]
4549    fn test_http_server_config_invalid_port() {
4550        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4551    }
4552
4553    #[test]
4554    fn test_http_server_config_default_port_by_scheme() {
4555        // HTTP without explicit port should default to 80
4556        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4557        assert_eq!(cfg_http.port, 80);
4558
4559        // HTTPS without explicit port should default to 443
4560        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4561        assert_eq!(cfg_https.port, 443);
4562    }
4563
4564    #[test]
4565    fn test_request_envelope_and_reply_are_send() {
4566        fn assert_send<T: Send>() {}
4567        assert_send::<RequestEnvelope>();
4568        assert_send::<HttpReply>();
4569    }
4570
4571    // -----------------------------------------------------------------------
4572    // ServerRegistry tests
4573    // -----------------------------------------------------------------------
4574
4575    #[test]
4576    fn test_server_registry_global_is_singleton() {
4577        let r1 = ServerRegistry::global();
4578        let r2 = ServerRegistry::global();
4579        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4580    }
4581
4582    #[allow(clippy::await_holding_lock)]
4583    #[tokio::test]
4584    async fn test_concurrent_get_or_spawn_returns_same_registry() {
4585        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4586        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4587        let port = listener.local_addr().unwrap().port();
4588        drop(listener);
4589
4590        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4591            Arc::new(std::sync::Mutex::new(Vec::new()));
4592
4593        let mut handles = Vec::new();
4594        for _ in 0..4 {
4595            let results = results.clone();
4596            handles.push(tokio::spawn(async move {
4597                let registry = ServerRegistry::global()
4598                    .get_or_spawn(
4599                        "127.0.0.1",
4600                        port,
4601                        2 * 1024 * 1024,
4602                        10 * 1024 * 1024,
4603                        1024,
4604                        test_rt(),
4605                        "test-route".into(),
4606                        None,
4607                    )
4608                    .await
4609                    .unwrap();
4610                results.lock().unwrap().push(registry);
4611            }));
4612        }
4613
4614        for h in handles {
4615            h.await.unwrap();
4616        }
4617
4618        let registries = results.lock().unwrap();
4619        assert_eq!(registries.len(), 4);
4620        for i in 1..registries.len() {
4621            assert!(
4622                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
4623                "all concurrent callers should get same route registry"
4624            );
4625        }
4626    }
4627
4628    #[test]
4629    fn test_server_registry_distinguishes_host_and_port() {
4630        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4631        let rt = tokio::runtime::Runtime::new().expect("runtime");
4632        rt.block_on(async {
4633            let registry = ServerRegistry::global();
4634            // Use two distinct host values with same configured port key.
4635            // Port 0 is acceptable here because the registry key uses the configured
4636            // tuple, not the OS-assigned ephemeral port.
4637            let d1 = registry
4638                .get_or_spawn(
4639                    "127.0.0.1",
4640                    0,
4641                    1024 * 1024,
4642                    10 * 1024 * 1024,
4643                    1024,
4644                    test_rt(),
4645                    "test-route-1".into(),
4646                    None,
4647                )
4648                .await;
4649            let d2 = registry
4650                .get_or_spawn(
4651                    "0.0.0.0",
4652                    0,
4653                    1024 * 1024,
4654                    10 * 1024 * 1024,
4655                    1024,
4656                    test_rt(),
4657                    "test-route-2".into(),
4658                    None,
4659                )
4660                .await;
4661            assert!(d1.is_ok());
4662            assert!(d2.is_ok());
4663            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4664        });
4665    }
4666
4667    #[allow(clippy::await_holding_lock)]
4668    #[tokio::test]
4669    async fn test_shared_server_max_request_body_policy_is_deterministic() {
4670        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4671        let registry = ServerRegistry::global();
4672        // First registration: maxRequestBody = 1 MB
4673        let d1 = registry
4674            .get_or_spawn(
4675                "127.0.0.1",
4676                9991,
4677                1024 * 1024,
4678                10 * 1024 * 1024,
4679                1024,
4680                test_rt(),
4681                "test-route".into(),
4682                None,
4683            )
4684            .await;
4685        assert!(d1.is_ok());
4686
4687        // Second registration on same (host,port): maxRequestBody = 2 MB
4688        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
4689        let d2 = registry
4690            .get_or_spawn(
4691                "127.0.0.1",
4692                9991,
4693                2 * 1024 * 1024,
4694                10 * 1024 * 1024,
4695                1024,
4696                test_rt(),
4697                "test-route-2".into(),
4698                None,
4699            )
4700            .await;
4701        assert!(d2.is_err());
4702        let err = d2.unwrap_err();
4703        assert!(
4704            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4705            "Expected incompatible maxRequestBody error, got: {}",
4706            err
4707        );
4708    }
4709
4710    #[test]
4711    fn test_server_registry_reset_clears_entries() {
4712        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4713        let rt = tokio::runtime::Runtime::new().expect("runtime");
4714        rt.block_on(async {
4715            // Register something on a unique port
4716            let d1 = ServerRegistry::global()
4717                .get_or_spawn(
4718                    "127.0.0.1",
4719                    9992,
4720                    1024 * 1024,
4721                    10 * 1024 * 1024,
4722                    1024,
4723                    test_rt(),
4724                    "test-route".into(),
4725                    None,
4726                )
4727                .await;
4728            assert!(d1.is_ok());
4729
4730            // Verify entry exists
4731            let guard = ServerRegistry::global().inner.lock().expect("lock");
4732            assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4733            drop(guard);
4734
4735            // Reset
4736            ServerRegistry::reset();
4737
4738            // Verify cleared
4739            let guard = ServerRegistry::global().inner.lock().expect("lock");
4740            assert!(
4741                guard.is_empty(),
4742                "registry should be empty after reset, has {} entries",
4743                guard.len()
4744            );
4745        });
4746    }
4747
4748    #[tokio::test]
4749    async fn registry_rejects_tls_on_plain_port() {
4750        ServerRegistry::reset();
4751        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4752
4753        // First route: plain HTTP
4754        let _r1 = ServerRegistry::global()
4755            .get_or_spawn(
4756                "127.0.0.1",
4757                0,
4758                1024,
4759                1024,
4760                16,
4761                Arc::clone(&rt),
4762                "route-1".into(),
4763                None, // plain
4764            )
4765            .await;
4766
4767        // Second route: TLS on same port → must fail
4768        let result = ServerRegistry::global()
4769            .get_or_spawn(
4770                "127.0.0.1",
4771                0,
4772                1024,
4773                1024,
4774                16,
4775                Arc::clone(&rt),
4776                "route-2".into(),
4777                Some(crate::config::ServerTlsConfig {
4778                    cert_path: "/x.pem".into(),
4779                    key_path: "/y.pem".into(),
4780                }),
4781            )
4782            .await;
4783        assert!(result.is_err(), "must reject TLS on plain port");
4784    }
4785
4786    // -----------------------------------------------------------------------
4787    // D-L10: HTTP monitor_axum_task refcounted shutdown
4788    // -----------------------------------------------------------------------
4789
4790    #[allow(clippy::await_holding_lock)]
4791    #[tokio::test]
4792    async fn test_unregister_last_http_route_keeps_server_alive() {
4793        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4794        ServerRegistry::reset();
4795        let registry = ServerRegistry::global();
4796
4797        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4798        let port = listener.local_addr().unwrap().port();
4799        drop(listener); // Release — ServerRegistry will rebind
4800        let rt = test_rt();
4801
4802        // Register 2 routes on the same (host, port) — OnceCell returns the
4803        // same ServerHandle.
4804        let _r1 = registry
4805            .get_or_spawn(
4806                "127.0.0.1",
4807                port,
4808                1024 * 1024,
4809                10 * 1024 * 1024,
4810                16,
4811                rt.clone(),
4812                "test-route-1".into(),
4813                None,
4814            )
4815            .await
4816            .unwrap();
4817        let _r2 = registry
4818            .get_or_spawn(
4819                "127.0.0.1",
4820                port,
4821                1024 * 1024,
4822                10 * 1024 * 1024,
4823                16,
4824                rt,
4825                "test-route-2".into(),
4826                None,
4827            )
4828            .await
4829            .unwrap();
4830
4831        let key = ("127.0.0.1".to_string(), port);
4832        let cell = {
4833            let guard = registry.inner.lock().expect("lock");
4834            guard.get(&key).expect("entry should exist").clone()
4835        };
4836
4837        // Unregister first route -> monitor still alive (count = 1).
4838        registry.unregister("127.0.0.1", port).await;
4839        {
4840            let handle = cell
4841                .get()
4842                .expect("handle should still exist after first unregister");
4843            assert!(
4844                !handle.monitor_task.is_finished(),
4845                "monitor task should still be alive after first unregister"
4846            );
4847        }
4848
4849        // Unregister second route -> server stays alive (process-lifetime).
4850        registry.unregister("127.0.0.1", port).await;
4851        tokio::time::sleep(Duration::from_millis(20)).await;
4852        {
4853            let handle = cell
4854                .get()
4855                .expect("handle should still exist after last unregister");
4856            assert!(
4857                !handle.monitor_task.is_finished(),
4858                "monitor task should still be alive — server is process-lifetime"
4859            );
4860        }
4861
4862        // Entry stays in registry for potential restart.
4863        {
4864            let guard = registry.inner.lock().expect("lock");
4865            assert!(
4866                guard.get(&key).is_some(),
4867                "entry should remain in registry — server kept alive for restart"
4868            );
4869        }
4870    }
4871
4872    // -----------------------------------------------------------------------
4873    // Axum dispatch handler tests
4874    // -----------------------------------------------------------------------
4875
4876    #[tokio::test]
4877    async fn test_dispatch_handler_returns_404_for_unknown_path() {
4878        let registry = HttpRouteRegistry::new();
4879        // Nothing registered in route registry
4880        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4881        let port = listener.local_addr().unwrap().port();
4882        tokio::spawn(run_axum_server(
4883            listener,
4884            registry,
4885            2 * 1024 * 1024,
4886            10 * 1024 * 1024,
4887            Arc::new(tokio::sync::Semaphore::new(1024)),
4888            test_rt(),
4889            "test-route".into(),
4890        ));
4891
4892        // Wait for server to start
4893        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4894
4895        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4896            .await
4897            .unwrap();
4898        assert_eq!(resp.status().as_u16(), 404);
4899    }
4900
4901    // -----------------------------------------------------------------------
4902    // HttpConsumer tests
4903    // -----------------------------------------------------------------------
4904
4905    #[tokio::test]
4906    async fn test_http_consumer_start_registers_path() {
4907        use camel_component_api::ConsumerContext;
4908
4909        // Get an OS-assigned free port
4910        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4911        let port = listener.local_addr().unwrap().port();
4912        drop(listener); // Release port — ServerRegistry will rebind it
4913
4914        let consumer_cfg = HttpServerConfig {
4915            scheme: "http".to_string(),
4916            host: "127.0.0.1".to_string(),
4917            port,
4918            path: "/ping".to_string(),
4919            max_request_body: 2 * 1024 * 1024,
4920            max_response_body: 10 * 1024 * 1024,
4921            max_inflight_requests: 1024,
4922            method: None,
4923            tls_config: None,
4924        };
4925        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4926
4927        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4928        let token = tokio_util::sync::CancellationToken::new();
4929        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4930
4931        tokio::spawn(async move {
4932            consumer.start(ctx).await.unwrap();
4933        });
4934
4935        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4936
4937        let client = reqwest::Client::new();
4938        let resp_future = client
4939            .post(format!("http://127.0.0.1:{port}/ping"))
4940            .body("hello world")
4941            .send();
4942
4943        let (http_result, _) = tokio::join!(resp_future, async {
4944            if let Some(mut envelope) = rx.recv().await {
4945                // Set a custom status code
4946                envelope.exchange.input.set_header(
4947                    "CamelHttpResponseCode",
4948                    serde_json::Value::Number(201.into()),
4949                );
4950                if let Some(reply_tx) = envelope.reply_tx {
4951                    let _ = reply_tx.send(Ok(envelope.exchange));
4952                }
4953            }
4954        });
4955
4956        let resp = http_result.unwrap();
4957        assert_eq!(resp.status().as_u16(), 201);
4958
4959        token.cancel();
4960    }
4961
4962    /// rc-3y6j: the RequestEnvelope channel capacity must mirror the
4963    /// dispatcher's inflight semaphore so the semaphore stays the single
4964    /// backpressure point. Zero maps to 1 because `mpsc::channel(0)` panics.
4965    #[test]
4966    fn test_envelope_channel_capacity_follows_max_inflight() {
4967        assert_eq!(envelope_channel_capacity(0), 1);
4968        assert_eq!(envelope_channel_capacity(1), 1);
4969        assert_eq!(envelope_channel_capacity(7), 7);
4970        assert_eq!(envelope_channel_capacity(64), 64);
4971        assert_eq!(envelope_channel_capacity(1024), 1024);
4972    }
4973
4974    /// rc-3y6j: `maxInflightRequests=0` is a representable reject-everything
4975    /// configuration. Consumer start must not panic on it (the channel guard)
4976    /// and every request must get 503 from the empty semaphore.
4977    #[tokio::test]
4978    async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
4979        use camel_component_api::ConsumerContext;
4980
4981        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4982        let port = listener.local_addr().unwrap().port();
4983        drop(listener);
4984
4985        let consumer_cfg = HttpServerConfig {
4986            scheme: "http".to_string(),
4987            host: "127.0.0.1".to_string(),
4988            port,
4989            path: "/ping".to_string(),
4990            max_request_body: 2 * 1024 * 1024,
4991            max_response_body: 10 * 1024 * 1024,
4992            max_inflight_requests: 0,
4993            method: None,
4994            tls_config: None,
4995        };
4996        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4997
4998        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4999        let token = tokio_util::sync::CancellationToken::new();
5000        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5001
5002        let start_handle = tokio::spawn(async move {
5003            consumer.start(ctx).await.unwrap();
5004        });
5005
5006        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5007
5008        let client = reqwest::Client::new();
5009        let resp = client
5010            .post(format!("http://127.0.0.1:{port}/ping"))
5011            .body("hello world")
5012            .send()
5013            .await
5014            .unwrap();
5015        assert_eq!(resp.status().as_u16(), 503);
5016
5017        token.cancel();
5018        let _ = start_handle.await;
5019    }
5020
5021    /// rc-w1u9: HttpConsumer MUST declare Explicit startup_mode so the runtime
5022    /// waits for the listener bind before publishing RouteStarted.
5023    #[test]
5024    fn test_http_consumer_startup_mode_is_explicit() {
5025        use camel_component_api::ConsumerStartupMode;
5026        let consumer_cfg = HttpServerConfig {
5027            scheme: "http".to_string(),
5028            host: "127.0.0.1".to_string(),
5029            port: 0,
5030            path: "/x".to_string(),
5031            max_request_body: 2 * 1024 * 1024,
5032            max_response_body: 10 * 1024 * 1024,
5033            max_inflight_requests: 1024,
5034            method: None,
5035            tls_config: None,
5036        };
5037        let consumer = HttpConsumer::new(consumer_cfg, test_rt());
5038        assert_eq!(
5039            consumer.startup_mode(),
5040            ConsumerStartupMode::Explicit,
5041            "HttpConsumer must opt into Explicit startup"
5042        );
5043    }
5044
5045    /// rc-w1u9: HttpConsumer::start() MUST call ctx.mark_ready() AFTER bind
5046    /// + route registration. The StartupSignal resolves Ok only when that
5047    /// happens. Verified here by injecting our own signal pair into the
5048    /// ConsumerContext and asserting the receiver resolves within a bounded
5049    /// window even before any HTTP request is made.
5050    #[allow(clippy::await_holding_lock)]
5051    #[tokio::test]
5052    async fn test_http_consumer_emits_mark_ready_after_bind() {
5053        use camel_component_api::{ConsumerContext, StartupSignal};
5054
5055        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5056
5057        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5058        let port = listener.local_addr().unwrap().port();
5059        drop(listener);
5060
5061        let consumer_cfg = HttpServerConfig {
5062            scheme: "http".to_string(),
5063            host: "127.0.0.1".to_string(),
5064            port,
5065            path: "/ready-probe".to_string(),
5066            max_request_body: 2 * 1024 * 1024,
5067            max_response_body: 10 * 1024 * 1024,
5068            max_inflight_requests: 1024,
5069            method: None,
5070            tls_config: None,
5071        };
5072        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5073
5074        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5075        let token = tokio_util::sync::CancellationToken::new();
5076        let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
5077
5078        // Inject our own startup signal so we can observe mark_ready.
5079        let (signal, startup_rx) = StartupSignal::pair();
5080        let ctx = ctx.with_startup(signal);
5081
5082        // Spawn start() — it MUST call mark_ready once the listener is bound
5083        // and the path is registered.
5084        tokio::spawn(async move {
5085            let _ = consumer.start(ctx).await;
5086        });
5087
5088        // The receiver MUST resolve Ok within a bounded window — proving
5089        // mark_ready was called by start(). A short timeout catches the
5090        // regression where mark_ready is never called (the old behaviour
5091        // would hang the receiver forever, which is exactly the rc-w1u9 bug).
5092        let result =
5093            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
5094                .await
5095                .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
5096        assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
5097
5098        // Cancellation tears down the spawned start() loop.
5099        token.cancel();
5100    }
5101
5102    #[tokio::test]
5103    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
5104        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5105
5106        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5107        let port = listener.local_addr().unwrap().port();
5108        drop(listener);
5109
5110        let consumer_cfg = HttpServerConfig {
5111            scheme: "http".to_string(),
5112            host: "127.0.0.1".to_string(),
5113            port,
5114            path: "/saturation".to_string(),
5115            max_request_body: 2 * 1024 * 1024,
5116            max_response_body: 10 * 1024 * 1024,
5117            max_inflight_requests: 1,
5118            method: None,
5119            tls_config: None,
5120        };
5121        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5122
5123        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5124        let token = tokio_util::sync::CancellationToken::new();
5125        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5126        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5127        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5128
5129        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
5130        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
5131
5132        tokio::spawn(async move {
5133            let mut first_seen_tx = Some(first_seen_tx);
5134            let mut unblock_first_rx = Some(unblock_first_rx);
5135
5136            while let Some(envelope) = rx.recv().await {
5137                if let Some(tx) = first_seen_tx.take() {
5138                    let _ = tx.send(());
5139                    if let Some(rx_unblock) = unblock_first_rx.take() {
5140                        let _ = rx_unblock.await;
5141                    }
5142                }
5143
5144                if let Some(reply_tx) = envelope.reply_tx {
5145                    let _ = reply_tx.send(Ok(envelope.exchange));
5146                }
5147            }
5148        });
5149
5150        let client = reqwest::Client::new();
5151        let first_req = {
5152            let client = client.clone();
5153            async move {
5154                client
5155                    .get(format!("http://127.0.0.1:{port}/saturation"))
5156                    .send()
5157                    .await
5158                    .unwrap()
5159            }
5160        };
5161
5162        let first_handle = tokio::spawn(first_req);
5163        first_seen_rx.await.unwrap();
5164
5165        let second_resp = client
5166            .get(format!("http://127.0.0.1:{port}/saturation"))
5167            .send()
5168            .await
5169            .unwrap();
5170
5171        assert_eq!(second_resp.status().as_u16(), 503);
5172
5173        let _ = unblock_first_tx.send(());
5174        let first_resp = first_handle.await.unwrap();
5175        assert_eq!(first_resp.status().as_u16(), 200);
5176
5177        token.cancel();
5178    }
5179
5180    #[tokio::test]
5181    #[allow(clippy::await_holding_lock)]
5182    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5183        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5184
5185        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5186
5187        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5188        let port = listener.local_addr().unwrap().port();
5189        drop(listener);
5190
5191        let consumer_cfg = HttpServerConfig {
5192            scheme: "http".to_string(),
5193            host: "127.0.0.1".to_string(),
5194            port,
5195            path: "/limit-bytes".to_string(),
5196            max_request_body: 2 * 1024 * 1024,
5197            max_response_body: 16,
5198            max_inflight_requests: 1024,
5199            method: None,
5200            tls_config: None,
5201        };
5202        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5203
5204        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5205        let token = tokio_util::sync::CancellationToken::new();
5206        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5207        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5208        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5209
5210        let client = reqwest::Client::new();
5211        let send_fut = client
5212            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5213            .send();
5214
5215        let (http_result, _) = tokio::join!(send_fut, async {
5216            if let Some(mut envelope) = rx.recv().await {
5217                envelope.exchange.input.body =
5218                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5219                if let Some(reply_tx) = envelope.reply_tx {
5220                    let _ = reply_tx.send(Ok(envelope.exchange));
5221                }
5222            }
5223        });
5224
5225        let resp = http_result.unwrap();
5226        assert_eq!(resp.status().as_u16(), 500);
5227        let body = resp.text().await.unwrap();
5228        assert_eq!(body, "Response body exceeds configured limit");
5229        token.cancel();
5230    }
5231
5232    #[tokio::test]
5233    #[allow(clippy::await_holding_lock)]
5234    async fn test_http_consumer_enforces_max_response_body_for_json() {
5235        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5236
5237        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5238
5239        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5240        let port = listener.local_addr().unwrap().port();
5241        drop(listener);
5242
5243        let consumer_cfg = HttpServerConfig {
5244            scheme: "http".to_string(),
5245            host: "127.0.0.1".to_string(),
5246            port,
5247            path: "/limit-json".to_string(),
5248            max_request_body: 2 * 1024 * 1024,
5249            max_response_body: 16,
5250            max_inflight_requests: 1024,
5251            method: None,
5252            tls_config: None,
5253        };
5254        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5255
5256        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5257        let token = tokio_util::sync::CancellationToken::new();
5258        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5259        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5260        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5261
5262        let client = reqwest::Client::new();
5263        let send_fut = client
5264            .get(format!("http://127.0.0.1:{port}/limit-json"))
5265            .send();
5266
5267        let (http_result, _) = tokio::join!(send_fut, async {
5268            if let Some(mut envelope) = rx.recv().await {
5269                envelope.exchange.input.body = camel_component_api::Body::Json(
5270                    serde_json::json!({"message":"this response is bigger than sixteen"}),
5271                );
5272                if let Some(reply_tx) = envelope.reply_tx {
5273                    let _ = reply_tx.send(Ok(envelope.exchange));
5274                }
5275            }
5276        });
5277
5278        let resp = http_result.unwrap();
5279        assert_eq!(resp.status().as_u16(), 500);
5280        let body = resp.text().await.unwrap();
5281        assert_eq!(body, "Response body exceeds configured limit");
5282        token.cancel();
5283    }
5284
5285    #[tokio::test]
5286    #[allow(clippy::await_holding_lock)]
5287    async fn test_http_consumer_enforces_max_response_body_for_xml() {
5288        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5289
5290        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5291
5292        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5293        let port = listener.local_addr().unwrap().port();
5294        drop(listener);
5295
5296        let consumer_cfg = HttpServerConfig {
5297            scheme: "http".to_string(),
5298            host: "127.0.0.1".to_string(),
5299            port,
5300            path: "/limit-xml".to_string(),
5301            max_request_body: 2 * 1024 * 1024,
5302            max_response_body: 16,
5303            max_inflight_requests: 1024,
5304            method: None,
5305            tls_config: None,
5306        };
5307        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5308
5309        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5310        let token = tokio_util::sync::CancellationToken::new();
5311        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5312        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5313        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5314
5315        let client = reqwest::Client::new();
5316        let send_fut = client
5317            .get(format!("http://127.0.0.1:{port}/limit-xml"))
5318            .send();
5319
5320        let (http_result, _) = tokio::join!(send_fut, async {
5321            if let Some(mut envelope) = rx.recv().await {
5322                envelope.exchange.input.body = camel_component_api::Body::Xml(
5323                    "<root><value>way-too-large</value></root>".into(),
5324                );
5325                if let Some(reply_tx) = envelope.reply_tx {
5326                    let _ = reply_tx.send(Ok(envelope.exchange));
5327                }
5328            }
5329        });
5330
5331        let resp = http_result.unwrap();
5332        assert_eq!(resp.status().as_u16(), 500);
5333        let body = resp.text().await.unwrap();
5334        assert_eq!(body, "Response body exceeds configured limit");
5335        token.cancel();
5336    }
5337
5338    #[tokio::test]
5339    #[allow(clippy::await_holding_lock)]
5340    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5341        use camel_component_api::{
5342            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5343        };
5344        use futures::stream;
5345
5346        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5347
5348        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5349        let port = listener.local_addr().unwrap().port();
5350        drop(listener);
5351
5352        let consumer_cfg = HttpServerConfig {
5353            scheme: "http".to_string(),
5354            host: "0.0.0.0".to_string(),
5355            port,
5356            path: "/limit-stream".to_string(),
5357            max_request_body: 2 * 1024 * 1024,
5358            max_response_body: 16,
5359            max_inflight_requests: 1024,
5360            method: None,
5361            tls_config: None,
5362        };
5363        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5364
5365        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5366        let token = tokio_util::sync::CancellationToken::new();
5367        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5368        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5369        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5370
5371        let client = reqwest::Client::new();
5372        let send_fut = client
5373            .get(format!("http://127.0.0.1:{port}/limit-stream"))
5374            .send();
5375
5376        let (http_result, _) = tokio::join!(send_fut, async {
5377            if let Some(mut envelope) = rx.recv().await {
5378                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5379                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5380                let stream = Box::pin(stream::iter(chunks));
5381                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5382                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5383                    metadata: StreamMetadata {
5384                        size_hint: Some(32),
5385                        content_type: Some("application/octet-stream".into()),
5386                        origin: None,
5387                    },
5388                });
5389                if let Some(reply_tx) = envelope.reply_tx {
5390                    let _ = reply_tx.send(Ok(envelope.exchange));
5391                }
5392            }
5393        });
5394
5395        let resp = http_result.unwrap();
5396        assert_eq!(resp.status().as_u16(), 200);
5397        let body = resp.bytes().await.unwrap();
5398        assert_eq!(body.len(), 32);
5399        token.cancel();
5400    }
5401
5402    // -----------------------------------------------------------------------
5403    // Integration tests
5404    // -----------------------------------------------------------------------
5405
5406    #[tokio::test]
5407    #[allow(clippy::await_holding_lock)]
5408    async fn test_integration_single_consumer_round_trip() {
5409        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5410
5411        // Spawns an HTTP consumer on the global ServerRegistry
5412        // (HttpConsumer::start → get_or_spawn). Serialize against the other
5413        // registry tests so parallel runs do not race on shared global state.
5414        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5415
5416        // Get an OS-assigned free port (ephemeral)
5417        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5418        let port = listener.local_addr().unwrap().port();
5419        drop(listener); // Release — ServerRegistry will rebind
5420
5421        let component = HttpComponent::new();
5422        let endpoint_ctx = NoOpComponentContext;
5423        let endpoint = component
5424            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5425            .unwrap();
5426        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5427
5428        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5429        let token = tokio_util::sync::CancellationToken::new();
5430        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5431
5432        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5433        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5434
5435        let client = reqwest::Client::new();
5436        let send_fut = client
5437            .post(format!("http://127.0.0.1:{port}/echo"))
5438            .header("Content-Type", "text/plain")
5439            .body("ping")
5440            .send();
5441
5442        let (http_result, _) = tokio::join!(send_fut, async {
5443            if let Some(mut envelope) = rx.recv().await {
5444                assert_eq!(
5445                    envelope.exchange.input.header("CamelHttpMethod"),
5446                    Some(&serde_json::Value::String("POST".into()))
5447                );
5448                assert_eq!(
5449                    envelope.exchange.input.header("CamelHttpPath"),
5450                    Some(&serde_json::Value::String("/echo".into()))
5451                );
5452                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5453                if let Some(reply_tx) = envelope.reply_tx {
5454                    let _ = reply_tx.send(Ok(envelope.exchange));
5455                }
5456            }
5457        });
5458
5459        let resp = http_result.unwrap();
5460        assert_eq!(resp.status().as_u16(), 200);
5461        let body = resp.text().await.unwrap();
5462        assert_eq!(body, "pong");
5463
5464        token.cancel();
5465    }
5466
5467    #[tokio::test]
5468    #[allow(clippy::await_holding_lock)]
5469    async fn test_integration_two_consumers_shared_port() {
5470        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5471
5472        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5473
5474        // Get an OS-assigned free port (ephemeral)
5475        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5476        let port = listener.local_addr().unwrap().port();
5477        drop(listener);
5478
5479        let component = HttpComponent::new();
5480        let endpoint_ctx = NoOpComponentContext;
5481
5482        // Consumer A: /hello
5483        let endpoint_a = component
5484            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5485            .unwrap();
5486        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5487
5488        // Consumer B: /world
5489        let endpoint_b = component
5490            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5491            .unwrap();
5492        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5493
5494        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5495        let token_a = tokio_util::sync::CancellationToken::new();
5496        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5497
5498        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5499        let token_b = tokio_util::sync::CancellationToken::new();
5500        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5501
5502        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5503        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5504        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5505
5506        let client = reqwest::Client::new();
5507
5508        // Request to /hello
5509        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5510        let (resp_hello, _) = tokio::join!(fut_hello, async {
5511            if let Some(mut envelope) = rx_a.recv().await {
5512                envelope.exchange.input.body =
5513                    camel_component_api::Body::Text("hello-response".to_string());
5514                if let Some(reply_tx) = envelope.reply_tx {
5515                    let _ = reply_tx.send(Ok(envelope.exchange));
5516                }
5517            }
5518        });
5519
5520        // Request to /world
5521        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5522        let (resp_world, _) = tokio::join!(fut_world, async {
5523            if let Some(mut envelope) = rx_b.recv().await {
5524                envelope.exchange.input.body =
5525                    camel_component_api::Body::Text("world-response".to_string());
5526                if let Some(reply_tx) = envelope.reply_tx {
5527                    let _ = reply_tx.send(Ok(envelope.exchange));
5528                }
5529            }
5530        });
5531
5532        let body_a = resp_hello.unwrap().text().await.unwrap();
5533        let body_b = resp_world.unwrap().text().await.unwrap();
5534
5535        assert_eq!(body_a, "hello-response");
5536        assert_eq!(body_b, "world-response");
5537
5538        token_a.cancel();
5539        token_b.cancel();
5540    }
5541
5542    #[tokio::test]
5543    #[allow(clippy::await_holding_lock)]
5544    async fn test_integration_unregistered_path_returns_404() {
5545        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5546
5547        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5548
5549        // Get an OS-assigned free port (ephemeral)
5550        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5551        let port = listener.local_addr().unwrap().port();
5552        drop(listener);
5553
5554        let component = HttpComponent::new();
5555        let endpoint_ctx = NoOpComponentContext;
5556        let endpoint = component
5557            .create_endpoint(
5558                &format!("http://127.0.0.1:{port}/registered"),
5559                &endpoint_ctx,
5560            )
5561            .unwrap();
5562        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5563
5564        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5565        let token = tokio_util::sync::CancellationToken::new();
5566        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5567
5568        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5569
5570        // Wait until the server is actually accepting connections (CI runners can be slow).
5571        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5572        loop {
5573            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5574                .await
5575                .is_ok()
5576            {
5577                break;
5578            }
5579            if std::time::Instant::now() >= deadline {
5580                panic!("HTTP server did not start within 5s on port {port}");
5581            }
5582            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5583        }
5584
5585        let client = reqwest::Client::new();
5586        let resp = client
5587            .get(format!("http://127.0.0.1:{port}/not-there"))
5588            .send()
5589            .await
5590            .unwrap();
5591        assert_eq!(resp.status().as_u16(), 404);
5592
5593        token.cancel();
5594    }
5595
5596    #[test]
5597    fn test_http_consumer_declares_concurrent() {
5598        use camel_component_api::ConcurrencyModel;
5599
5600        let config = HttpServerConfig {
5601            scheme: "http".to_string(),
5602            host: "127.0.0.1".to_string(),
5603            port: 19999,
5604            path: "/test".to_string(),
5605            max_request_body: 2 * 1024 * 1024,
5606            max_response_body: 10 * 1024 * 1024,
5607            max_inflight_requests: 1024,
5608            method: None,
5609            tls_config: None,
5610        };
5611        let consumer = HttpConsumer::new(config, test_rt());
5612        assert_eq!(
5613            consumer.concurrency_model(),
5614            ConcurrencyModel::Concurrent { max: None }
5615        );
5616    }
5617
5618    #[test]
5619    fn server_config_parses_tls_cert_and_key() {
5620        let cfg = HttpServerConfig::from_uri(
5621            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5622        )
5623        .unwrap();
5624        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5625        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5626    }
5627
5628    #[test]
5629    fn server_config_no_tls_when_params_absent() {
5630        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5631        assert!(cfg.tls_config.is_none());
5632    }
5633
5634    // -----------------------------------------------------------------------
5635    // HttpReplyBody streaming tests
5636    // -----------------------------------------------------------------------
5637
5638    #[tokio::test]
5639    async fn test_http_reply_body_stream_variant_exists() {
5640        use bytes::Bytes;
5641        use camel_component_api::CamelError;
5642        use futures::stream;
5643
5644        let chunks: Vec<Result<Bytes, CamelError>> =
5645            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5646        let stream = Box::pin(stream::iter(chunks));
5647        let reply_body = HttpReplyBody::Stream(stream);
5648        // Si compila y el match funciona, el test pasa
5649        match reply_body {
5650            HttpReplyBody::Stream(_) => {}
5651            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5652        }
5653    }
5654
5655    // -----------------------------------------------------------------------
5656    // OpenTelemetry propagation tests (only compiled with "otel" feature)
5657    // -----------------------------------------------------------------------
5658
5659    #[cfg(feature = "otel")]
5660    mod otel_tests {
5661        use super::*;
5662        use camel_component_api::Message;
5663        use tower::ServiceExt;
5664
5665        #[tokio::test]
5666        async fn test_producer_injects_traceparent_header() {
5667            let (url, _handle) = start_test_server_with_header_capture().await;
5668            let ctx = test_producer_ctx();
5669
5670            let component = HttpComponent::new();
5671            let endpoint_ctx = NoOpComponentContext;
5672            let endpoint = component
5673                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5674                .unwrap();
5675            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5676
5677            // Create exchange with an OTel context by extracting from a traceparent header
5678            let mut exchange = Exchange::new(Message::default());
5679            let mut headers = std::collections::HashMap::new();
5680            headers.insert(
5681                "traceparent".to_string(),
5682                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5683            );
5684            camel_otel::extract_into_exchange(&mut exchange, &headers);
5685
5686            let result = producer.oneshot(exchange).await.unwrap();
5687
5688            // Verify request succeeded
5689            let status = result
5690                .input
5691                .header("CamelHttpResponseCode")
5692                .and_then(|v| v.as_u64())
5693                .unwrap();
5694            assert_eq!(status, 200);
5695
5696            // The test server echoes back the received traceparent header
5697            let traceparent = result.input.header("X-Received-Traceparent");
5698            assert!(
5699                traceparent.is_some(),
5700                "traceparent header should have been sent"
5701            );
5702
5703            let traceparent_str = traceparent.unwrap().as_str().unwrap();
5704            // Verify format: version-traceid-spanid-flags
5705            let parts: Vec<&str> = traceparent_str.split('-').collect();
5706            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5707            assert_eq!(parts[0], "00", "version should be 00");
5708            assert_eq!(
5709                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5710                "trace-id should match"
5711            );
5712            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5713            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5714        }
5715
5716        #[tokio::test]
5717        async fn test_consumer_extracts_traceparent_header() {
5718            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5719
5720            // Get an OS-assigned free port
5721            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5722            let port = listener.local_addr().unwrap().port();
5723            drop(listener);
5724
5725            let component = HttpComponent::new();
5726            let endpoint_ctx = NoOpComponentContext;
5727            let endpoint = component
5728                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5729                .unwrap();
5730            let mut consumer = endpoint.create_consumer(rt()).unwrap();
5731
5732            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5733            let token = tokio_util::sync::CancellationToken::new();
5734            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5735
5736            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5737            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5738
5739            // Send request with traceparent header
5740            let client = reqwest::Client::new();
5741            let send_fut = client
5742                .post(format!("http://127.0.0.1:{port}/trace"))
5743                .header(
5744                    "traceparent",
5745                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5746                )
5747                .body("test")
5748                .send();
5749
5750            let (http_result, _) = tokio::join!(send_fut, async {
5751                if let Some(envelope) = rx.recv().await {
5752                    // Verify the exchange has a valid OTel context by re-injecting it
5753                    // and checking the traceparent matches
5754                    let mut injected_headers = std::collections::HashMap::new();
5755                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5756
5757                    assert!(
5758                        injected_headers.contains_key("traceparent"),
5759                        "Exchange should have traceparent after extraction"
5760                    );
5761
5762                    let traceparent = injected_headers.get("traceparent").unwrap();
5763                    let parts: Vec<&str> = traceparent.split('-').collect();
5764                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5765                    assert_eq!(
5766                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5767                        "Trace ID should match the original traceparent header"
5768                    );
5769
5770                    if let Some(reply_tx) = envelope.reply_tx {
5771                        let _ = reply_tx.send(Ok(envelope.exchange));
5772                    }
5773                }
5774            });
5775
5776            let resp = http_result.unwrap();
5777            assert_eq!(resp.status().as_u16(), 200);
5778
5779            token.cancel();
5780        }
5781
5782        #[tokio::test]
5783        async fn test_consumer_extracts_mixed_case_traceparent_header() {
5784            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5785
5786            // Get an OS-assigned free port
5787            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5788            let port = listener.local_addr().unwrap().port();
5789            drop(listener);
5790
5791            let component = HttpComponent::new();
5792            let endpoint_ctx = NoOpComponentContext;
5793            let endpoint = component
5794                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5795                .unwrap();
5796            let mut consumer = endpoint.create_consumer(rt()).unwrap();
5797
5798            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5799            let token = tokio_util::sync::CancellationToken::new();
5800            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5801
5802            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5803            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5804
5805            // Send request with MIXED-CASE TraceParent header (not lowercase)
5806            let client = reqwest::Client::new();
5807            let send_fut = client
5808                .post(format!("http://127.0.0.1:{port}/trace"))
5809                .header(
5810                    "TraceParent",
5811                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5812                )
5813                .body("test")
5814                .send();
5815
5816            let (http_result, _) = tokio::join!(send_fut, async {
5817                if let Some(envelope) = rx.recv().await {
5818                    // Verify the exchange has a valid OTel context by re-injecting it
5819                    // and checking the traceparent matches
5820                    let mut injected_headers = HashMap::new();
5821                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5822
5823                    assert!(
5824                        injected_headers.contains_key("traceparent"),
5825                        "Exchange should have traceparent after extraction from mixed-case header"
5826                    );
5827
5828                    let traceparent = injected_headers.get("traceparent").unwrap();
5829                    let parts: Vec<&str> = traceparent.split('-').collect();
5830                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5831                    assert_eq!(
5832                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5833                        "Trace ID should match the original mixed-case TraceParent header"
5834                    );
5835
5836                    if let Some(reply_tx) = envelope.reply_tx {
5837                        let _ = reply_tx.send(Ok(envelope.exchange));
5838                    }
5839                }
5840            });
5841
5842            let resp = http_result.unwrap();
5843            assert_eq!(resp.status().as_u16(), 200);
5844
5845            token.cancel();
5846        }
5847
5848        #[tokio::test]
5849        async fn test_producer_no_trace_context_no_crash() {
5850            let (url, _handle) = start_test_server().await;
5851            let ctx = test_producer_ctx();
5852
5853            let component = HttpComponent::new();
5854            let endpoint_ctx = NoOpComponentContext;
5855            let endpoint = component
5856                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5857                .unwrap();
5858            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5859
5860            // Create exchange with default (empty) otel_context - no trace context
5861            let exchange = Exchange::new(Message::default());
5862
5863            // Should succeed without panic
5864            let result = producer.oneshot(exchange).await.unwrap();
5865
5866            // Verify request succeeded
5867            let status = result
5868                .input
5869                .header("CamelHttpResponseCode")
5870                .and_then(|v| v.as_u64())
5871                .unwrap();
5872            assert_eq!(status, 200);
5873        }
5874
5875        /// Test server that captures and echoes back the traceparent header
5876        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5877            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5878            let addr = listener.local_addr().unwrap();
5879            let url = format!("http://127.0.0.1:{}", addr.port());
5880
5881            let handle = tokio::spawn(async move {
5882                loop {
5883                    if let Ok((mut stream, _)) = listener.accept().await {
5884                        tokio::spawn(async move {
5885                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
5886                            let mut buf = vec![0u8; 8192];
5887                            let n = stream.read(&mut buf).await.unwrap_or(0);
5888                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
5889
5890                            // Extract traceparent header from request
5891                            let traceparent = request
5892                                .lines()
5893                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
5894                                .map(|line| {
5895                                    line.split(':')
5896                                        .nth(1)
5897                                        .map(|s| s.trim().to_string())
5898                                        .unwrap_or_default()
5899                                })
5900                                .unwrap_or_default();
5901
5902                            let body =
5903                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5904                            let response = format!(
5905                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5906                                body.len(),
5907                                traceparent,
5908                                body
5909                            );
5910                            let _ = stream.write_all(response.as_bytes()).await;
5911                        });
5912                    }
5913                }
5914            });
5915
5916            (url, handle)
5917        }
5918    }
5919
5920    // -----------------------------------------------------------------------
5921    // Response streaming tests (Eje A - Task 2)
5922    // -----------------------------------------------------------------------
5923
5924    // -----------------------------------------------------------------------
5925    // Request streaming tests (Eje B - Task 3)
5926    // -----------------------------------------------------------------------
5927
5928    #[tokio::test]
5929    async fn test_request_body_arrives_as_stream() {
5930        use camel_component_api::Body;
5931        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5932
5933        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5934        let port = listener.local_addr().unwrap().port();
5935        drop(listener);
5936
5937        let component = HttpComponent::new();
5938        let endpoint_ctx = NoOpComponentContext;
5939        let endpoint = component
5940            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
5941            .unwrap();
5942        let mut consumer = endpoint.create_consumer(rt()).unwrap();
5943
5944        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5945        let token = tokio_util::sync::CancellationToken::new();
5946        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5947
5948        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5949        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5950
5951        let client = reqwest::Client::new();
5952        let send_fut = client
5953            .post(format!("http://127.0.0.1:{port}/upload"))
5954            .body("hello streaming world")
5955            .send();
5956
5957        let (http_result, _) = tokio::join!(send_fut, async {
5958            if let Some(mut envelope) = rx.recv().await {
5959                // Body must be Body::Stream, not Body::Text or Body::Bytes
5960                assert!(
5961                    matches!(envelope.exchange.input.body, Body::Stream(_)),
5962                    "expected Body::Stream, got discriminant {:?}",
5963                    std::mem::discriminant(&envelope.exchange.input.body)
5964                );
5965                // Materialize to verify content
5966                let bytes = envelope
5967                    .exchange
5968                    .input
5969                    .body
5970                    .into_bytes(1024 * 1024)
5971                    .await
5972                    .unwrap();
5973                assert_eq!(&bytes[..], b"hello streaming world");
5974
5975                envelope.exchange.input.body = camel_component_api::Body::Empty;
5976                if let Some(reply_tx) = envelope.reply_tx {
5977                    let _ = reply_tx.send(Ok(envelope.exchange));
5978                }
5979            }
5980        });
5981
5982        let resp = http_result.unwrap();
5983        assert_eq!(resp.status().as_u16(), 200);
5984
5985        token.cancel();
5986    }
5987
5988    // -----------------------------------------------------------------------
5989    // Response streaming tests (Eje A - Task 2)
5990    // -----------------------------------------------------------------------
5991
5992    #[tokio::test]
5993    async fn test_streaming_response_chunked() {
5994        use bytes::Bytes;
5995        use camel_component_api::Body;
5996        use camel_component_api::CamelError;
5997        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5998        use camel_component_api::{StreamBody, StreamMetadata};
5999        use futures::stream;
6000        use std::sync::Arc;
6001        use tokio::sync::Mutex;
6002
6003        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6004        let port = listener.local_addr().unwrap().port();
6005        drop(listener);
6006
6007        let component = HttpComponent::new();
6008        let endpoint_ctx = NoOpComponentContext;
6009        let endpoint = component
6010            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
6011            .unwrap();
6012        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6013
6014        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6015        let token = tokio_util::sync::CancellationToken::new();
6016        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6017
6018        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6019        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6020
6021        let client = reqwest::Client::new();
6022        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
6023
6024        let (http_result, _) = tokio::join!(send_fut, async {
6025            if let Some(mut envelope) = rx.recv().await {
6026                // Respond with Body::Stream
6027                let chunks: Vec<Result<Bytes, CamelError>> =
6028                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
6029                let stream = Box::pin(stream::iter(chunks));
6030                envelope.exchange.input.body = Body::Stream(StreamBody {
6031                    stream: Arc::new(Mutex::new(Some(stream))),
6032                    metadata: StreamMetadata::default(),
6033                });
6034                if let Some(reply_tx) = envelope.reply_tx {
6035                    let _ = reply_tx.send(Ok(envelope.exchange));
6036                }
6037            }
6038        });
6039
6040        let resp = http_result.unwrap();
6041        assert_eq!(resp.status().as_u16(), 200);
6042        let body = resp.text().await.unwrap();
6043        assert_eq!(body, "chunk1chunk2");
6044
6045        token.cancel();
6046    }
6047
6048    // -----------------------------------------------------------------------
6049    // 413 Content-Length limit test (Task 4)
6050    // -----------------------------------------------------------------------
6051
6052    #[tokio::test]
6053    async fn test_413_when_content_length_exceeds_limit() {
6054        use camel_component_api::ConsumerContext;
6055
6056        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6057        let port = listener.local_addr().unwrap().port();
6058        drop(listener);
6059
6060        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
6061        let component = HttpComponent::new();
6062        let endpoint_ctx = NoOpComponentContext;
6063        let endpoint = component
6064            .create_endpoint(
6065                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
6066                &endpoint_ctx,
6067            )
6068            .unwrap();
6069        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6070
6071        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6072        let token = tokio_util::sync::CancellationToken::new();
6073        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6074
6075        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6076        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6077
6078        let client = reqwest::Client::new();
6079        let resp = client
6080            .post(format!("http://127.0.0.1:{port}/upload"))
6081            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
6082            .body("x".repeat(1000))
6083            .send()
6084            .await
6085            .unwrap();
6086
6087        assert_eq!(resp.status().as_u16(), 413);
6088
6089        token.cancel();
6090    }
6091
6092    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
6093    /// The spec says: "If there is no Content-Length, the limit does not apply at the
6094    /// consumer level — the route is responsible."
6095    #[tokio::test]
6096    async fn test_chunked_upload_without_content_length_bypasses_limit() {
6097        use bytes::Bytes;
6098        use camel_component_api::Body;
6099        use camel_component_api::ConsumerContext;
6100        use futures::stream;
6101
6102        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6103        let port = listener.local_addr().unwrap().port();
6104        drop(listener);
6105
6106        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
6107        let component = HttpComponent::new();
6108        let endpoint_ctx = NoOpComponentContext;
6109        let endpoint = component
6110            .create_endpoint(
6111                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
6112                &endpoint_ctx,
6113            )
6114            .unwrap();
6115        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6116
6117        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6118        let token = tokio_util::sync::CancellationToken::new();
6119        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6120
6121        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6122        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6123
6124        let client = reqwest::Client::new();
6125
6126        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
6127        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
6128        // but since there's no Content-Length the 413 check must NOT fire.
6129        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
6130            Ok(Bytes::from("y".repeat(50))),
6131            Ok(Bytes::from("y".repeat(50))),
6132        ];
6133        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
6134        let send_fut = client
6135            .post(format!("http://127.0.0.1:{port}/upload"))
6136            .body(stream_body)
6137            .send();
6138
6139        let consumer_fut = async {
6140            // Use timeout to avoid deadlock if the handler rejects before enqueueing
6141            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
6142                Ok(Some(mut envelope)) => {
6143                    assert!(
6144                        matches!(envelope.exchange.input.body, Body::Stream(_)),
6145                        "expected Body::Stream"
6146                    );
6147                    envelope.exchange.input.body = camel_component_api::Body::Empty;
6148                    if let Some(reply_tx) = envelope.reply_tx {
6149                        let _ = reply_tx.send(Ok(envelope.exchange));
6150                    }
6151                }
6152                Ok(None) => panic!("consumer channel closed unexpectedly"),
6153                Err(_) => {
6154                    // Timeout: the request was rejected before reaching the consumer.
6155                    // The HTTP response will carry the real status code (we check below).
6156                }
6157            }
6158        };
6159
6160        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6161
6162        let resp = http_result.unwrap();
6163        // Must NOT be 413; chunked uploads without Content-Length bypass the limit.
6164        assert_ne!(
6165            resp.status().as_u16(),
6166            413,
6167            "chunked upload must not be rejected by maxRequestBody"
6168        );
6169        assert_eq!(resp.status().as_u16(), 200);
6170
6171        token.cancel();
6172    }
6173
6174    #[test]
6175    fn test_is_private_ip_ranges() {
6176        use camel_api::is_ssrf_blocked_ip;
6177        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
6178        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
6179        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
6180        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
6181        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
6182        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
6183
6184        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
6185        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
6186        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
6187        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
6188        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
6189        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
6190        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
6191        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
6192
6193        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
6194        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
6195        assert!(!is_ssrf_blocked_ip(
6196            &"2001:4860:4860::8888".parse().unwrap()
6197        )); // allow-unwrap
6198    }
6199
6200    #[test]
6201    fn test_title_case_header() {
6202        assert_eq!(title_case_header("content-type"), "Content-Type");
6203        assert_eq!(title_case_header("authorization"), "Authorization");
6204        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6205        assert_eq!(title_case_header("host"), "Host");
6206        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6207        assert_eq!(title_case_header("single"), "Single");
6208        assert_eq!(title_case_header(""), "");
6209    }
6210
6211    #[test]
6212    fn test_resolve_url_combines_path_and_query_sources() {
6213        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6214        let mut exchange = Exchange::new(Message::default());
6215        exchange.input.set_header(
6216            "CamelHttpPath",
6217            serde_json::Value::String("next".to_string()),
6218        );
6219        let url = HttpProducer::resolve_url(&exchange, &cfg);
6220        assert!(url.starts_with("http://example.com/base/next?"));
6221        assert!(url.contains("foo=bar"));
6222
6223        exchange.input.set_header(
6224            "CamelHttpUri",
6225            serde_json::Value::String("http://other.test/root".to_string()),
6226        );
6227        exchange.input.set_header(
6228            "CamelHttpQuery",
6229            serde_json::Value::String("a=1&b=2".to_string()),
6230        );
6231
6232        let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6233        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6234    }
6235
6236    fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6237        let mut exchange = Exchange::new(Message::default());
6238        exchange
6239            .input
6240            .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6241        exchange.input.set_header(
6242            "CamelHttpQuery",
6243            serde_json::Value::String(query.to_string()),
6244        );
6245        exchange
6246    }
6247
6248    #[test]
6249    fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6250        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6251        cfg.bridge_endpoint = true;
6252        cfg.query_params
6253            .insert("token".to_string(), "secret".to_string());
6254        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6255        let url = HttpProducer::resolve_url(&exchange, &cfg);
6256        assert_eq!(url, "http://x/?token=secret");
6257        assert!(!url.contains("/foo"));
6258        assert!(!url.contains("dropme"));
6259    }
6260
6261    #[test]
6262    fn resolve_url_bridge_endpoint_false_merges_path() {
6263        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6264        cfg.bridge_endpoint = false;
6265        let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6266        let url = HttpProducer::resolve_url(&exchange, &cfg);
6267        assert!(url.contains("/foo"), "url should contain /foo: {url}");
6268        assert!(
6269            url.contains("dropme=1"),
6270            "url should contain dropme=1: {url}"
6271        );
6272    }
6273
6274    #[test]
6275    fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6276        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6277        cfg.bridge_endpoint = true;
6278        let mut exchange = Exchange::new(Message::default());
6279        exchange.input.set_header(
6280            "CamelHttpPath",
6281            serde_json::Value::String("/foo".to_string()),
6282        );
6283        let url = HttpProducer::resolve_url(&exchange, &cfg);
6284        assert_eq!(url, "http://x");
6285        assert!(!url.contains("/foo"));
6286    }
6287
6288    #[test]
6289    fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6290        let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6291        cfg.bridge_endpoint = true;
6292        // query_params stays empty ([])
6293        let mut exchange = Exchange::new(Message::default());
6294        exchange.input.set_header(
6295            "CamelHttpUri",
6296            serde_json::Value::String("http://dest/explicit".to_string()),
6297        );
6298        exchange.input.set_header(
6299            "CamelHttpPath",
6300            serde_json::Value::String("/foo".to_string()),
6301        );
6302        exchange.input.set_header(
6303            "CamelHttpQuery",
6304            serde_json::Value::String("x=1".to_string()),
6305        );
6306        let url = HttpProducer::resolve_url(&exchange, &cfg);
6307        // Under bridgeEndpoint=true ALL exchange URL headers (CamelHttpUri,
6308        // CamelHttpPath, CamelHttpQuery) are ignored; the endpoint base URL
6309        // wins verbatim.
6310        assert_eq!(url, "http://x");
6311    }
6312
6313    #[test]
6314    fn test_http_producer_helpers_status_and_size_boundaries() {
6315        assert!(HttpProducer::is_ok_status(200, (200, 299)));
6316        assert!(HttpProducer::is_ok_status(299, (200, 299)));
6317        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6318        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6319
6320        assert!(!exceeds_max_response_body(10, 10));
6321        assert!(exceeds_max_response_body(11, 10));
6322    }
6323
6324    // -----------------------------------------------------------------------
6325    // Content-Type inference tests
6326    // -----------------------------------------------------------------------
6327
6328    async fn setup_consumer_on_free_port(
6329        path: &str,
6330    ) -> (
6331        u16,
6332        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6333        tokio_util::sync::CancellationToken,
6334    ) {
6335        use camel_component_api::ConsumerContext;
6336
6337        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6338        let port = listener.local_addr().unwrap().port();
6339        drop(listener);
6340
6341        let consumer_cfg = HttpServerConfig {
6342            scheme: "http".to_string(),
6343            host: "127.0.0.1".to_string(),
6344            port,
6345            path: path.to_string(),
6346            max_request_body: 2 * 1024 * 1024,
6347            max_response_body: 10 * 1024 * 1024,
6348            max_inflight_requests: 1024,
6349            method: None,
6350            tls_config: None,
6351        };
6352        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6353
6354        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6355        let token = tokio_util::sync::CancellationToken::new();
6356        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6357
6358        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6359        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6360
6361        (port, rx, token)
6362    }
6363
6364    #[tokio::test]
6365    async fn test_content_type_inferred_for_json_body() {
6366        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6367
6368        let client = reqwest::Client::new();
6369        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6370
6371        let (http_result, _) = tokio::join!(send_fut, async {
6372            if let Some(mut envelope) = rx.recv().await {
6373                envelope.exchange.input.body =
6374                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6375                if let Some(reply_tx) = envelope.reply_tx {
6376                    let _ = reply_tx.send(Ok(envelope.exchange));
6377                }
6378            }
6379        });
6380
6381        let resp = http_result.unwrap();
6382        assert_eq!(resp.status().as_u16(), 200);
6383        let ct = resp
6384            .headers()
6385            .get("content-type")
6386            .expect("Content-Type header should be present");
6387        assert_eq!(ct, "application/json");
6388        let body = resp.text().await.unwrap();
6389        assert_eq!(body, r#"{"message":"hello"}"#);
6390
6391        token.cancel();
6392    }
6393
6394    #[tokio::test]
6395    async fn test_content_type_inferred_for_text_body() {
6396        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6397
6398        let client = reqwest::Client::new();
6399        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6400
6401        let (http_result, _) = tokio::join!(send_fut, async {
6402            if let Some(mut envelope) = rx.recv().await {
6403                envelope.exchange.input.body =
6404                    camel_component_api::Body::Text("plain text response".to_string());
6405                if let Some(reply_tx) = envelope.reply_tx {
6406                    let _ = reply_tx.send(Ok(envelope.exchange));
6407                }
6408            }
6409        });
6410
6411        let resp = http_result.unwrap();
6412        assert_eq!(resp.status().as_u16(), 200);
6413        let ct = resp
6414            .headers()
6415            .get("content-type")
6416            .expect("Content-Type header should be present");
6417        assert_eq!(ct, "text/plain; charset=utf-8");
6418        let body = resp.text().await.unwrap();
6419        assert_eq!(body, "plain text response");
6420
6421        token.cancel();
6422    }
6423
6424    #[tokio::test]
6425    async fn test_content_type_inferred_for_xml_body() {
6426        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6427
6428        let client = reqwest::Client::new();
6429        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6430
6431        let (http_result, _) = tokio::join!(send_fut, async {
6432            if let Some(mut envelope) = rx.recv().await {
6433                envelope.exchange.input.body =
6434                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6435                if let Some(reply_tx) = envelope.reply_tx {
6436                    let _ = reply_tx.send(Ok(envelope.exchange));
6437                }
6438            }
6439        });
6440
6441        let resp = http_result.unwrap();
6442        assert_eq!(resp.status().as_u16(), 200);
6443        let ct = resp
6444            .headers()
6445            .get("content-type")
6446            .expect("Content-Type header should be present");
6447        assert_eq!(ct, "application/xml");
6448        let body = resp.text().await.unwrap();
6449        assert_eq!(body, "<root><item>value</item></root>");
6450
6451        token.cancel();
6452    }
6453
6454    #[tokio::test]
6455    async fn test_no_content_type_for_empty_body() {
6456        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6457
6458        let client = reqwest::Client::new();
6459        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6460
6461        let (http_result, _) = tokio::join!(send_fut, async {
6462            if let Some(mut envelope) = rx.recv().await {
6463                envelope.exchange.input.body = camel_component_api::Body::Empty;
6464                if let Some(reply_tx) = envelope.reply_tx {
6465                    let _ = reply_tx.send(Ok(envelope.exchange));
6466                }
6467            }
6468        });
6469
6470        let resp = http_result.unwrap();
6471        assert_eq!(resp.status().as_u16(), 200);
6472        assert!(
6473            resp.headers().get("content-type").is_none(),
6474            "Empty body should not set Content-Type"
6475        );
6476
6477        token.cancel();
6478    }
6479
6480    #[tokio::test]
6481    async fn test_no_content_type_for_raw_bytes_body() {
6482        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6483
6484        let client = reqwest::Client::new();
6485        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6486
6487        let (http_result, _) = tokio::join!(send_fut, async {
6488            if let Some(mut envelope) = rx.recv().await {
6489                envelope.exchange.input.body =
6490                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6491                if let Some(reply_tx) = envelope.reply_tx {
6492                    let _ = reply_tx.send(Ok(envelope.exchange));
6493                }
6494            }
6495        });
6496
6497        let resp = http_result.unwrap();
6498        assert_eq!(resp.status().as_u16(), 200);
6499        assert!(
6500            resp.headers().get("content-type").is_none(),
6501            "Raw Bytes body should not set Content-Type"
6502        );
6503
6504        token.cancel();
6505    }
6506
6507    #[tokio::test]
6508    async fn test_content_type_from_stream_metadata() {
6509        use camel_component_api::{StreamBody, StreamMetadata};
6510        use futures::stream;
6511
6512        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6513
6514        let client = reqwest::Client::new();
6515        let send_fut = client
6516            .get(format!("http://127.0.0.1:{port}/stream-ct"))
6517            .send();
6518
6519        let (http_result, _) = tokio::join!(send_fut, async {
6520            if let Some(mut envelope) = rx.recv().await {
6521                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6522                    vec![Ok(bytes::Bytes::from("audio data"))];
6523                let stream = Box::pin(stream::iter(chunks));
6524                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6525                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6526                    metadata: StreamMetadata {
6527                        size_hint: None,
6528                        content_type: Some("audio/mpeg".to_string()),
6529                        origin: None,
6530                    },
6531                });
6532                if let Some(reply_tx) = envelope.reply_tx {
6533                    let _ = reply_tx.send(Ok(envelope.exchange));
6534                }
6535            }
6536        });
6537
6538        let resp = http_result.unwrap();
6539        assert_eq!(resp.status().as_u16(), 200);
6540        let ct = resp
6541            .headers()
6542            .get("content-type")
6543            .expect("Content-Type header should be present");
6544        assert_eq!(ct, "audio/mpeg");
6545        let body = resp.text().await.unwrap();
6546        assert_eq!(body, "audio data");
6547
6548        token.cancel();
6549    }
6550
6551    #[tokio::test]
6552    async fn test_user_content_type_overrides_inferred() {
6553        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6554
6555        let client = reqwest::Client::new();
6556        let send_fut = client
6557            .get(format!("http://127.0.0.1:{port}/override-ct"))
6558            .send();
6559
6560        let (http_result, _) = tokio::join!(send_fut, async {
6561            if let Some(mut envelope) = rx.recv().await {
6562                envelope.exchange.input.body =
6563                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6564                envelope.exchange.input.set_header(
6565                    "Content-Type",
6566                    serde_json::Value::String("text/html".to_string()),
6567                );
6568                if let Some(reply_tx) = envelope.reply_tx {
6569                    let _ = reply_tx.send(Ok(envelope.exchange));
6570                }
6571            }
6572        });
6573
6574        let resp = http_result.unwrap();
6575        assert_eq!(resp.status().as_u16(), 200);
6576        let ct = resp
6577            .headers()
6578            .get("content-type")
6579            .expect("Content-Type header should be present");
6580        assert_eq!(
6581            ct, "text/html",
6582            "User-set Content-Type should take precedence over inferred type"
6583        );
6584
6585        token.cancel();
6586    }
6587
6588    #[tokio::test]
6589    async fn test_user_content_type_with_bytes_body() {
6590        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6591
6592        let client = reqwest::Client::new();
6593        let send_fut = client
6594            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6595            .send();
6596
6597        let (http_result, _) = tokio::join!(send_fut, async {
6598            if let Some(mut envelope) = rx.recv().await {
6599                envelope.exchange.input.body =
6600                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6601                envelope.exchange.input.set_header(
6602                    "Content-Type",
6603                    serde_json::Value::String("application/json".to_string()),
6604                );
6605                if let Some(reply_tx) = envelope.reply_tx {
6606                    let _ = reply_tx.send(Ok(envelope.exchange));
6607                }
6608            }
6609        });
6610
6611        let resp = http_result.unwrap();
6612        assert_eq!(resp.status().as_u16(), 200);
6613        let ct = resp
6614            .headers()
6615            .get("content-type")
6616            .expect("Content-Type header should be present for Bytes body with user header");
6617        assert_eq!(
6618            ct, "application/json",
6619            "User Content-Type should be sent for Bytes body"
6620        );
6621
6622        token.cancel();
6623    }
6624
6625    // -----------------------------------------------------------------------
6626    // Server monitor tests (GRL-005)
6627    // -----------------------------------------------------------------------
6628
6629    #[tokio::test]
6630    async fn monitor_task_silent_on_clean_exit() {
6631        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6632        // Clean exit should complete without panicking or logging errors
6633        monitor_axum_task(
6634            handle,
6635            "127.0.0.1:0".to_string(),
6636            noop_rt(),
6637            "test-monitor".into(),
6638        )
6639        .await;
6640    }
6641
6642    #[tokio::test]
6643    async fn monitor_task_handles_panicked_task() {
6644        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6645            panic!("simulated server crash");
6646        });
6647        // Should complete without panicking even though the inner task panicked
6648        monitor_axum_task(
6649            handle,
6650            "127.0.0.1:9999".to_string(),
6651            noop_rt(),
6652            "test-monitor".into(),
6653        )
6654        .await;
6655    }
6656
6657    // -----------------------------------------------------------------------
6658    // Credential redaction tests
6659    // -----------------------------------------------------------------------
6660
6661    #[test]
6662    fn http_auth_basic_debug_redacts_password() {
6663        let auth = HttpAuth::Basic {
6664            username: "admin".to_string(),
6665            password: "hunter2".to_string(),
6666        };
6667        let debug = format!("{:?}", auth);
6668        assert!(
6669            !debug.contains("hunter2"),
6670            "password must be redacted: {debug}"
6671        );
6672        assert!(debug.contains("admin"), "username should appear: {debug}");
6673    }
6674
6675    #[test]
6676    fn http_auth_bearer_debug_redacts_token() {
6677        let auth = HttpAuth::Bearer {
6678            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6679        };
6680        let debug = format!("{:?}", auth);
6681        assert!(
6682            !debug.contains("eyJhbGci"),
6683            "token must be redacted: {debug}"
6684        );
6685    }
6686
6687    #[test]
6688    fn http_auth_none_debug_shows_variant() {
6689        let debug = format!("{:?}", HttpAuth::None);
6690        assert!(
6691            debug.contains("None"),
6692            "None variant should appear: {debug}"
6693        );
6694    }
6695
6696    #[test]
6697    fn http_endpoint_config_debug_redacts_auth_credentials() {
6698        let config = HttpEndpointConfig::from_uri(
6699            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6700        )
6701        .unwrap();
6702        let debug = format!("{:?}", config);
6703        assert!(
6704            !debug.contains("secret123"),
6705            "password must be redacted in HttpEndpointConfig debug: {debug}"
6706        );
6707    }
6708
6709    // -----------------------------------------------------------------------
6710    // Static file serving tests (Task 5)
6711    // -----------------------------------------------------------------------
6712
6713    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6714    use tower_http::services::ServeDir;
6715
6716    fn make_test_registry() -> HttpRouteRegistry {
6717        HttpRouteRegistry::new()
6718    }
6719
6720    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6721        AppState {
6722            registry,
6723            max_request_body: 2 * 1024 * 1024,
6724            max_response_body: 10 * 1024 * 1024,
6725            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6726        }
6727    }
6728
6729    #[allow(clippy::await_holding_lock)]
6730    #[tokio::test]
6731    async fn test_static_file_serving_serves_file_contents() {
6732        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6733        ServerRegistry::reset();
6734
6735        // Create temp dir with test files
6736        let temp_dir =
6737            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6738        std::fs::create_dir_all(&temp_dir).unwrap();
6739        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6740        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6741
6742        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6743
6744        let registry = make_test_registry();
6745        let serve_dir = ServeDir::new(&canonical_dir)
6746            .precompressed_gzip()
6747            .precompressed_br()
6748            .append_index_html_on_directories(true);
6749
6750        let mount = StaticMount {
6751            mount_path: "/".to_string(),
6752            mode: MountMode::Static,
6753            dir: canonical_dir.clone(),
6754            cache_control: "public, max-age=3600".to_string(),
6755            error_pages: std::collections::HashMap::new(),
6756            serve_dir,
6757        };
6758        registry.register_static_mount(mount).await.unwrap();
6759
6760        let state = make_test_state(registry);
6761
6762        // Test serving hello.txt
6763        let req = Request::builder()
6764            .uri("/hello.txt")
6765            .body(AxumBody::empty())
6766            .unwrap();
6767        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6768        assert_eq!(resp.status(), StatusCode::OK);
6769        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6770            .await
6771            .unwrap();
6772        assert_eq!(&body[..], b"Hello, static world!");
6773
6774        // Test serving style.css
6775        let req = Request::builder()
6776            .uri("/style.css")
6777            .body(AxumBody::empty())
6778            .unwrap();
6779        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6780        assert_eq!(resp.status(), StatusCode::OK);
6781        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6782            .await
6783            .unwrap();
6784        assert_eq!(&body[..], b"body { color: red; }");
6785
6786        // Test 404 for non-existent file
6787        let req = Request::builder()
6788            .uri("/missing.txt")
6789            .body(AxumBody::empty())
6790            .unwrap();
6791        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6792        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6793
6794        // Cleanup
6795        std::fs::remove_dir_all(&temp_dir).ok();
6796    }
6797
6798    #[allow(clippy::await_holding_lock)]
6799    #[tokio::test]
6800    async fn test_spa_fallback_serves_index_for_unknown_paths() {
6801        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6802        ServerRegistry::reset();
6803
6804        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6805        std::fs::create_dir_all(&temp_dir).unwrap();
6806        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6807        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6808
6809        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6810
6811        let registry = make_test_registry();
6812        let serve_dir = ServeDir::new(&canonical_dir)
6813            .precompressed_gzip()
6814            .precompressed_br()
6815            .append_index_html_on_directories(true);
6816
6817        let mount = StaticMount {
6818            mount_path: "/".to_string(),
6819            mode: MountMode::Spa,
6820            dir: canonical_dir.clone(),
6821            cache_control: "public, max-age=0".to_string(),
6822            error_pages: std::collections::HashMap::new(),
6823            serve_dir,
6824        };
6825        // Register as SPA mount
6826        registry.register_static_mount(mount).await.unwrap();
6827
6828        let state = make_test_state(registry);
6829
6830        // SPA fallback: GET /dashboard with Accept: text/html → index.html
6831        let req = Request::builder()
6832            .method("GET")
6833            .uri("/dashboard")
6834            .header("Accept", "text/html")
6835            .body(AxumBody::empty())
6836            .unwrap();
6837        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6838        assert_eq!(resp.status(), StatusCode::OK);
6839        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6840            .await
6841            .unwrap();
6842        assert_eq!(&body[..], b"<h1>SPA App</h1>");
6843
6844        // Static file still works: GET /app.js
6845        let req = Request::builder()
6846            .method("GET")
6847            .uri("/app.js")
6848            .body(AxumBody::empty())
6849            .unwrap();
6850        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6851        assert_eq!(resp.status(), StatusCode::OK);
6852        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6853            .await
6854            .unwrap();
6855        assert_eq!(&body[..], b"console.log('app')");
6856
6857        // No SPA fallback for JSON accept → 404
6858        let req = Request::builder()
6859            .method("GET")
6860            .uri("/api/data")
6861            .header("Accept", "application/json")
6862            .body(AxumBody::empty())
6863            .unwrap();
6864        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6865        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6866
6867        // No SPA fallback for file extensions → 404
6868        let req = Request::builder()
6869            .method("GET")
6870            .uri("/style.css")
6871            .header("Accept", "text/html")
6872            .body(AxumBody::empty())
6873            .unwrap();
6874        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6875        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6876
6877        // Cleanup
6878        std::fs::remove_dir_all(&temp_dir).ok();
6879    }
6880
6881    // Regression for rc-zoai: a conditional GET (If-None-Match / If-Modified-Since)
6882    // whose validator matches MUST return 304 Not Modified, not 404. The bug was
6883    // dispatch_static's L92 gate `if resp.status().is_success()` discarding
6884    // ServeDir's legitimate 304 and falling through to the generic 404. The fix
6885    // adds `|| resp.status() == StatusCode::NOT_MODIFIED` to that gate (and the
6886    // matching gate in serve_via_serve_dir so the 304 keeps its Cache-Control).
6887    #[allow(clippy::await_holding_lock)]
6888    async fn run_conditional_get_returns_304(mode: MountMode) {
6889        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6890        ServerRegistry::reset();
6891
6892        let temp_dir = std::env::temp_dir().join(format!(
6893            "http_cond_get_{}_{}",
6894            if mode == MountMode::Spa {
6895                "spa"
6896            } else {
6897                "static"
6898            },
6899            std::process::id()
6900        ));
6901        std::fs::create_dir_all(&temp_dir).unwrap();
6902        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6903
6904        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6905
6906        let registry = make_test_registry();
6907        let serve_dir = ServeDir::new(&canonical_dir)
6908            .precompressed_gzip()
6909            .precompressed_br()
6910            .append_index_html_on_directories(true);
6911
6912        let mount = StaticMount {
6913            mount_path: "/".to_string(),
6914            mode,
6915            dir: canonical_dir.clone(),
6916            cache_control: "public, max-age=3600".to_string(),
6917            error_pages: std::collections::HashMap::new(),
6918            serve_dir,
6919        };
6920        registry.register_static_mount(mount).await.unwrap();
6921
6922        let state = make_test_state(registry);
6923
6924        // 1st request: normal GET → 200, capture validators.
6925        let req = Request::builder()
6926            .method("GET")
6927            .uri("/index.html")
6928            .body(AxumBody::empty())
6929            .unwrap();
6930        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6931        assert_eq!(
6932            resp.status(),
6933            StatusCode::OK,
6934            "first GET should return 200, got {}",
6935            resp.status()
6936        );
6937        // Cache-Control must be attached on 200 (sanity for serve_via_serve_dir).
6938        assert!(
6939            resp.headers().contains_key(http::header::CACHE_CONTROL),
6940            "200 response missing Cache-Control"
6941        );
6942        let etag = resp
6943            .headers()
6944            .get(http::header::ETAG)
6945            .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
6946            .clone();
6947        let last_modified = resp
6948            .headers()
6949            .get(http::header::LAST_MODIFIED)
6950            .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
6951            .clone();
6952        // Consume the body so the response is fully drained.
6953        let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
6954            .await
6955            .unwrap();
6956
6957        // 2nd request: If-None-Match with the captured ETag → 304.
6958        // Unconditional: ETag presence is required (asserted above) so this
6959        // sub-test cannot silently skip on a ServeDir etag_method change.
6960        let req = Request::builder()
6961            .method("GET")
6962            .uri("/index.html")
6963            .header(http::header::IF_NONE_MATCH, etag.clone())
6964            .body(AxumBody::empty())
6965            .unwrap();
6966        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6967        assert_eq!(
6968            resp.status(),
6969            StatusCode::NOT_MODIFIED,
6970            "If-None-Match with matching ETag should return 304, got {}",
6971            resp.status()
6972        );
6973        // RFC 7232 §4.1: 304 SHOULD include Cache-Control (the serve_via_serve_dir fix).
6974        assert!(
6975            resp.headers().contains_key(http::header::CACHE_CONTROL),
6976            "304 (If-None-Match) missing Cache-Control"
6977        );
6978        // RFC 7232 §4.1: 304 SHOULD carry the validators forward. Assert the
6979        // response parts rebuild in serve_via_serve_dir preserves them.
6980        assert_eq!(
6981            resp.headers().get(http::header::ETAG),
6982            Some(&etag),
6983            "304 (If-None-Match) must echo the ETag validator"
6984        );
6985        assert_eq!(
6986            resp.headers().get(http::header::LAST_MODIFIED),
6987            Some(&last_modified),
6988            "304 (If-None-Match) must carry Last-Modified"
6989        );
6990
6991        // 3rd request: If-Modified-Since with the captured Last-Modified → 304.
6992        let req = Request::builder()
6993            .method("GET")
6994            .uri("/index.html")
6995            .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
6996            .body(AxumBody::empty())
6997            .unwrap();
6998        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6999        assert_eq!(
7000            resp.status(),
7001            StatusCode::NOT_MODIFIED,
7002            "If-Modified-Since with matching timestamp should return 304, got {}",
7003            resp.status()
7004        );
7005        assert!(
7006            resp.headers().contains_key(http::header::CACHE_CONTROL),
7007            "304 (If-Modified-Since) missing Cache-Control"
7008        );
7009        assert_eq!(
7010            resp.headers().get(http::header::ETAG),
7011            Some(&etag),
7012            "304 (If-Modified-Since) must carry the ETag validator"
7013        );
7014        assert_eq!(
7015            resp.headers().get(http::header::LAST_MODIFIED),
7016            Some(&last_modified),
7017            "304 (If-Modified-Since) must echo Last-Modified"
7018        );
7019
7020        // Negative control: a PAST If-Modified-Since (before the file's mtime)
7021        // MUST return 200 — proving the 304 path is validator-aware, not a
7022        // blanket "always 304" regression. A future date would correctly yield
7023        // 304 since the file's mtime precedes it; that is RFC-correct 304
7024        // behaviour, not a negative control.
7025        let req = Request::builder()
7026            .method("GET")
7027            .uri("/index.html")
7028            .header(
7029                http::header::IF_MODIFIED_SINCE,
7030                "Wed, 21 Oct 2000 07:28:00 GMT",
7031            )
7032            .body(AxumBody::empty())
7033            .unwrap();
7034        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7035        assert_eq!(
7036            resp.status(),
7037            StatusCode::OK,
7038            "past If-Modified-Since should return 200 (file modified after it), got {}",
7039            resp.status()
7040        );
7041
7042        // Cleanup
7043        std::fs::remove_dir_all(&temp_dir).ok();
7044    }
7045
7046    #[tokio::test]
7047    async fn test_conditional_get_returns_304_static_mode() {
7048        run_conditional_get_returns_304(MountMode::Static).await;
7049    }
7050
7051    #[tokio::test]
7052    async fn test_conditional_get_returns_304_spa_mode() {
7053        run_conditional_get_returns_304(MountMode::Spa).await;
7054    }
7055
7056    #[allow(clippy::await_holding_lock)]
7057    #[tokio::test]
7058    async fn test_error_page_mapping_serves_custom_404() {
7059        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7060        ServerRegistry::reset();
7061
7062        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
7063        let errors_dir = temp_dir.join("errors");
7064        std::fs::create_dir_all(&errors_dir).unwrap();
7065        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7066        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
7067
7068        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7069        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
7070
7071        let registry = make_test_registry();
7072        let serve_dir = ServeDir::new(&canonical_dir)
7073            .precompressed_gzip()
7074            .precompressed_br()
7075            .append_index_html_on_directories(true);
7076
7077        let mut error_pages = std::collections::HashMap::new();
7078        error_pages.insert(404, canonical_404);
7079
7080        let mount = StaticMount {
7081            mount_path: "/".to_string(),
7082            mode: MountMode::Static,
7083            dir: canonical_dir.clone(),
7084            cache_control: "public, max-age=0".to_string(),
7085            error_pages,
7086            serve_dir,
7087        };
7088        registry.register_static_mount(mount).await.unwrap();
7089
7090        let state = make_test_state(registry);
7091
7092        // Request non-existent file → custom 404 page
7093        let req = Request::builder()
7094            .method("GET")
7095            .uri("/missing.html")
7096            .body(AxumBody::empty())
7097            .unwrap();
7098        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
7099        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7100        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7101            .await
7102            .unwrap();
7103        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
7104
7105        // Existing file still works
7106        let req = Request::builder()
7107            .method("GET")
7108            .uri("/index.html")
7109            .body(AxumBody::empty())
7110            .unwrap();
7111        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7112        assert_eq!(resp.status(), StatusCode::OK);
7113        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7114            .await
7115            .unwrap();
7116        assert_eq!(&body[..], b"<h1>Home</h1>");
7117
7118        // Cleanup
7119        std::fs::remove_dir_all(&temp_dir).ok();
7120    }
7121
7122    #[tokio::test]
7123    async fn http_consumer_returns_body_and_code_on_stop() {
7124        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
7125        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7126        use tower::ServiceExt;
7127
7128        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
7129        let set_body_step = CompiledStep::Process {
7130            kind_hint: camel_api::SpanKindHint::Internal,
7131            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7132                ex.input.body = Body::Text("nope".into());
7133                Box::pin(async move { Ok(ex) })
7134            }),
7135            body_contract: None,
7136            lifecycle: None,
7137            label: None,
7138        };
7139        let set_status_step = CompiledStep::Process {
7140            kind_hint: camel_api::SpanKindHint::Internal,
7141            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7142                ex.input.set_header(
7143                    "CamelHttpResponseCode",
7144                    serde_json::Value::Number(409.into()),
7145                );
7146                Box::pin(async move { Ok(ex) })
7147            }),
7148            body_contract: None,
7149            lifecycle: None,
7150            label: None,
7151        };
7152        let pipeline = compose_pipeline_with_handler(
7153            vec![set_body_step, set_status_step, CompiledStep::Stop],
7154            None,
7155            PipelineRuntimeCtx::compile_time(),
7156        );
7157
7158        let ex = Exchange::new(Message::default());
7159        let result = pipeline.oneshot(ex).await;
7160        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7161        let returned = result.unwrap();
7162        assert_eq!(returned.input.body.as_text(), Some("nope"));
7163        assert_eq!(
7164            returned
7165                .input
7166                .header("CamelHttpResponseCode")
7167                .and_then(|v| v.as_u64()),
7168            Some(409)
7169        );
7170    }
7171
7172    #[tokio::test]
7173    async fn http_consumer_returns_200_when_body_empty_on_stop() {
7174        // After ADR-0024: Stop with no body + no status header produces 200 (same as
7175        // a normal completion with no body). The 204 default is gone — users who
7176        // want 204 set CamelHttpResponseCode=204 explicitly.
7177        //
7178        // This test stays at the pipeline level (consistent with the test above).
7179        // E2E coverage of the full HTTP dispatch path is in
7180        // crates/camel-test/tests/integration_test.rs.
7181        use camel_api::{Exchange, Message};
7182        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7183        use tower::ServiceExt;
7184
7185        let pipeline = compose_pipeline_with_handler(
7186            vec![CompiledStep::Stop],
7187            None,
7188            PipelineRuntimeCtx::compile_time(),
7189        );
7190        let ex = Exchange::new(Message::default());
7191        let result = pipeline.oneshot(ex).await;
7192        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7193        // Body is default (empty); no CamelHttpResponseCode header was set.
7194        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
7195    }
7196
7197    // -----------------------------------------------------------------------
7198    // Task 5: Method-aware REST dispatch tests
7199    // -----------------------------------------------------------------------
7200
7201    /// Spins up an axum server on a free port with a fresh registry.
7202    /// Returns the port plus the registry so the caller can register
7203    /// REST endpoints directly.
7204    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7205        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7206        let port = listener.local_addr().unwrap().port();
7207        let registry = HttpRouteRegistry::new();
7208        tokio::spawn(run_axum_server(
7209            listener,
7210            registry.clone(),
7211            2 * 1024 * 1024,
7212            10 * 1024 * 1024,
7213            Arc::new(tokio::sync::Semaphore::new(1024)),
7214            test_rt(),
7215            "test-route".into(),
7216        ));
7217        // Give the server a moment to start accepting.
7218        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7219        (port, registry)
7220    }
7221
7222    /// Helper for REST integration tests: spawns a responder task that
7223    /// reads from `rx`, writes a fixed `(status, body)` back via the
7224    /// envelope's reply channel, and returns once the test request is
7225    /// satisfied.
7226    fn spawn_responder(
7227        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7228        status: u16,
7229        body: String,
7230    ) -> tokio::task::JoinHandle<()> {
7231        tokio::spawn(async move {
7232            if let Some(envelope) = rx.recv().await {
7233                let _ = envelope.reply_tx.send(HttpReply {
7234                    status,
7235                    headers: vec![],
7236                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7237                });
7238            }
7239        })
7240    }
7241
7242    #[tokio::test]
7243    async fn method_aware_dispatch_same_path_different_verbs() {
7244        let (port, registry) = spawn_test_server().await;
7245
7246        // Register two REST endpoints on the same path with different
7247        // methods. This is the core scenario REST DSL needs to support:
7248        // GET /users (list) and POST /users (create) must not overwrite
7249        // each other.
7250        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7251        registry
7252            .register_rest_endpoint(
7253                "GET".into(),
7254                vec![PathSegment::Literal("users".into())],
7255                get_tx,
7256            )
7257            .await;
7258
7259        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7260        registry
7261            .register_rest_endpoint(
7262                "POST".into(),
7263                vec![PathSegment::Literal("users".into())],
7264                post_tx,
7265            )
7266            .await;
7267
7268        let get_handle = spawn_responder(get_rx, 200, "list".into());
7269        let post_handle = spawn_responder(post_rx, 201, "create".into());
7270
7271        let client = reqwest::Client::new();
7272
7273        // GET /users → list route
7274        let resp = client
7275            .get(format!("http://127.0.0.1:{port}/users"))
7276            .send()
7277            .await
7278            .unwrap();
7279        assert_eq!(resp.status().as_u16(), 200);
7280        let body = resp.text().await.unwrap();
7281        assert_eq!(body, "list");
7282
7283        // POST /users → create route
7284        let resp = client
7285            .post(format!("http://127.0.0.1:{port}/users"))
7286            .send()
7287            .await
7288            .unwrap();
7289        assert_eq!(resp.status().as_u16(), 201);
7290        let body = resp.text().await.unwrap();
7291        assert_eq!(body, "create");
7292
7293        let _ = tokio::join!(get_handle, post_handle);
7294    }
7295
7296    #[tokio::test]
7297    async fn method_aware_dispatch_templated_path_extracts_params() {
7298        let (port, registry) = spawn_test_server().await;
7299
7300        // Register GET /users/{id} as a templated endpoint. The
7301        // dispatcher should match `/users/42` against the template and
7302        // attach `id=42` to the envelope's path_params.
7303        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7304        registry
7305            .register_rest_endpoint(
7306                "GET".into(),
7307                vec![
7308                    PathSegment::Literal("users".into()),
7309                    PathSegment::Param("id".into()),
7310                ],
7311                tx,
7312            )
7313            .await;
7314
7315        // Spawn a responder that echoes the captured id back in the body
7316        // so the test can verify the param was set.
7317        let handle = tokio::spawn(async move {
7318            if let Some(envelope) = rx.recv().await {
7319                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7320                let _ = envelope.reply_tx.send(HttpReply {
7321                    status: 200,
7322                    headers: vec![],
7323                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7324                });
7325            }
7326        });
7327
7328        let client = reqwest::Client::new();
7329        let resp = client
7330            .get(format!("http://127.0.0.1:{port}/users/42"))
7331            .send()
7332            .await
7333            .unwrap();
7334        assert_eq!(resp.status().as_u16(), 200);
7335        let body = resp.text().await.unwrap();
7336        assert_eq!(body, "id=42");
7337
7338        let _ = handle.await;
7339    }
7340
7341    #[tokio::test]
7342    async fn method_aware_dispatch_unmatched_method_falls_through() {
7343        // If no REST endpoint matches the method, dispatch must fall
7344        // through to the legacy api_routes lookup or static mounts. With
7345        // nothing else registered, the request gets 404 from static
7346        // dispatch.
7347        let (port, _registry) = spawn_test_server().await;
7348
7349        // Register only GET /users; a DELETE /users request has no match.
7350        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7351        _registry
7352            .register_rest_endpoint(
7353                "GET".into(),
7354                vec![PathSegment::Literal("users".into())],
7355                get_tx,
7356            )
7357            .await;
7358
7359        // Drain the GET channel in the background so the consumer side
7360        // doesn't block (we don't expect any envelopes here).
7361        let drain = tokio::spawn(async move {
7362            let mut get_rx = get_rx;
7363            while get_rx.recv().await.is_some() {}
7364        });
7365
7366        let client = reqwest::Client::new();
7367        let resp = client
7368            .delete(format!("http://127.0.0.1:{port}/users"))
7369            .send()
7370            .await
7371            .unwrap();
7372        assert_eq!(resp.status().as_u16(), 404);
7373
7374        drop(drain);
7375    }
7376
7377    #[tokio::test]
7378    async fn regression_legacy_exact_api_route_still_works() {
7379        // A `http:` route registered without an `httpMethod=` URI param
7380        // lands in the legacy api_routes registry. The dispatcher must
7381        // still find it via exact path lookup. This guards against
7382        // regressions introduced by the new REST-aware dispatch.
7383        let (port, registry) = spawn_test_server().await;
7384
7385        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7386        registry.register_api_route("/legacy/path".into(), tx).await;
7387
7388        let handle = tokio::spawn(async move {
7389            if let Some(envelope) = rx.recv().await {
7390                let _ = envelope.reply_tx.send(HttpReply {
7391                    status: 200,
7392                    headers: vec![],
7393                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7394                });
7395            }
7396        });
7397
7398        let client = reqwest::Client::new();
7399        let resp = client
7400            .get(format!("http://127.0.0.1:{port}/legacy/path"))
7401            .send()
7402            .await
7403            .unwrap();
7404        assert_eq!(resp.status().as_u16(), 200);
7405        let body = resp.text().await.unwrap();
7406        assert_eq!(body, "legacy ok");
7407
7408        let _ = handle.await;
7409    }
7410
7411    #[allow(clippy::await_holding_lock)]
7412    #[tokio::test]
7413    async fn regression_static_mount_still_works() {
7414        // Verify that static file serving still works after the
7415        // dispatch refactor. We register a temp-dir mount and request
7416        // a file from it; the static dispatcher should serve it.
7417        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7418        ServerRegistry::reset();
7419
7420        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7421        std::fs::create_dir_all(&temp_dir).unwrap();
7422        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7423        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7424
7425        let registry = make_test_registry();
7426        let serve_dir = ServeDir::new(&canonical_dir)
7427            .precompressed_gzip()
7428            .precompressed_br()
7429            .append_index_html_on_directories(true);
7430        let mount = StaticMount {
7431            mount_path: "/".to_string(),
7432            mode: MountMode::Static,
7433            dir: canonical_dir.clone(),
7434            cache_control: "public, max-age=3600".to_string(),
7435            error_pages: std::collections::HashMap::new(),
7436            serve_dir,
7437        };
7438        registry.register_static_mount(mount).await.unwrap();
7439
7440        let state = make_test_state(registry);
7441        let req = Request::builder()
7442            .uri("/regress.txt")
7443            .body(AxumBody::empty())
7444            .unwrap();
7445        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7446        assert_eq!(resp.status(), StatusCode::OK);
7447        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7448            .await
7449            .unwrap();
7450        assert_eq!(&body[..], b"static works");
7451
7452        std::fs::remove_dir_all(&temp_dir).ok();
7453    }
7454
7455    // -----------------------------------------------------------------------
7456    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
7457    // templated from-URI round-trip. These exercise the real axum dispatch
7458    // path (register → HTTP request → reply) so a regression in any of the
7459    // three critical fixes surfaces as a test failure rather than a silent
7460    // production 404/500.
7461    // -----------------------------------------------------------------------
7462
7463    #[tokio::test]
7464    async fn deregister_one_method_keeps_sibling_verbs() {
7465        // Review C1: stopping the GET /users consumer must NOT tear down the
7466        // live POST /users endpoint. Register both, deregister GET only,
7467        // then verify POST still dispatches.
7468        let (port, registry) = spawn_test_server().await;
7469
7470        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7471        registry
7472            .register_rest_endpoint(
7473                "GET".into(),
7474                vec![PathSegment::Literal("users".into())],
7475                get_tx,
7476            )
7477            .await;
7478
7479        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7480        registry
7481            .register_rest_endpoint(
7482                "POST".into(),
7483                vec![PathSegment::Literal("users".into())],
7484                post_tx,
7485            )
7486            .await;
7487
7488        // Drain GET in the background (no requests expected after deregister).
7489        let drain = tokio::spawn(async move {
7490            let mut get_rx = get_rx;
7491            while get_rx.recv().await.is_some() {}
7492        });
7493
7494        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
7495        registry.unregister_rest_endpoint("GET", "/users").await;
7496        drop(drain);
7497
7498        let post_handle = spawn_responder(post_rx, 201, "create".into());
7499
7500        let client = reqwest::Client::new();
7501        // POST /users must still reach its consumer after GET was removed.
7502        let resp = client
7503            .post(format!("http://127.0.0.1:{port}/users"))
7504            .send()
7505            .await
7506            .unwrap();
7507        assert_eq!(resp.status().as_u16(), 201);
7508        assert_eq!(resp.text().await.unwrap(), "create");
7509
7510        let _ = post_handle.await;
7511    }
7512
7513    #[tokio::test]
7514    async fn dispatch_exact_legacy_beats_rest_template() {
7515        // Review C2: an exact legacy API route (`GET /api/users`, no
7516        // httpMethod) must win over a templated REST route
7517        // (`GET /api/{resource}`) for the request `/api/users`, per spec
7518        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
7519        let (port, registry) = spawn_test_server().await;
7520
7521        // Exact legacy route.
7522        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7523        registry
7524            .register_api_route("/api/users".into(), exact_tx)
7525            .await;
7526        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7527
7528        // Templated REST route that would ALSO match /api/users.
7529        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7530        registry
7531            .register_rest_endpoint(
7532                "GET".into(),
7533                vec![
7534                    PathSegment::Literal("api".into()),
7535                    PathSegment::Param("resource".into()),
7536                ],
7537                tpl_tx,
7538            )
7539            .await;
7540        // The templated handler must NOT receive the /api/users request. If
7541        // it does, it replies "template-leak" so a future assertion could
7542        // catch it. We do NOT await this task: the exact-match branch wins
7543        // and the templated channel never receives, so awaiting would block
7544        // until the test runtime tears down.
7545        let _tpl_drain = tokio::spawn(async move {
7546            let mut tpl_rx = tpl_rx;
7547            if let Some(env) = tpl_rx.recv().await {
7548                let _ = env.reply_tx.send(HttpReply {
7549                    status: 200,
7550                    headers: vec![],
7551                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7552                });
7553            }
7554        });
7555
7556        let client = reqwest::Client::new();
7557        let resp = client
7558            .get(format!("http://127.0.0.1:{port}/api/users"))
7559            .send()
7560            .await
7561            .unwrap();
7562        assert_eq!(resp.status().as_u16(), 200);
7563        // Exact-match handler answered — not the templated one.
7564        assert_eq!(resp.text().await.unwrap(), "exact");
7565
7566        let _ = exact_handle.await;
7567    }
7568
7569    #[tokio::test]
7570    async fn ambiguous_rest_templates_return_500_not_silent_404() {
7571        // Review C3: two equal-specificity templates that both match one
7572        // request are an ambiguous registration. At runtime this must
7573        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
7574        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
7575        let (port, registry) = spawn_test_server().await;
7576
7577        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7578        registry
7579            .register_rest_endpoint(
7580                "GET".into(),
7581                vec![
7582                    PathSegment::Literal("users".into()),
7583                    PathSegment::Param("id".into()),
7584                ],
7585                a_tx,
7586            )
7587            .await;
7588
7589        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7590        registry
7591            .register_rest_endpoint(
7592                "GET".into(),
7593                vec![
7594                    PathSegment::Literal("users".into()),
7595                    PathSegment::Param("name".into()),
7596                ],
7597                b_tx,
7598            )
7599            .await;
7600
7601        let client = reqwest::Client::new();
7602        let resp = client
7603            .get(format!("http://127.0.0.1:{port}/users/42"))
7604            .send()
7605            .await
7606            .unwrap();
7607        // Ambiguous → 500 (previously a silent 404).
7608        assert_eq!(resp.status().as_u16(), 500);
7609    }
7610
7611    #[test]
7612    fn from_uri_round_trips_templated_path_with_http_method() {
7613        // Review I4: a REST-lowered from-URI like
7614        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
7615        // through HttpServerConfig::from_uri, preserving the templated path
7616        // and the (uppercased) method. This is the binding the DSL lowering
7617        // emits and the consumer reads; it was previously unasserted.
7618        use crate::UriConfig;
7619        let cfg =
7620            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7621        assert_eq!(cfg.host, "0.0.0.0");
7622        assert_eq!(cfg.port, 8080);
7623        assert_eq!(cfg.path, "/users/{id}");
7624        assert_eq!(cfg.method.as_deref(), Some("GET"));
7625
7626        // Lower-case httpMethod is uppercased (review I5).
7627        let cfg_lc =
7628            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7629        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7630        assert_eq!(cfg_lc.path, "/orders");
7631    }
7632
7633    // -----------------------------------------------------------------------
7634    // rc-1dk4: TypeConversionFailed → 400 Bad Request
7635    // -----------------------------------------------------------------------
7636
7637    #[test]
7638    fn type_conversion_failed_maps_to_400() {
7639        let reply = pipeline_error_to_reply(
7640            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7641            "/api/users",
7642        );
7643        assert_eq!(reply.status, 400);
7644        // Content-Type must be application/json
7645        let ct = reply
7646            .headers
7647            .iter()
7648            .find(|(k, _)| k == "Content-Type")
7649            .map(|(_, v)| v.as_str());
7650        assert_eq!(ct, Some("application/json"));
7651        // Body must contain structured error JSON
7652        let body = match &reply.body {
7653            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7654            _ => panic!("expected bytes body"),
7655        };
7656        assert!(body.contains("\"error\""));
7657        assert!(body.contains("bad_request"));
7658        assert!(body.contains("invalid JSON at line 1"));
7659    }
7660
7661    #[test]
7662    fn other_error_still_maps_to_500() {
7663        let reply =
7664            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7665        assert_eq!(reply.status, 500);
7666    }
7667
7668    #[test]
7669    fn unauthenticated_maps_to_401() {
7670        let reply = pipeline_error_to_reply(
7671            CamelError::Unauthenticated("no token".to_string()),
7672            "/api/users",
7673        );
7674        assert_eq!(reply.status, 401);
7675    }
7676
7677    #[test]
7678    fn unauthorized_maps_to_403() {
7679        let reply = pipeline_error_to_reply(
7680            CamelError::Unauthorized("forbidden".to_string()),
7681            "/api/users",
7682        );
7683        assert_eq!(reply.status, 403);
7684    }
7685
7686    #[test]
7687    fn validation_error_maps_to_400() {
7688        let reply = pipeline_error_to_reply(
7689            CamelError::ValidationError("body does not match schema".to_string()),
7690            "/api/users",
7691        );
7692        assert_eq!(reply.status, 400);
7693        let ct = reply
7694            .headers
7695            .iter()
7696            .find(|(k, _)| k == "Content-Type")
7697            .map(|(_, v)| v.as_str());
7698        assert_eq!(ct, Some("application/json"));
7699        let body = match &reply.body {
7700            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7701            _ => panic!("expected bytes body"),
7702        };
7703        assert!(body.contains("\"error\""));
7704        assert!(body.contains("validation_error"));
7705        assert!(body.contains("body does not match schema"));
7706    }
7707
7708    #[test]
7709    fn https_consumer_without_tls_cert_errors() {
7710        let endpoint = HttpEndpoint {
7711            uri: "https://0.0.0.0:8443/api".to_string(),
7712            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7713            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7714            client: reqwest::Client::new(),
7715            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7716                PINNED_CLIENT_TTL,
7717                PINNED_CLIENT_MAX_ENTRIES,
7718            )),
7719            http_config: HttpConfig::default(),
7720        };
7721        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7722        let result = endpoint.create_consumer(rt);
7723        assert!(result.is_err(), "expected error for https without tls cert");
7724        if let Err(e) = result {
7725            let msg = e.to_string();
7726            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7727        }
7728    }
7729
7730    #[test]
7731    fn http_consumer_with_tls_config_errors() {
7732        let endpoint = HttpEndpoint {
7733            uri: "http://0.0.0.0:8080/api".to_string(),
7734            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7735            server_config: HttpServerConfig::from_uri(
7736                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7737            )
7738            .unwrap(),
7739            client: reqwest::Client::new(),
7740            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7741                PINNED_CLIENT_TTL,
7742                PINNED_CLIENT_MAX_ENTRIES,
7743            )),
7744            http_config: HttpConfig::default(),
7745        };
7746        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7747        let result = endpoint.create_consumer(rt);
7748        assert!(result.is_err(), "expected error for http with tls config");
7749        if let Err(e) = result {
7750            let msg = e.to_string();
7751            assert!(msg.contains("https"), "error must mention https: {msg}");
7752        }
7753    }
7754
7755    #[test]
7756    fn https_consumer_with_partial_tls_cert_only_errors() {
7757        // tlsCert without tlsKey → tls_config is None at parse time
7758        // → create_consumer sees https:// + no TLS → must error
7759        let server_config =
7760            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7761        assert!(
7762            server_config.tls_config.is_none(),
7763            "partial tlsCert must not create ServerTlsConfig"
7764        );
7765        let endpoint = HttpEndpoint {
7766            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7767            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7768                .unwrap(),
7769            server_config,
7770            client: reqwest::Client::new(),
7771            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7772                PINNED_CLIENT_TTL,
7773                PINNED_CLIENT_MAX_ENTRIES,
7774            )),
7775            http_config: HttpConfig::default(),
7776        };
7777        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7778        let result = endpoint.create_consumer(rt);
7779        assert!(
7780            result.is_err(),
7781            "must error: https:// requires both tlsCert and tlsKey"
7782        );
7783    }
7784
7785    #[test]
7786    fn load_tls_config_parses_valid_pem() {
7787        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
7788        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7789        use camel_component_api::test_support::tls;
7790        let (_, cert_pem, key_pem) = tls::gen_server_cert();
7791        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7792        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7793
7794        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7795        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7796    }
7797
7798    #[tokio::test(flavor = "multi_thread")]
7799    #[allow(clippy::await_holding_lock)]
7800    async fn consumer_tls_handshake_roundtrip() {
7801        use camel_component_api::test_support::tls;
7802        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7803
7804        // Install rustls crypto provider (aws-lc-rs)
7805        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7806
7807        // Serialize against global ServerRegistry singleton
7808        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7809
7810        // Generate CA + server cert
7811        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7812        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7813        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7814        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7815
7816        // Get ephemeral port
7817        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7818        let port = probe.local_addr().unwrap().port();
7819        drop(probe);
7820
7821        ServerRegistry::reset();
7822
7823        // Create real HttpComponent + endpoint with TLS URI
7824        let component = HttpComponent::new();
7825        let endpoint_ctx = NoOpComponentContext;
7826        let uri = format!(
7827            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7828            cert_path.to_string_lossy(),
7829            key_path.to_string_lossy(),
7830        );
7831        let endpoint = component
7832            .create_endpoint(&uri, &endpoint_ctx)
7833            .expect("create TLS endpoint");
7834        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7835
7836        // Start consumer — this calls get_or_spawn with tls_config
7837        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7838        let token = tokio_util::sync::CancellationToken::new();
7839        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7840        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7841
7842        // Give server time to start
7843        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7844
7845        // Client with CA cert — REAL verification (no danger_accept_invalid)
7846        let ca_bytes = std::fs::read(&ca_path).unwrap();
7847        let client = reqwest::Client::builder()
7848            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7849            .build()
7850            .unwrap();
7851
7852        let send_fut = client
7853            .post(format!("https://localhost:{port}/test"))
7854            .body("ping")
7855            .send();
7856
7857        // Handler: receive envelope, reply 200 with "pong" body
7858        let (http_result, _) = tokio::join!(send_fut, async {
7859            if let Some(mut envelope) = rx.recv().await {
7860                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7861                if let Some(reply_tx) = envelope.reply_tx {
7862                    let _ = reply_tx.send(Ok(envelope.exchange));
7863                }
7864            }
7865        });
7866
7867        let resp = http_result.expect("TLS handshake + request must succeed");
7868
7869        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7870        let body = resp.text().await.unwrap();
7871        assert_eq!(body, "pong");
7872
7873        token.cancel();
7874    }
7875
7876    #[tokio::test(flavor = "multi_thread")]
7877    #[allow(clippy::await_holding_lock)]
7878    async fn consumer_tls_rejects_client_without_ca() {
7879        use camel_component_api::test_support::tls;
7880        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7881
7882        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7883
7884        // Serialize against global ServerRegistry singleton
7885        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7886
7887        let (_, cert_pem, key_pem) = tls::gen_server_cert();
7888        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7889        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7890
7891        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7892        let port = probe.local_addr().unwrap().port();
7893        drop(probe);
7894
7895        ServerRegistry::reset();
7896
7897        // Spawn TLS server via real HttpComponent path
7898        let component = HttpComponent::new();
7899        let endpoint_ctx = NoOpComponentContext;
7900        let uri = format!(
7901            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7902            cert_path.to_string_lossy(),
7903            key_path.to_string_lossy(),
7904        );
7905        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7906        let mut consumer = endpoint.create_consumer(rt()).unwrap();
7907        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7908        let token = tokio_util::sync::CancellationToken::new();
7909        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7910        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7911
7912        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7913
7914        // Client WITHOUT CA cert — must fail TLS verification
7915        let client = reqwest::Client::builder().build().unwrap();
7916
7917        let result = client
7918            .get(format!("https://localhost:{port}/test"))
7919            .send()
7920            .await;
7921
7922        assert!(
7923            result.is_err(),
7924            "must reject without CA — proves real verification"
7925        );
7926
7927        token.cancel();
7928    }
7929
7930    #[test]
7931    fn server_config_partial_tls_cert_without_key() {
7932        // Parse URI with only tlsCert (no tlsKey)
7933        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7934        // Partial params → tls_config must be None
7935        assert!(cfg.tls_config.is_none());
7936    }
7937
7938    #[test]
7939    fn endpoint_uri_options_count_parity() {
7940        // Mirror struct must stay in sync with bespoke from_components parser.
7941        assert_eq!(
7942            HttpEndpointConfig::uri_options().len(),
7943            20,
7944            "HttpEndpointUriConfig #[uri_param] count drifted from parser"
7945        );
7946    }
7947
7948    fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
7949        pairs
7950            .iter()
7951            .map(|(k, v)| {
7952                (
7953                    (*k).to_string(),
7954                    serde_json::Value::String((*v).to_string()),
7955                )
7956            })
7957            .collect()
7958    }
7959
7960    #[test]
7961    fn response_emits_cache_control_via_pragma_warning() {
7962        let headers = make_headers(&[
7963            ("Cache-Control", "public, max-age=3600"),
7964            ("Via", "1.1 myproxy"),
7965            ("Pragma", "no-cache"),
7966            ("Warning", "199 misc"),
7967        ]);
7968        let selected = select_response_headers(&headers, None, None);
7969        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7970        for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
7971            assert!(
7972                names.contains(&expected),
7973                "{expected} should pass through to the response"
7974            );
7975        }
7976    }
7977
7978    #[test]
7979    fn response_excludes_request_only_and_server_owned() {
7980        let headers = make_headers(&[
7981            ("User-Agent", "x"),
7982            ("Accept", "*/*"),
7983            ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
7984        ]);
7985        let selected = select_response_headers(&headers, None, None);
7986        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7987        for excluded in ["User-Agent", "Accept", "Date"] {
7988            assert!(
7989                !names.contains(&excluded),
7990                "{excluded} should NOT appear in the response"
7991            );
7992        }
7993    }
7994
7995    #[test]
7996    fn response_re_derives_content_type() {
7997        let headers = make_headers(&[("Content-Type", "text/plain")]);
7998        let selected = select_response_headers(&headers, Some("application/json".into()), None);
7999        let ct_entries: Vec<&str> = selected
8000            .iter()
8001            .filter(|(k, _)| k == "Content-Type")
8002            .map(|(_, v)| v.as_str())
8003            .collect();
8004        assert_eq!(
8005            ct_entries,
8006            ["application/json"],
8007            "exactly one Content-Type entry, re-derived from user_content_type"
8008        );
8009    }
8010
8011    #[test]
8012    fn response_excludes_camel_headers() {
8013        let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
8014        let selected = select_response_headers(&headers, None, None);
8015        let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8016        assert!(
8017            !names.contains(&"CamelHttpPath"),
8018            "Camel-namespace headers must be excluded"
8019        );
8020        assert!(
8021            names.contains(&"Cache-Control"),
8022            "Cache-Control must pass through"
8023        );
8024    }
8025
8026    // -----------------------------------------------------------------------
8027    // Bridge proxy end-to-end integration tests (Task 4.1)
8028    // Fully local + deterministic: raw TCP / in-process consumer / reqwest
8029    // to 127.0.0.1. No public CDN, no httpbin, no network egress.
8030    // -----------------------------------------------------------------------
8031
8032    /// Destination server that captures the outbound request line and the
8033    /// `Host:` header the producer actually sent on the wire. Returns
8034    /// `(host_value, request_line)` so a bridge-proxy test can assert that
8035    /// the producer derived `Host` from the destination (not the exchange)
8036    /// and honoured bridging semantics for the path.
8037    async fn start_host_capturing_destination() -> (
8038        String,
8039        Arc<std::sync::Mutex<Option<(String, String)>>>,
8040        tokio::task::JoinHandle<()>,
8041    ) {
8042        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8043        let port = listener.local_addr().unwrap().port();
8044        let url = format!("http://127.0.0.1:{port}");
8045        let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
8046            Arc::new(std::sync::Mutex::new(None));
8047        let captured_clone = Arc::clone(&captured);
8048        let handle = tokio::spawn(async move {
8049            use tokio::io::{AsyncReadExt, AsyncWriteExt};
8050            if let Ok((mut stream, _)) = listener.accept().await {
8051                let mut buf = vec![0u8; 16384];
8052                let n = stream.read(&mut buf).await.unwrap_or(0);
8053                let request = String::from_utf8_lossy(&buf[..n]).to_string();
8054                if request.contains("\r\n\r\n") {
8055                    let request_line = request.lines().next().unwrap_or("").to_string();
8056                    let host_value = request
8057                        .lines()
8058                        .find(|l| l.to_lowercase().starts_with("host:"))
8059                        .and_then(|l| l.split_once(':'))
8060                        .map(|(_, v)| v.trim().to_string())
8061                        .unwrap_or_default();
8062                    *captured_clone.lock().unwrap() = Some((host_value, request_line));
8063                }
8064                let body = r#"{"echo":"ok"}"#;
8065                let resp = format!(
8066                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
8067                    body.len(),
8068                    body
8069                );
8070                let _ = stream.write_all(resp.as_bytes()).await;
8071            }
8072        });
8073        (url, captured, handle)
8074    }
8075
8076    /// A bridging producer must derive `Host` from the destination URL and
8077    /// ignore the exchange `CamelHttpPath`, matching Apache Camel bridging
8078    /// semantics. The wire-level proof is the raw `Host:` header and request
8079    /// line captured at the destination TCP socket.
8080    #[tokio::test]
8081    async fn bridge_proxy_outbound_host_matches_destination() {
8082        use tower::ServiceExt;
8083
8084        let (url, captured, _handle) = start_host_capturing_destination().await;
8085        // The Host header reqwest derives for http://127.0.0.1:{port} is the
8086        // authority, scheme-stripped: "127.0.0.1:{port}".
8087        let expected_host = url.strip_prefix("http://").unwrap();
8088
8089        let ctx = test_producer_ctx();
8090        let component = HttpComponent::new();
8091        let endpoint_ctx = NoOpComponentContext;
8092        let endpoint = component
8093            .create_endpoint(
8094                &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
8095                &endpoint_ctx,
8096            )
8097            .unwrap();
8098        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8099
8100        // Exchange carries a stale Host and a CamelHttpPath that bridging
8101        // must drop.
8102        let mut exchange = Exchange::new(Message::default());
8103        exchange.input.set_header("Host", "localhost");
8104        exchange.input.set_header("CamelHttpPath", "/foo");
8105
8106        let result = producer.oneshot(exchange).await;
8107        assert!(result.is_ok(), "producer call failed: {:?}", result);
8108
8109        tokio::time::sleep(Duration::from_millis(100)).await;
8110        let (host_value, request_line) = captured
8111            .lock()
8112            .unwrap()
8113            .take()
8114            .expect("destination capture mutex empty — producer did not reach the destination");
8115
8116        assert_ne!(
8117            host_value, "localhost",
8118            "bridge producer must not forward the exchange Host: localhost"
8119        );
8120        assert_eq!(
8121            host_value, expected_host,
8122            "Host must be derived from the destination authority (no scheme)"
8123        );
8124        assert!(
8125            !request_line.contains("/foo"),
8126            "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
8127        );
8128    }
8129
8130    /// A response header set by the route (`Cache-Control`) must survive to
8131    /// the wire. The assertion is on the reqwest HTTP response — not an
8132    /// in-process HttpReply struct — so it proves the consumer's reply
8133    /// finaliser emitted the header over the socket.
8134    #[tokio::test]
8135    async fn bridge_proxy_route_set_response_header_survives() {
8136        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8137
8138        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8139        let port = listener.local_addr().unwrap().port();
8140        drop(listener);
8141
8142        let component = HttpComponent::new();
8143        let endpoint_ctx = NoOpComponentContext;
8144        let endpoint = component
8145            .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
8146            .unwrap();
8147        let mut consumer = endpoint.create_consumer(rt()).unwrap();
8148
8149        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8150        let token = tokio_util::sync::CancellationToken::new();
8151        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8152
8153        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8154        tokio::time::sleep(Duration::from_millis(50)).await;
8155
8156        let client = reqwest::Client::new();
8157        let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
8158
8159        // Route sets Cache-Control on the outbound reply (exchange.input is
8160        // the message the reply finaliser reads — see select_response_headers
8161        // at the dispatch site).
8162        let (http_result, _) = tokio::join!(send_fut, async {
8163            if let Some(mut envelope) = rx.recv().await {
8164                envelope
8165                    .exchange
8166                    .input
8167                    .set_header("Cache-Control", "public, max-age=3600");
8168                if let Some(reply_tx) = envelope.reply_tx {
8169                    let _ = reply_tx.send(Ok(envelope.exchange));
8170                }
8171            }
8172        });
8173
8174        let resp = http_result.unwrap();
8175        assert_eq!(resp.status().as_u16(), 200);
8176
8177        let cache_control = resp.headers().get("cache-control");
8178        assert!(
8179            cache_control.is_some(),
8180            "Cache-Control header must survive to the wire response"
8181        );
8182        assert_eq!(
8183            cache_control.unwrap().to_str().unwrap(),
8184            "public, max-age=3600"
8185        );
8186
8187        token.cancel();
8188    }
8189
8190    // -----------------------------------------------------------------------
8191    // credential-sources task 2.3: credential values stay out of diagnostics
8192    // -----------------------------------------------------------------------
8193    //
8194    // camel-http has no request access log (design.md "Redaction sinks",
8195    // ADR-0051). The only diagnostic sink on the failed-auth path is
8196    // `pipeline_error_to_reply`, which renders the (generic) error message and
8197    // the *configured* route path — never the request URI, query string, or
8198    // extracted credential. These tests pin that redact-by-construction
8199    // contract: a sentinel credential presented in a declared source must not
8200    // appear in the reply body nor in any tracing record emitted while the
8201    // request is handled.
8202    //
8203    // Capture scope: `#[traced_test]` installs a per-crate env filter
8204    // (`camel_component_http=trace`), so records from OTHER targets
8205    // (`camel_auth`, `camel_processor`, etc.) are NOT captured here. The
8206    // redaction contract for those crates is guarded by their own tests.
8207    // Revisit this capture scope if camel-auth ever logs on the auth path.
8208    use camel_api::security_policy::CredentialSource;
8209    use camel_auth::credential_source::extract_token_from_exchange;
8210    use camel_auth::native_auth::NativeCredentialStore;
8211    use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
8212
8213    // Sentinel credential values — test fixtures only, not real secrets.
8214    const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; // allow-secret
8215    const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; // allow-secret
8216    const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; // allow-secret
8217
8218    /// Build the exchange the consumer would build for a request envelope:
8219    /// standard Camel HTTP headers plus title-cased forwarded request headers.
8220    fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
8221        let mut msg = Message::default();
8222        msg.set_header(
8223            "CamelHttpMethod",
8224            serde_json::Value::String(envelope.method.clone()),
8225        );
8226        msg.set_header(
8227            "CamelHttpPath",
8228            serde_json::Value::String(envelope.path.clone()),
8229        );
8230        msg.set_header(
8231            "CamelHttpQuery",
8232            serde_json::Value::String(envelope.query.clone()),
8233        );
8234        for (k, v) in &envelope.headers {
8235            if let Ok(val_str) = v.to_str() {
8236                msg.set_header(
8237                    title_case_header(k.as_str()),
8238                    serde_json::Value::String(val_str.to_string()),
8239                );
8240            }
8241        }
8242        Exchange::new(msg)
8243    }
8244
8245    /// Register a route whose responder authenticates each request against an
8246    /// empty native store, so every presented credential fails lookup with
8247    /// `Unauthenticated` (401). Restores the pre-AuthContext `RolePolicy`
8248    /// authentication step (extract per `sources` → authenticate → deny) so the
8249    /// credential-extraction redaction contract is exercised on a real
8250    /// authentication failure.
8251    async fn spawn_failing_auth_route(
8252        registry: &HttpRouteRegistry,
8253        path: &str,
8254        sources: Vec<CredentialSource>,
8255    ) {
8256        let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8257            NativeCredentialStore::try_new(vec![]).unwrap(),
8258        ));
8259        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8260        registry.register_api_route(path.to_string(), tx).await;
8261        let path_owned = path.to_string();
8262        tokio::spawn(async move {
8263            while let Some(envelope) = rx.recv().await {
8264                let exchange = envelope_to_exchange(&envelope);
8265                let reply_tx = envelope.reply_tx;
8266                let result: Result<(), CamelError> = async {
8267                    let token = extract_token_from_exchange(&exchange, &sources)
8268                        .map(|extracted| extracted.token)
8269                        .ok_or_else(|| {
8270                            CamelError::Unauthenticated("no credential in any source".into())
8271                        })?;
8272                    authenticator.authenticate_bearer(&token).await?;
8273                    Ok(())
8274                }
8275                .await;
8276                let reply = match result {
8277                    Ok(()) => HttpReply {
8278                        status: 200,
8279                        headers: vec![],
8280                        body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8281                    },
8282                    Err(e) => pipeline_error_to_reply(e, &path_owned),
8283                };
8284                let _ = reply_tx.send(reply);
8285            }
8286        });
8287    }
8288
8289    /// Whether any tracing record captured so far (process-wide) contains
8290    /// `needle`. `#[traced_test]` installs a global subscriber writing to a
8291    /// shared buffer, so logs from spawned request-handling tasks are included.
8292    fn captured_logs_contain(needle: &str) -> bool {
8293        let buf = tracing_test::internal::global_buf().lock().unwrap();
8294        String::from_utf8_lossy(&buf).contains(needle)
8295    }
8296
8297    #[tracing_test::traced_test]
8298    #[tokio::test]
8299    async fn error_context_redacts_query_sentinel() {
8300        let (port, registry) = spawn_test_server().await;
8301        spawn_failing_auth_route(
8302            &registry,
8303            "/secure-query",
8304            vec![CredentialSource::QueryParam {
8305                param: "token".to_string(),
8306            }],
8307        )
8308        .await;
8309
8310        let client = reqwest::Client::new();
8311        let resp = client
8312            // allow-secret: `token` is the declared query-source param name, not a credential
8313            .get(format!(
8314                "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8315            ))
8316            .send()
8317            .await
8318            .unwrap();
8319
8320        assert_eq!(resp.status().as_u16(), 401);
8321        let body = resp.text().await.unwrap();
8322        assert_eq!(body, "Unauthorized");
8323        assert!(
8324            !body.contains(SENTINEL_QRY_42),
8325            "reply body must not contain the query credential"
8326        );
8327        assert!(
8328            !captured_logs_contain(SENTINEL_QRY_42),
8329            "no tracing record during request handling may render the query credential"
8330        );
8331        // Permanent positive control: the failed-auth warn! must be captured.
8332        // If the per-crate env filter ever stops matching, this fails loudly
8333        // instead of letting the sentinel assertions pass vacuously.
8334        assert!(
8335            captured_logs_contain("Authentication failed"),
8336            "positive control: the failed-auth warn! must be captured by the test subscriber"
8337        );
8338    }
8339
8340    #[tracing_test::traced_test]
8341    #[tokio::test]
8342    async fn error_context_redacts_cookie_sentinel() {
8343        let (port, registry) = spawn_test_server().await;
8344        spawn_failing_auth_route(
8345            &registry,
8346            "/secure-cookie",
8347            vec![CredentialSource::Cookie {
8348                name: "session".to_string(),
8349            }],
8350        )
8351        .await;
8352
8353        let client = reqwest::Client::new();
8354        let resp = client
8355            .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8356            .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8357            .send()
8358            .await
8359            .unwrap();
8360
8361        assert_eq!(resp.status().as_u16(), 401);
8362        let body = resp.text().await.unwrap();
8363        assert_eq!(body, "Unauthorized");
8364        assert!(
8365            !body.contains(SENTINEL_CKY_7),
8366            "reply body must not contain the cookie credential"
8367        );
8368        assert!(
8369            !captured_logs_contain(SENTINEL_CKY_7),
8370            "no tracing record during request handling may render the cookie credential"
8371        );
8372    }
8373
8374    #[tracing_test::traced_test]
8375    #[tokio::test]
8376    async fn error_reply_no_credential_value() {
8377        let (port, registry) = spawn_test_server().await;
8378        spawn_failing_auth_route(
8379            &registry,
8380            "/secure-bad",
8381            vec![CredentialSource::Cookie {
8382                name: "session".to_string(),
8383            }],
8384        )
8385        .await;
8386
8387        let client = reqwest::Client::new();
8388        let resp = client
8389            .get(format!("http://127.0.0.1:{port}/secure-bad"))
8390            .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8391            .send()
8392            .await
8393            .unwrap();
8394
8395        assert_eq!(resp.status().as_u16(), 401);
8396        let body = resp.text().await.unwrap();
8397        assert_eq!(body, "Unauthorized");
8398        assert!(
8399            !body.contains(SENTINEL_BAD_1),
8400            "reply body must not contain the credential value"
8401        );
8402        assert!(
8403            !captured_logs_contain(SENTINEL_BAD_1),
8404            "error logs must not render the credential value"
8405        );
8406    }
8407
8408    // -----------------------------------------------------------------------
8409    // Pinned-client-cache producer-path behavioral tests
8410    // (openspec/changes/http-pinned-client-cache — scenarios: producers share
8411    // the endpoint cache, hostname requests build one client while the entry
8412    // stays retrievable, IP-literal requests bypass the cache)
8413    // -----------------------------------------------------------------------
8414
8415    /// Local responder that accepts any number of HTTP/1.1 connections on an
8416    /// ephemeral 127.0.0.1 port and answers each with a fixed 200 response.
8417    /// Unlike [`start_host_capturing_destination`], which serves exactly one
8418    /// connection, this loop keeps accepting so cache-reuse tests can drive
8419    /// several requests through one destination. Returns
8420    /// `(base_url, JoinHandle)`.
8421    async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
8422        use tokio::io::AsyncWriteExt;
8423
8424        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8425            .await
8426            .expect("bind ephemeral 127.0.0.1 listener");
8427        let port = listener.local_addr().expect("local addr").port();
8428        let base_url = format!("http://localhost:{port}");
8429        let handle = tokio::spawn(async move {
8430            while let Ok((mut conn, _)) = listener.accept().await {
8431                let _ = conn
8432                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
8433                    .await;
8434                let _ = conn.shutdown().await;
8435            }
8436        });
8437        (base_url, handle)
8438    }
8439
8440    /// Port of a [`spawn_multi_accept_200`] base URL, for tests that must
8441    /// target a different authority (the 127.0.0.1 literal) on the same
8442    /// listener.
8443    fn responder_port(base_url: &str) -> u16 {
8444        url::Url::parse(base_url)
8445            .expect("responder base URL parses")
8446            .port()
8447            .expect("responder base URL carries an explicit port")
8448    }
8449
8450    /// Build an endpoint literal whose outbound config points at
8451    /// `base_url` (with `allowInternal=true` so the SSRF resolver permits
8452    /// loopback) and whose pinned-client cache is the caller-owned Arc, so
8453    /// build counts stay observable across producers.
8454    fn endpoint_with_shared_cache(
8455        base_url: &str,
8456        pinned_cache: &Arc<PinnedClientCache>,
8457    ) -> HttpEndpoint {
8458        let uri = format!("{base_url}?allowInternal=true");
8459        HttpEndpoint {
8460            uri: uri.clone(),
8461            config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
8462            server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
8463            client: reqwest::Client::new(),
8464            pinned_cache: Arc::clone(pinned_cache),
8465            http_config: HttpConfig::default(),
8466        }
8467    }
8468
8469    #[tokio::test]
8470    async fn producers_share_endpoint_cache() {
8471        use tower::ServiceExt;
8472
8473        let (base_url, _handle) = spawn_multi_accept_200().await;
8474        let pinned_cache = Arc::new(PinnedClientCache::new(
8475            PINNED_CLIENT_TTL,
8476            PINNED_CLIENT_MAX_ENTRIES,
8477        ));
8478
8479        let ctx = test_producer_ctx();
8480        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8481        let producer_a = endpoint.create_producer(rt(), &ctx);
8482        let producer_b = endpoint.create_producer(rt(), &ctx);
8483
8484        // Each producer sends one exchange whose resolved URL is the
8485        // endpoint's localhost base URL (a domain name → pinned-client path).
8486        for producer in [producer_a, producer_b] {
8487            let producer = producer.expect("create producer");
8488            let exchange = Exchange::new(Message::default());
8489            let reply = producer.oneshot(exchange).await;
8490            assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8491        }
8492
8493        assert_eq!(
8494            pinned_cache.build_count(),
8495            1,
8496            "both producers must hit the same shared cache entry; a second \
8497             build means sharing is broken"
8498        );
8499    }
8500
8501    #[tokio::test]
8502    async fn producer_repeated_hostname_requests_build_one_client() {
8503        use tower::ServiceExt;
8504
8505        let (base_url, _handle) = spawn_multi_accept_200().await;
8506        let pinned_cache = Arc::new(PinnedClientCache::new(
8507            PINNED_CLIENT_TTL,
8508            PINNED_CLIENT_MAX_ENTRIES,
8509        ));
8510        let ctx = test_producer_ctx();
8511        let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8512        let producer = endpoint
8513            .create_producer(rt(), &ctx)
8514            .expect("create producer");
8515
8516        // Two sequential hostname requests — the cached pinned client stays
8517        // retrievable between them, so no second build may happen.
8518        for i in 0..2 {
8519            let exchange = Exchange::new(Message::default());
8520            let reply = producer.clone().oneshot(exchange).await;
8521            assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8522        }
8523
8524        assert_eq!(
8525            pinned_cache.build_count(),
8526            1,
8527            "repeated hostname requests must reuse the one pinned client; \
8528             0 builds means the producer bypassed the cache, more than 1 \
8529             means the entry was dropped"
8530        );
8531    }
8532
8533    #[tokio::test]
8534    async fn ip_literal_request_never_enters_cache() {
8535        use tower::ServiceExt;
8536
8537        let (base_url, _handle) = spawn_multi_accept_200().await;
8538        let pinned_cache = Arc::new(PinnedClientCache::new(
8539            PINNED_CLIENT_TTL,
8540            PINNED_CLIENT_MAX_ENTRIES,
8541        ));
8542
8543        let ctx = test_producer_ctx();
8544        let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
8545        let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
8546        let producer = endpoint
8547            .create_producer(rt(), &ctx)
8548            .expect("create producer");
8549
8550        let exchange = Exchange::new(Message::default());
8551        let reply = producer.oneshot(exchange).await;
8552        assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8553
8554        assert_eq!(
8555            pinned_cache.build_count(),
8556            0,
8557            "an IP-literal URL must use the shared unpinned client and \
8558             never enter the pinned cache"
8559        );
8560    }
8561}