Skip to main content

camel_component_http/
lib.rs

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