Skip to main content

platform_core/automation/
http_client.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! The Async HTTP client (`async.http.request`) — Rust port of
18//! `org.platformlambda.automation.http.AsyncHttpClient`, closing the
19//! long-standing design §7 deferral. An event interceptor: the request is an
20//! [`AsyncHttpRequest`] map on the envelope body; the reply carries the HTTP
21//! status, response headers and a body decoded by content type (JSON object/
22//! array, text, or raw bytes). Outbound calls propagate the distributed
23//! trace (`X-Trace-Id` + W3C `traceparent`) and the business correlation-id,
24//! exactly like the REST automation edge expects on ingress.
25//!
26//! Deliberate deferrals from the Java original (documented, not silent):
27//! object streams (up/download) and multipart file upload wait for the
28//! platform-core streams port; XML request/response bodies pass through as
29//! text (the `SimpleXmlParser`/writer pair is not ported).
30//!
31//! `https` targets are supported (increment 48): certificates verify against
32//! the OS trust store (the JDK-truststore analog), and `trust_all_cert` on
33//! the request skips chain validation for self-signed endpoints — the same
34//! escape hatch as Java's `InsecureTrustManagerFactory` path.
35
36use std::collections::HashMap;
37use std::sync::{Arc, OnceLock};
38use std::time::Duration;
39
40use http_body_util::{BodyExt, Full};
41use hyper::body::Bytes;
42use rmpv::Value;
43use tokio_rustls::rustls;
44use tokio_rustls::rustls::pki_types::ServerName;
45use tokio_rustls::TlsConnector;
46
47use crate::envelope::EventEnvelope;
48use crate::event_stream;
49use crate::function::AppError;
50use crate::platform::Platform;
51use crate::post_office::PostOffice;
52use crate::util::app_config_reader::AppConfigReader;
53use crate::util::w3c_trace;
54
55pub const ASYNC_HTTP_REQUEST: &str = "async.http.request";
56/// The `x-event-api` marker value of the streaming-capable Event-over-HTTP
57/// relay: the request event carrying it opts the SSE consumption into the
58/// envelope-mode wire dialect (Java `EventEmitter.STREAM_RELAY`).
59pub const STREAM_RELAY: &str = "stream";
60const USER_AGENT_NAME: &str = "async-http-client";
61const DEFAULT_TTL_SECONDS: u64 = 30;
62/// Headers that may interfere with the underlying HTTP client (Java parity).
63const HEADERS_TO_IGNORE: &[&str] = &[
64    "content-length",
65    "user-agent",
66    "x-stream-id",
67    "content-encoding",
68    "transfer-encoding",
69    "host",
70    "connection",
71    "upgrade-insecure-requests",
72    "accept-encoding",
73    "sec-fetch-mode",
74    "sec-fetch-site",
75    "sec-fetch-user",
76    // engine-internal client instruction (the Event-over-HTTP transport-leg
77    // marker) — consumed by this client, never sent to the peer
78    "x-event-api",
79];
80
81/// The HTTP request contract (Java `AsyncHttpRequest`): a map-backed model
82/// with `method`, `url`, `host`, `headers`, `body`, `parameters.query`,
83/// `parameters.path`, `cookies`, `session` and the server-side dataset keys
84/// (`ip`, `https`, `timeout`, raw `query`). Programmatic callers build it
85/// with the fluent setters; declarative callers (flow data mapping) build
86/// the same map shape directly; a **typed function**
87/// (`TypedFunction<AsyncHttpRequest, O>` + `#[preload(..., typed)]`) receives
88/// it deserialized from the REST edge's request dataset — see the
89/// `Deserialize` impl below.
90#[derive(Clone, Debug)]
91pub struct AsyncHttpRequest {
92    method: Option<String>,
93    url: Option<String>,
94    target_host: Option<String>,
95    headers: Vec<(String, String)>,
96    // Option distinguishes "never set" (key omitted) from "set to null"
97    // (the REST edge emits an explicit null body for byte/form payloads)
98    body: Option<Value>,
99    query_parameters: Vec<(String, Value)>,
100    path_parameters: Vec<(String, String)>,
101    cookies: Vec<(String, String)>,
102    session: Vec<(String, String)>,
103    // Option: emitted only when explicitly set — a service-target dataset
104    // carries `host` (the Host header) WITHOUT a trust flag, while a relay
105    // caller that sets the flag keeps it on the wire
106    trust_all_cert: Option<bool>,
107    // server-side dataset keys (the REST edge emits them into the function's
108    // body; see automation::server process())
109    ip: Option<String>,
110    https: Option<bool>,
111    timeout: Option<u64>,
112    query_string: Option<String>,
113}
114
115/// Typed-function support (the maintainer-agreed design, 2026-07-26): the
116/// two serde traits are thin delegates onto the existing map-shape parser
117/// and builder, so `#[preload(..., typed)]` +
118/// `TypedFunction<AsyncHttpRequest, O>` flows through the ordinary
119/// `TypedAdapter` (`body_as::<I>()`) with ZERO worker special-casing — the
120/// knowledge lives on the type, not in the engine (Java, by contrast,
121/// special-cases `AsyncHttpRequest.class` inside `WorkerHandler.getMapBody`).
122/// This is the template rule for the Python/Node ports: their request
123/// classes must be constructible from the request map so typed signatures
124/// just work.
125impl<'de> serde::Deserialize<'de> for AsyncHttpRequest {
126    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127    where
128        D: serde::Deserializer<'de>,
129    {
130        let value = Value::deserialize(deserializer)?;
131        Ok(AsyncHttpRequest::from_value(&value))
132    }
133}
134
135impl serde::Serialize for AsyncHttpRequest {
136    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
137    where
138        S: serde::Serializer,
139    {
140        self.to_value().serialize(serializer)
141    }
142}
143
144impl Default for AsyncHttpRequest {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl AsyncHttpRequest {
151    #[allow(clippy::new_without_default)]
152    pub fn new() -> Self {
153        AsyncHttpRequest {
154            method: None,
155            url: None,
156            target_host: None,
157            headers: Vec::new(),
158            body: None,
159            query_parameters: Vec::new(),
160            path_parameters: Vec::new(),
161            cookies: Vec::new(),
162            session: Vec::new(),
163            trust_all_cert: None,
164            ip: None,
165            https: None,
166            timeout: None,
167            query_string: None,
168        }
169    }
170
171    /// Parse the map shape (the envelope body of an `async.http.request`
172    /// event).
173    pub fn from_value(value: &Value) -> Self {
174        let mut request = AsyncHttpRequest::new();
175        let Value::Map(entries) = value else {
176            return request;
177        };
178        let get = |key: &str| -> Option<&Value> {
179            entries
180                .iter()
181                .find(|(k, _)| k.as_str() == Some(key))
182                .map(|(_, v)| v)
183        };
184        request.method = get("method").and_then(|v| v.as_str()).map(str::to_string);
185        request.url = get("url").and_then(|v| v.as_str()).map(str::to_string);
186        request.target_host = get("host").and_then(|v| v.as_str()).map(str::to_string);
187        request.trust_all_cert = get("trust_all_cert").and_then(|v| v.as_bool());
188        request.body = get("body").cloned();
189        // server-side dataset keys (REST edge → service-target function)
190        request.ip = get("ip").and_then(|v| v.as_str()).map(str::to_string);
191        request.https = get("https").and_then(|v| v.as_bool());
192        request.timeout = get("timeout").and_then(|v| v.as_u64());
193        request.query_string = get("query").and_then(|v| v.as_str()).map(str::to_string);
194        if let Some(Value::Map(headers)) = get("headers") {
195            for (k, v) in headers {
196                if let Some(key) = k.as_str() {
197                    request.headers.push((key.to_string(), display_text(v)));
198                }
199            }
200        }
201        if let Some(Value::Map(cookies)) = get("cookies") {
202            for (k, v) in cookies {
203                if let Some(key) = k.as_str() {
204                    request.cookies.push((key.to_string(), display_text(v)));
205                }
206            }
207        }
208        if let Some(Value::Map(session)) = get("session") {
209            for (k, v) in session {
210                if let Some(key) = k.as_str() {
211                    request.session.push((key.to_string(), display_text(v)));
212                }
213            }
214        }
215        if let Some(Value::Map(parameters)) = get("parameters") {
216            for (k, v) in parameters {
217                match (k.as_str(), v) {
218                    (Some("query"), Value::Map(query)) => {
219                        for (qk, qv) in query {
220                            if let Some(key) = qk.as_str() {
221                                request.query_parameters.push((key.to_string(), qv.clone()));
222                            }
223                        }
224                    }
225                    (Some("path"), Value::Map(path)) => {
226                        for (pk, pv) in path {
227                            if let Some(key) = pk.as_str() {
228                                request
229                                    .path_parameters
230                                    .push((key.to_string(), display_text(pv)));
231                            }
232                        }
233                    }
234                    _ => {}
235                }
236            }
237        }
238        request
239    }
240
241    /// Render the Java `toMap()` shape for the envelope body.
242    pub fn to_value(&self) -> Value {
243        let mut map: Vec<(Value, Value)> = Vec::new();
244        if let Some(method) = &self.method {
245            map.push((Value::from("method"), Value::from(method.as_str())));
246        }
247        if let Some(url) = &self.url {
248            map.push((Value::from("url"), Value::from(url.as_str())));
249        }
250        if let Some(host) = &self.target_host {
251            map.push((Value::from("host"), Value::from(host.as_str())));
252        }
253        if let Some(trust_all_cert) = self.trust_all_cert {
254            // Java toMap: trust_all_cert travels with the relay target
255            map.push((Value::from("trust_all_cert"), Value::from(trust_all_cert)));
256        }
257        if !self.headers.is_empty() {
258            map.push((Value::from("headers"), string_pairs(&self.headers)));
259        }
260        if let Some(body) = &self.body {
261            // an explicit null body stays on the wire (the REST edge emits
262            // body: null for byte/form payloads; an UNSET body is omitted)
263            map.push((Value::from("body"), body.clone()));
264        }
265        // server-side dataset keys, emitted when set (round-trip integrity:
266        // to_value emits exactly what from_value parses)
267        if let Some(ip) = &self.ip {
268            map.push((Value::from("ip"), Value::from(ip.as_str())));
269        }
270        if let Some(https) = self.https {
271            map.push((Value::from("https"), Value::from(https)));
272        }
273        if let Some(timeout) = self.timeout {
274            map.push((Value::from("timeout"), Value::from(timeout)));
275        }
276        if let Some(query) = &self.query_string {
277            map.push((Value::from("query"), Value::from(query.as_str())));
278        }
279        if !self.cookies.is_empty() {
280            map.push((Value::from("cookies"), string_pairs(&self.cookies)));
281        }
282        if !self.session.is_empty() {
283            map.push((Value::from("session"), string_pairs(&self.session)));
284        }
285        // "parameters" is always present with both sub-maps — the REST
286        // edge's dataset shape (a paramless request still carries the empty
287        // maps, exactly like today's server emission)
288        let query: Vec<(Value, Value)> = self
289            .query_parameters
290            .iter()
291            .map(|(k, v)| (Value::from(k.as_str()), v.clone()))
292            .collect();
293        let path: Vec<(Value, Value)> = self
294            .path_parameters
295            .iter()
296            .map(|(k, v)| (Value::from(k.as_str()), Value::from(v.as_str())))
297            .collect();
298        map.push((
299            Value::from("parameters"),
300            Value::Map(vec![
301                (Value::from("query"), Value::Map(query)),
302                (Value::from("path"), Value::Map(path)),
303            ]),
304        ));
305        Value::Map(map)
306    }
307
308    pub fn set_method(mut self, method: &str) -> Self {
309        self.method = Some(method.to_string());
310        self
311    }
312
313    pub fn set_url(mut self, url: &str) -> Self {
314        self.url = Some(url.to_string());
315        self
316    }
317
318    pub fn set_target_host(mut self, host: &str) -> Self {
319        self.target_host = Some(host.to_string());
320        self
321    }
322
323    /// Java `setTrustAllCert`: skip certificate-chain validation for an
324    /// `https` target (self-signed endpoints). Ignored for plain `http`.
325    pub fn set_trust_all_cert(mut self, trust_all_cert: bool) -> Self {
326        self.trust_all_cert = Some(trust_all_cert);
327        self
328    }
329
330    /// Set (or replace, case-insensitively) a request header.
331    pub fn set_header(mut self, key: &str, value: &str) -> Self {
332        self.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(key));
333        self.headers.push((key.to_string(), value.to_string()));
334        self
335    }
336
337    pub fn set_body(mut self, body: Value) -> Self {
338        self.body = Some(body);
339        self
340    }
341
342    /// Set (or replace — Java `setQueryParameter` put semantics) a
343    /// single-value query parameter.
344    pub fn set_query_parameter(mut self, key: &str, value: &str) -> Self {
345        self.query_parameters.retain(|(k, _)| k != key);
346        self.query_parameters
347            .push((key.to_string(), Value::from(value)));
348        self
349    }
350
351    /// Set a REPEATED query parameter (the list shape the REST edge produces
352    /// for `?q=a&q=b` — Java `setQueryParameter` with a List value).
353    pub fn set_query_parameter_values(mut self, key: &str, values: &[&str]) -> Self {
354        self.query_parameters.retain(|(k, _)| k != key);
355        self.query_parameters.push((
356            key.to_string(),
357            Value::Array(values.iter().map(|v| Value::from(*v)).collect()),
358        ));
359        self
360    }
361
362    pub fn set_path_parameter(mut self, key: &str, value: &str) -> Self {
363        self.path_parameters
364            .push((key.to_string(), value.to_string()));
365        self
366    }
367
368    /// Set (or replace) a cookie (Java `setCookie`).
369    pub fn set_cookie(mut self, key: &str, value: &str) -> Self {
370        self.cookies.retain(|(k, _)| k != key);
371        self.cookies.push((key.to_string(), value.to_string()));
372        self
373    }
374
375    /// Set (or replace) a session-info entry (Java `setSessionInfo`) — on the
376    /// server side these arrive from the authentication service's verdict.
377    pub fn set_session_info(mut self, key: &str, value: &str) -> Self {
378        self.session.retain(|(k, _)| k != key);
379        self.session.push((key.to_string(), value.to_string()));
380        self
381    }
382
383    /// The caller's IP address (Java `setRemoteIp`) — the REST edge stamps it
384    /// on the request dataset.
385    pub fn set_remote_ip(mut self, ip: &str) -> Self {
386        self.ip = Some(ip.to_string());
387        self
388    }
389
390    /// Whether the original request arrived over HTTPS (Java `setSecure`).
391    pub fn set_secure(mut self, https: bool) -> Self {
392        self.https = Some(https);
393        self
394    }
395
396    /// The raw query string (Java `setQueryString`).
397    pub fn set_query_string(mut self, query: &str) -> Self {
398        self.query_string = Some(query.to_string());
399        self
400    }
401
402    /// The ROUTE timeout in seconds — the REST edge's `timeout` dataset key
403    /// (rest.yaml endpoint timeout). Distinct from [`set_timeout_seconds`],
404    /// which writes the caller's TTL as the `x-ttl` header (milliseconds,
405    /// Java `setTimeoutSeconds`); on read, `timeout_seconds()` prefers a
406    /// caller-sent x-ttl and falls back to this field — the ingress
407    /// precedence.
408    pub fn set_route_timeout_seconds(mut self, seconds: u64) -> Self {
409        self.timeout = Some(seconds);
410        self
411    }
412
413    /// Java `setTimeoutSeconds`: the TTL travels as the `x-ttl` header (ms).
414    pub fn set_timeout_seconds(self, timeout_seconds: u64) -> Self {
415        let ms = timeout_seconds.max(1) * 1000;
416        self.set_header("x-ttl", &ms.to_string())
417    }
418
419    pub fn method(&self) -> &str {
420        self.method.as_deref().unwrap_or("GET")
421    }
422
423    /// The request URI path (Java `AsyncHttpRequest.getUrl`).
424    pub fn url(&self) -> &str {
425        self.url.as_deref().unwrap_or("/")
426    }
427
428    pub fn target_host(&self) -> Option<&str> {
429        self.target_host.as_deref()
430    }
431
432    pub fn trust_all_cert(&self) -> bool {
433        self.trust_all_cert.unwrap_or(false)
434    }
435
436    /// All request headers in insertion order.
437    pub fn headers(&self) -> &[(String, String)] {
438        &self.headers
439    }
440
441    /// Session info injected by an authentication service (Java
442    /// `AsyncHttpRequest.getSessionInfo`): headers returned on the auth
443    /// verdict, riding to the target function as read-only headers.
444    pub fn session(&self) -> &[(String, String)] {
445        &self.session
446    }
447
448    pub fn header(&self, key: &str) -> Option<&str> {
449        self.headers
450            .iter()
451            .find(|(k, _)| k.eq_ignore_ascii_case(key))
452            .map(|(_, v)| v.as_str())
453    }
454
455    pub fn body(&self) -> &Value {
456        static NIL: Value = Value::Nil;
457        self.body.as_ref().unwrap_or(&NIL)
458    }
459
460    /// Java `AsyncHttpRequest.getTimeoutSeconds()`: the x-ttl header is in
461    /// milliseconds and a fractional second must round UP, never down
462    /// (1,500 ms is a 2-second budget, not 1) — otherwise the wire-level
463    /// read timeout fires before a peer that spends its whole TTL replies.
464    pub fn timeout_seconds(&self) -> u64 {
465        self.header("x-ttl")
466            .and_then(|v| v.parse::<u64>().ok())
467            .map(|ms| ms.max(1).div_ceil(1000))
468            // the REST edge also carries the route timeout as the dataset's
469            // "timeout" key (seconds); a caller-sent x-ttl wins, matching
470            // the ingress precedence
471            .or(self.timeout)
472            .unwrap_or(DEFAULT_TTL_SECONDS)
473    }
474
475    /// Deserialize the request body into a typed value (Java
476    /// `AsyncHttpRequest.getBody(Class<T>)`).
477    pub fn body_as<T: serde::de::DeserializeOwned>(&self) -> Result<T, AppError> {
478        rmpv::ext::from_value(self.body().clone())
479            .map_err(|e| AppError::new(500, format!("unable to deserialize body: {e}")))
480    }
481
482    /// The caller's IP address (Java `AsyncHttpRequest.getRemoteIp`) — set
483    /// by the REST edge on the request dataset.
484    pub fn remote_ip(&self) -> Option<&str> {
485        self.ip.as_deref()
486    }
487
488    /// Whether the original request arrived over HTTPS (Java
489    /// `AsyncHttpRequest.isSecure`; the REST edge derives it from
490    /// `x-forwarded-proto`).
491    pub fn is_secure(&self) -> bool {
492        self.https.unwrap_or(false)
493    }
494
495    /// The raw query string (Java `AsyncHttpRequest.getQueryString`).
496    pub fn query_string(&self) -> Option<&str> {
497        self.query_string.as_deref()
498    }
499
500    /// One `{path}` parameter by name (Java `getPathParameter`).
501    pub fn path_parameter(&self, key: &str) -> Option<&str> {
502        self.path_parameters
503            .iter()
504            .find(|(k, _)| k == key)
505            .map(|(_, v)| v.as_str())
506    }
507
508    /// All `{path}` parameters (Java `getPathParameters`).
509    pub fn path_parameters(&self) -> &[(String, String)] {
510        &self.path_parameters
511    }
512
513    /// One query parameter as text (Java `getQueryParameter`): a repeated
514    /// parameter (list value) yields its FIRST occurrence.
515    pub fn query_parameter(&self, key: &str) -> Option<String> {
516        self.query_parameters
517            .iter()
518            .find(|(k, _)| k == key)
519            .map(|(_, v)| match v {
520                Value::Array(values) => values.first().map(display_text).unwrap_or_default(),
521                other => display_text(other),
522            })
523    }
524
525    /// Every value of a query parameter (Java `getQueryParameters`): one
526    /// occurrence is a one-element list, repeats keep every value.
527    pub fn query_parameters(&self, key: &str) -> Vec<String> {
528        self.query_parameters
529            .iter()
530            .find(|(k, _)| k == key)
531            .map(|(_, v)| match v {
532                Value::Array(values) => values.iter().map(display_text).collect(),
533                other => vec![display_text(other)],
534            })
535            .unwrap_or_default()
536    }
537
538    /// One cookie by name (Java `getCookie`).
539    pub fn cookie(&self, key: &str) -> Option<&str> {
540        self.cookies
541            .iter()
542            .find(|(k, _)| k == key)
543            .map(|(_, v)| v.as_str())
544    }
545
546    /// All cookies as parsed by the REST edge (Java `getCookies`).
547    pub fn cookies(&self) -> &[(String, String)] {
548        &self.cookies
549    }
550
551    /// One session-info entry by name (Java `getSessionInfo(String)`).
552    pub fn session_info(&self, key: &str) -> Option<&str> {
553        self.session
554            .iter()
555            .find(|(k, _)| k == key)
556            .map(|(_, v)| v.as_str())
557    }
558
559    /// Java `getFinalizedUrl`: substitute `{path}` parameters, merge query
560    /// parameters into the query string, and keep any `#hash` suffix.
561    pub fn finalized_url(&self) -> String {
562        let uri = self.url.as_deref().unwrap_or("/");
563        let (without_hash, hash) = match uri.rfind('#') {
564            Some(mark) => (&uri[..mark], Some(&uri[mark + 1..])),
565            None => (uri, None),
566        };
567        let (mut raw_uri, mut query_string) = match without_hash.rfind('?') {
568            Some(mark) => (
569                without_hash[..mark].to_string(),
570                Some(without_hash[mark + 1..].to_string()),
571            ),
572            None => (without_hash.to_string(), None),
573        };
574        let qs = self.query_parameters_to_string();
575        if let Some(qs) = qs {
576            query_string = Some(match query_string {
577                Some(existing) => format!("{existing}&{qs}"),
578                None => qs,
579            });
580        }
581        for (key, value) in &self.path_parameters {
582            let token = format!("{{{key}}}");
583            if raw_uri.contains(&token) {
584                raw_uri = raw_uri.replace(&token, value);
585            }
586        }
587        let mut out = raw_uri;
588        if let Some(qs) = query_string {
589            out.push('?');
590            out.push_str(&qs);
591        }
592        if let Some(hash) = hash {
593            out.push('#');
594            out.push_str(hash);
595        }
596        // minimal encoding (Java getEncodedUri): spaces in the path
597        out.replace(' ', "%20")
598    }
599
600    /// Java `queryParametersToString`: string values and lists of strings
601    /// only (other types are skipped, Java parity).
602    fn query_parameters_to_string(&self) -> Option<String> {
603        if self.query_parameters.is_empty() {
604            return None;
605        }
606        let mut parts: Vec<String> = Vec::new();
607        for (key, value) in &self.query_parameters {
608            match value {
609                Value::String(_) => parts.push(format!("{key}={}", display_text(value))),
610                Value::Array(items) => {
611                    for item in items {
612                        if matches!(item, Value::String(_)) {
613                            parts.push(format!("{key}={}", display_text(item)));
614                        }
615                    }
616                }
617                _ => {}
618            }
619        }
620        if parts.is_empty() {
621            None
622        } else {
623            Some(parts.join("&"))
624        }
625    }
626}
627
628fn string_pairs(pairs: &[(String, String)]) -> Value {
629    Value::Map(
630        pairs
631            .iter()
632            .map(|(k, v)| (Value::from(k.as_str()), Value::from(v.as_str())))
633            .collect(),
634    )
635}
636
637fn display_text(value: &Value) -> String {
638    match value {
639        Value::String(s) => s.as_str().unwrap_or_default().to_string(),
640        Value::Nil => String::new(),
641        other => other.to_string(),
642    }
643}
644
645/// The interceptor body: process the request and reply manually with the
646/// decoded HTTP response (or an error envelope).
647/// The reply routing of one client call, when the caller supplied a reply_to.
648/// `envelope_mode` marks the Event-over-HTTP streaming relay leg (the request
649/// event carries `x-event-api: stream`): an SSE response decodes as the
650/// envelope-mode wire dialect and a buffered reply decodes as a serialized
651/// envelope, preserving the classic callback semantics.
652struct StreamTarget {
653    reply_to: String,
654    cid: String,
655    envelope_mode: bool,
656}
657
658pub(crate) async fn handle(
659    platform: &Platform,
660    _headers: HashMap<String, String>,
661    event: EventEnvelope,
662) -> Result<EventEnvelope, AppError> {
663    let po = PostOffice::new(platform);
664    let Some(reply_to) = event.reply_to().map(str::to_string) else {
665        // fire-and-forget: errors are logged, successes discarded (Java parity)
666        if let Err(e) = process_request(platform, &po, &_headers, &event, None).await {
667            log::error!("Unhandled exception (no reply-to) - {}", e.message());
668        }
669        return EventEnvelope::new().set_body("ignored");
670    };
671    let cid = event.correlation_id().unwrap_or_default().to_string();
672    let envelope_mode = event.headers().iter().any(|(name, value)| {
673        name.eq_ignore_ascii_case(super::event_api::X_EVENT_API) && value == STREAM_RELAY
674    });
675    let stream_target = Some(StreamTarget {
676        reply_to: reply_to.clone(),
677        cid: cid.clone(),
678        envelope_mode,
679    });
680    let response = match process_request(platform, &po, &_headers, &event, stream_target).await {
681        // a progressive SSE relay was spawned - it owns the reply route now
682        Ok(None) => return EventEnvelope::new().set_body("ignored"),
683        Ok(Some(response)) => response,
684        Err(e) => EventEnvelope::new()
685            .set_status(e.status())
686            .set_raw_body(Value::from(e.message())),
687    };
688    let _ = po
689        .send(response.set_to(&reply_to).set_correlation_id(&cid))
690        .await;
691    EventEnvelope::new().set_body("ignored")
692}
693
694/// A request is a progressive-SSE candidate when the caller explicitly accepts
695/// text/event-stream AND supplied a reply_to (a multi-shot-capable consumer) -
696/// everything else keeps the buffered single-shot behavior (D1).
697fn accepts_event_stream(request: &AsyncHttpRequest) -> bool {
698    request.headers().iter().any(|(name, value)| {
699        name.eq_ignore_ascii_case("accept") && value.contains("text/event-stream")
700    })
701}
702
703async fn process_request(
704    platform: &Platform,
705    po: &PostOffice,
706    invocation_headers: &HashMap<String, String>,
707    event: &EventEnvelope,
708    stream_target: Option<StreamTarget>,
709) -> Result<Option<EventEnvelope>, AppError> {
710    let request = AsyncHttpRequest::from_value(event.body());
711    let (secure, host, port) = validate_url(&request)?;
712    let uri = request.finalized_url();
713    po.annotate_trace(
714        "destination",
715        format!(
716            "{}{}",
717            request.target_host().unwrap_or_default(),
718            raw_url(&uri)
719        ),
720    );
721    let method = request.method().to_string();
722    if !matches!(
723        method.as_str(),
724        "GET" | "HEAD" | "PUT" | "POST" | "PATCH" | "DELETE" | "OPTIONS"
725    ) {
726        return Err(AppError::new(405, "Method not allowed"));
727    }
728    // connect with the configured timeout (Java http.client.connection.timeout)
729    let connect_ms = connect_timeout_ms();
730    let stream = tokio::time::timeout(
731        Duration::from_millis(connect_ms),
732        tokio::net::TcpStream::connect((host.as_str(), port)),
733    )
734    .await
735    .map_err(|_| AppError::new(408, format!("Connection timeout for {host}:{port}")))?
736    .map_err(|e| AppError::new(500, format!("Unable to connect to {host}:{port} - {e}")))?;
737    // http and https produce different stream types but the same SendRequest
738    let mut sender = if secure {
739        let connector = tls_connector(request.trust_all_cert())?;
740        let server_name = ServerName::try_from(host.clone())
741            .map_err(|e| AppError::new(400, format!("Invalid TLS server name {host} - {e}")))?;
742        let tls_stream = connector
743            .connect(server_name, stream)
744            .await
745            .map_err(|e| AppError::new(500, format!("TLS handshake failed for {host} - {e}")))?;
746        let io = hyper_util::rt::TokioIo::new(tls_stream);
747        let (sender, connection) = hyper::client::conn::http1::handshake(io)
748            .await
749            .map_err(|e| AppError::new(500, format!("HTTP handshake failed - {e}")))?;
750        tokio::spawn(async move {
751            let _ = connection.await;
752        });
753        sender
754    } else {
755        let io = hyper_util::rt::TokioIo::new(stream);
756        let (sender, connection) = hyper::client::conn::http1::handshake(io)
757            .await
758            .map_err(|e| AppError::new(500, format!("HTTP handshake failed - {e}")))?;
759        tokio::spawn(async move {
760            let _ = connection.await;
761        });
762        sender
763    };
764    let mut builder = hyper::Request::builder()
765        .method(method.as_str())
766        .uri(if uri.is_empty() { "/" } else { &uri });
767    builder = apply_headers(
768        builder,
769        invocation_headers,
770        &request,
771        event,
772        &host,
773        port,
774        secure,
775    );
776    let body_bytes = request_body_bytes(&request, &method)?;
777    let http_request = builder
778        .body(Full::new(Bytes::from(body_bytes)))
779        .map_err(|e| AppError::new(400, format!("Invalid HTTP request - {e}")))?;
780    // per-request response timeout (the x-ttl header, default 30s) with one
781    // extra second of wire-level grace so a peer that spends its whole TTL
782    // and replies AT the deadline is still readable; the caller's own RPC
783    // timeout, not this read timeout, governs the user-visible deadline
784    // (Java parity: AsyncHttpClient responseTimeout = getTimeoutSeconds() + 1)
785    let ttl = Duration::from_secs(request.timeout_seconds() + 1);
786    let http_response = tokio::time::timeout(ttl, sender.send_request(http_request))
787        .await
788        .map_err(|_| AppError::new(408, format!("Timeout for {} ms", ttl.as_millis())))?
789        .map_err(|e| AppError::new(500, format!("HTTP request failed - {e}")))?;
790    let mut response = EventEnvelope::new().set_status(http_response.status().as_u16() as i32);
791    let mut content_type: Option<String> = None;
792    let mut has_content_length = false;
793    for (name, value) in http_response.headers() {
794        let key = name.as_str();
795        let text = value.to_str().unwrap_or_default();
796        if key.eq_ignore_ascii_case("content-type") {
797            content_type = Some(text.to_lowercase());
798        }
799        if key.eq_ignore_ascii_case("content-length") {
800            has_content_length = true;
801        }
802        response = response.set_header(key, text);
803    }
804    // progressive SSE consumption (D1): the caller opted in with Accept AND the
805    // upstream actually answers text/event-stream - relay each SSE event as one
806    // x-event-stream data envelope to the caller's reply route (the producer
807    // contract the HTTP edge consumes), then eof. The relay runs in its own task
808    // so this worker is freed - a long stream never holds a client instance.
809    let envelope_mode = stream_target.as_ref().is_some_and(|t| t.envelope_mode);
810    if let Some(target) = stream_target {
811        let sse = content_type
812            .as_deref()
813            .is_some_and(|ct| ct.starts_with("text/event-stream"));
814        if sse && accepts_event_stream(&request) {
815            let status = http_response.status().as_u16() as i32;
816            let relay_platform = platform.clone();
817            if target.envelope_mode {
818                // one extra second so the peer's in-band 408, sent AT the
819                // deadline, wins the race against the local idle timer
820                let idle = Duration::from_secs(request.timeout_seconds().max(1) + 1);
821                tokio::spawn(relay_envelope_sse(
822                    relay_platform,
823                    http_response.into_body(),
824                    target.reply_to,
825                    target.cid,
826                    idle,
827                ));
828            } else {
829                let idle = Duration::from_secs(request.timeout_seconds().max(1));
830                tokio::spawn(relay_sse(
831                    relay_platform,
832                    http_response.into_body(),
833                    status,
834                    target.reply_to,
835                    target.cid,
836                    idle,
837                ));
838            }
839            return Ok(None);
840        }
841    }
842    let bytes = http_response
843        .into_body()
844        .collect()
845        .await
846        .map_err(|e| AppError::new(500, format!("Unable to read HTTP response - {e}")))?
847        .to_bytes();
848    if envelope_mode {
849        // the peer answered single-shot (a non-streaming target, or an edge
850        // error) - decode and deliver with the classic callback semantics
851        return Ok(Some(decode_relay_reply(&bytes, response.status())));
852    }
853    if !has_content_length {
854        response = response.set_header("x-content-length", &bytes.len().to_string());
855    }
856    Ok(Some(response.set_raw_body(decode_response_body(
857        &bytes,
858        content_type.as_deref(),
859    ))))
860}
861
862/// Decode a single-shot Event-over-HTTP reply: a serialized envelope normally,
863/// with the classic tolerant handling of an edge-level REST error body
864/// (`'{"type": "error", "status": n, "message": text}'` JSON) and of a payload
865/// that is not a serialized envelope at all.
866fn decode_relay_reply(bytes: &[u8], http_status: i32) -> EventEnvelope {
867    if bytes.is_empty() {
868        return EventEnvelope::new().set_status(http_status);
869    }
870    match EventEnvelope::from_bytes(bytes) {
871        Ok(envelope) => envelope.clear_reply_to(),
872        Err(e) => rest_error_reply(bytes, http_status).unwrap_or_else(|| {
873            EventEnvelope::new()
874                .set_status(400)
875                .set_raw_body(Value::from(format!(
876                    "Did you configure rest.yaml correctly? Invalid result set - {}",
877                    e.message()
878                )))
879        }),
880    }
881}
882
883/// An edge-level REST error arrives as JSON, not as a serialized envelope -
884/// unwrap it exactly as the classic relay does.
885fn rest_error_reply(bytes: &[u8], http_status: i32) -> Option<EventEnvelope> {
886    if http_status < 400 {
887        return None;
888    }
889    let data = serde_json::from_slice::<serde_json::Value>(bytes).ok()?;
890    if data.get("type").and_then(|v| v.as_str()) != Some("error") {
891        return None;
892    }
893    let message = data.get("message").and_then(|v| v.as_str())?;
894    Some(
895        EventEnvelope::new()
896            .set_status(http_status)
897            .set_raw_body(Value::from(message)),
898    )
899}
900
901/// Incremental SSE frame parser (raw mode): byte-level line split (a newline
902/// is a single byte, so this is UTF-8 safe), one-leading-space value strip,
903/// comment/id/retry suppression, multi-line data joined per the SSE
904/// specification. Mirrors the Java SseRelay parser.
905#[derive(Default)]
906struct SseParser {
907    pending: Vec<u8>,
908    data_lines: Vec<String>,
909    event_name: Option<String>,
910}
911
912impl SseParser {
913    /// Feed one body chunk; return the completed (event_name, data) events.
914    fn feed(&mut self, chunk: &[u8]) -> Vec<(Option<String>, String)> {
915        self.pending.extend_from_slice(chunk);
916        let mut events = Vec::new();
917        let mut start = 0;
918        let buffer = std::mem::take(&mut self.pending);
919        for i in 0..buffer.len() {
920            if buffer[i] == b'\n' {
921                let end = if i > start && buffer[i - 1] == b'\r' {
922                    i - 1
923                } else {
924                    i
925                };
926                let line = String::from_utf8_lossy(&buffer[start..end]).to_string();
927                start = i + 1;
928                if line.is_empty() {
929                    // blank line dispatches the pending event (SSE specification)
930                    if !self.data_lines.is_empty() {
931                        events.push((self.event_name.take(), self.data_lines.join("\n")));
932                    }
933                    self.data_lines.clear();
934                    self.event_name = None;
935                } else if !line.starts_with(':') {
936                    // a comment line (leading colon) is consumed, never forwarded
937                    let (field, value) = match line.find(':') {
938                        Some(colon) => (&line[..colon], &line[colon + 1..]),
939                        None => (line.as_str(), ""),
940                    };
941                    let value = value.strip_prefix(' ').unwrap_or(value);
942                    match field {
943                        "data" => self.data_lines.push(value.to_string()),
944                        "event" => self.event_name = Some(value.to_string()),
945                        _ => { /* id, retry and unknown fields are ignored */ }
946                    }
947                }
948            }
949        }
950        self.pending = buffer[start..].to_vec();
951        events
952    }
953}
954
955/// The spawned relay of a progressive SSE response: one x-event-stream data
956/// envelope per upstream event to the caller's reply route (head control on
957/// the first), eof on a clean end, in-band exception on idle expiry or a
958/// mid-stream transport error. The per-read idle allowance is the request
959/// TTL - any upstream bytes, keep-alive comments included, reset it (D4).
960async fn relay_sse(
961    platform: Platform,
962    mut body: hyper::body::Incoming,
963    status: i32,
964    reply_to: String,
965    cid: String,
966    idle: Duration,
967) {
968    let po = PostOffice::new(&platform);
969    let mut parser = SseParser::default();
970    let mut head_sent = false;
971    loop {
972        match tokio::time::timeout(idle, body.frame()).await {
973            Ok(Some(Ok(frame))) => {
974                if let Some(data) = frame.data_ref() {
975                    for (name, text) in parser.feed(data) {
976                        let mut segment = EventEnvelope::new()
977                            .set_header(event_stream::X_EVENT_STREAM, event_stream::DATA);
978                        if let Some(name) = name.filter(|n| !n.is_empty()) {
979                            segment = segment.set_header(event_stream::X_EVENT_NAME, &name);
980                        }
981                        segment = match segment.set_body(text) {
982                            Ok(seg) => seg,
983                            Err(_) => continue,
984                        };
985                        if !head_sent {
986                            head_sent = true;
987                            // head control rides the first envelope: upstream
988                            // status + the SSE content type
989                            segment = segment
990                                .set_status(status)
991                                .set_header("content-type", "text/event-stream");
992                        }
993                        if send_segment(&po, segment, &reply_to, &cid).await.is_err() {
994                            return;
995                        }
996                    }
997                }
998            }
999            Ok(None) => {
1000                // clean end of transmission - an incomplete trailing event is
1001                // discarded (SSE specification)
1002                let mut eof = EventEnvelope::new()
1003                    .set_header(event_stream::X_EVENT_STREAM, event_stream::EOF);
1004                if !head_sent {
1005                    eof = eof
1006                        .set_status(status)
1007                        .set_header("content-type", "text/event-stream");
1008                }
1009                let _ = send_segment(&po, eof, &reply_to, &cid).await;
1010                return;
1011            }
1012            Ok(Some(Err(e))) => {
1013                fail_in_band(&po, &reply_to, &cid, 500, &e.to_string(), head_sent).await;
1014                return;
1015            }
1016            Err(_) => {
1017                // idle expiry - the connection closes when the body is dropped
1018                let message = format!("Timeout for {} seconds", idle.as_secs());
1019                fail_in_band(&po, &reply_to, &cid, 408, &message, head_sent).await;
1020                return;
1021            }
1022        }
1023    }
1024}
1025
1026/// The spawned relay of an Event-over-HTTP streaming response (envelope mode):
1027/// an "envelope" frame carries one base64-encoded serialized EventEnvelope -
1028/// the head, the terminals and non-text segments; any other frame is a raw
1029/// text segment. Decoded events forward to the original caller's reply route
1030/// with the original correlation id; a decoded terminal (eof or exception)
1031/// ends the logical stream and trailing frames are discarded. Dialect guards:
1032/// the first frame must be an envelope frame, and a transport end without a
1033/// decoded terminal is a truncation - both fail in-band.
1034async fn relay_envelope_sse(
1035    platform: Platform,
1036    mut body: hyper::body::Incoming,
1037    reply_to: String,
1038    cid: String,
1039    idle: Duration,
1040) {
1041    let po = PostOffice::new(&platform);
1042    let mut parser = SseParser::default();
1043    let mut head_seen = false;
1044    loop {
1045        match tokio::time::timeout(idle, body.frame()).await {
1046            Ok(Some(Ok(frame))) => {
1047                if let Some(data) = frame.data_ref() {
1048                    for (name, text) in parser.feed(data) {
1049                        match relay_envelope_event(&po, name, text, &reply_to, &cid, &mut head_seen)
1050                            .await
1051                        {
1052                            RelayFlow::Next => {}
1053                            // a decoded terminal ends the logical stream -
1054                            // frames after it (and the transport end) are
1055                            // discarded by returning here
1056                            RelayFlow::End => return,
1057                        }
1058                    }
1059                }
1060            }
1061            Ok(None) => {
1062                // the dialect ends with a decoded terminal - a bare transport
1063                // end is a truncation
1064                fail_in_band(
1065                    &po,
1066                    &reply_to,
1067                    &cid,
1068                    500,
1069                    "Event stream ended without eof",
1070                    head_seen,
1071                )
1072                .await;
1073                return;
1074            }
1075            Ok(Some(Err(e))) => {
1076                fail_in_band(&po, &reply_to, &cid, 500, &e.to_string(), head_seen).await;
1077                return;
1078            }
1079            Err(_) => {
1080                let message = format!("Timeout for {} seconds", idle.as_secs());
1081                fail_in_band(&po, &reply_to, &cid, 408, &message, head_seen).await;
1082                return;
1083            }
1084        }
1085    }
1086}
1087
1088/// What one envelope-mode frame did to the relay.
1089enum RelayFlow {
1090    Next,
1091    End,
1092}
1093
1094async fn relay_envelope_event(
1095    po: &PostOffice,
1096    name: Option<String>,
1097    text: String,
1098    reply_to: &str,
1099    cid: &str,
1100    head_seen: &mut bool,
1101) -> RelayFlow {
1102    use base64::Engine as _;
1103    if name.as_deref() == Some(event_stream::ENVELOPE) {
1104        let decoded = base64::engine::general_purpose::STANDARD
1105            .decode(&text)
1106            .ok()
1107            .and_then(|bytes| EventEnvelope::from_bytes(&bytes).ok());
1108        let Some(decoded) = decoded else {
1109            fail_in_band(
1110                po,
1111                reply_to,
1112                cid,
1113                500,
1114                "Invalid event stream - malformed envelope frame",
1115                *head_seen,
1116            )
1117            .await;
1118            return RelayFlow::End;
1119        };
1120        *head_seen = true;
1121        let terminal = decoded.headers().iter().any(|(key, value)| {
1122            key.eq_ignore_ascii_case(event_stream::X_EVENT_STREAM)
1123                && (value.eq_ignore_ascii_case(event_stream::EOF)
1124                    || value.eq_ignore_ascii_case(event_stream::EXCEPTION))
1125        });
1126        let _ = send_segment(po, decoded.clear_reply_to(), reply_to, cid).await;
1127        if terminal {
1128            RelayFlow::End
1129        } else {
1130            RelayFlow::Next
1131        }
1132    } else if !*head_seen {
1133        // the dialect guarantees an envelope frame first (conformance guard)
1134        fail_in_band(
1135            po,
1136            reply_to,
1137            cid,
1138            500,
1139            "Invalid event stream - missing envelope head",
1140            false,
1141        )
1142        .await;
1143        RelayFlow::End
1144    } else {
1145        // a raw frame is one plain text segment
1146        let mut segment =
1147            EventEnvelope::new().set_header(event_stream::X_EVENT_STREAM, event_stream::DATA);
1148        if let Some(name) = name.filter(|n| !n.is_empty()) {
1149            segment = segment.set_header(event_stream::X_EVENT_NAME, &name);
1150        }
1151        if let Ok(segment) = segment.set_body(text) {
1152            let _ = send_segment(po, segment, reply_to, cid).await;
1153        }
1154        RelayFlow::Next
1155    }
1156}
1157
1158async fn fail_in_band(
1159    po: &PostOffice,
1160    reply_to: &str,
1161    cid: &str,
1162    status: i32,
1163    message: &str,
1164    head_sent: bool,
1165) {
1166    // the standard error key-values: '{"type": "error", "status": n, "message": text}'
1167    let body = serde_json::json!({"type": "error", "status": status, "message": message});
1168    let Ok(mut error) = EventEnvelope::new()
1169        .set_header(event_stream::X_EVENT_STREAM, event_stream::EXCEPTION)
1170        .set_status(status)
1171        .set_body(body)
1172    else {
1173        return;
1174    };
1175    if !head_sent {
1176        error = error.set_header("content-type", "text/event-stream");
1177    }
1178    let _ = send_segment(po, error, reply_to, cid).await;
1179}
1180
1181async fn send_segment(
1182    po: &PostOffice,
1183    segment: EventEnvelope,
1184    reply_to: &str,
1185    cid: &str,
1186) -> Result<(), AppError> {
1187    po.send(segment.set_to(reply_to).set_correlation_id(cid))
1188        .await
1189}
1190
1191fn raw_url(uri: &str) -> &str {
1192    match uri.rfind('?') {
1193        Some(mark) => &uri[..mark],
1194        None => uri,
1195    }
1196}
1197
1198fn connect_timeout_ms() -> u64 {
1199    let config = AppConfigReader::get_instance();
1200    config
1201        .get_property_or("http.client.connection.timeout", "5000")
1202        .parse::<u64>()
1203        .unwrap_or(5000)
1204        .max(2000)
1205}
1206
1207/// Java `validateUrl`: the target host must be `http(s)://host[:port]` with
1208/// no URI path. Default ports follow the scheme (80 / 443).
1209fn validate_url(request: &AsyncHttpRequest) -> Result<(bool, String, u16), AppError> {
1210    let Some(target) = request.target_host() else {
1211        return Err(AppError::new(
1212            400,
1213            "Missing target host. e.g. https://hostname",
1214        ));
1215    };
1216    let (secure, rest) = if let Some(rest) = target.strip_prefix("http://") {
1217        (false, rest)
1218    } else if let Some(rest) = target.strip_prefix("https://") {
1219        (true, rest)
1220    } else {
1221        return Err(AppError::new(400, "Protocol must be http or https"));
1222    };
1223    let authority = rest.trim_end_matches('/');
1224    if authority.contains('/') {
1225        return Err(AppError::new(400, "Target host must not contain URI path"));
1226    }
1227    let default_port = if secure { 443 } else { 80 };
1228    let (host, port) = match authority.rsplit_once(':') {
1229        Some((h, p)) => (
1230            h.to_string(),
1231            p.parse::<u16>()
1232                .map_err(|_| AppError::new(400, "Invalid port number in target host"))?,
1233        ),
1234        None => (authority.to_string(), default_port),
1235    };
1236    if host.trim().is_empty() {
1237        return Err(AppError::new(
1238            400,
1239            "Unable to resolve target host as domain or IP address",
1240        ));
1241    }
1242    Ok((secure, host, port))
1243}
1244
1245/// TLS client config, built once per verification mode. The strict config
1246/// trusts the OS certificate store (the closest analog of the JDK's default
1247/// truststore that the Java client uses); the trust-all config skips chain
1248/// validation only — TLS signatures are still verified — mirroring Java's
1249/// `InsecureTrustManagerFactory` escape hatch for self-signed endpoints.
1250fn tls_connector(trust_all_cert: bool) -> Result<TlsConnector, AppError> {
1251    static STRICT: OnceLock<Result<Arc<rustls::ClientConfig>, String>> = OnceLock::new();
1252    static TRUST_ALL: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
1253    let config = if trust_all_cert {
1254        TRUST_ALL
1255            .get_or_init(|| {
1256                let config = rustls::ClientConfig::builder()
1257                    .dangerous()
1258                    .with_custom_certificate_verifier(Arc::new(TrustAllVerifier))
1259                    .with_no_client_auth();
1260                Arc::new(config)
1261            })
1262            .clone()
1263    } else {
1264        STRICT
1265            .get_or_init(|| {
1266                let loaded = rustls_native_certs::load_native_certs();
1267                let mut roots = rustls::RootCertStore::empty();
1268                for cert in loaded.certs {
1269                    // tolerate individual unparsable certs (OS stores carry
1270                    // legacy entries); fail only if nothing loads at all
1271                    let _ = roots.add(cert);
1272                }
1273                if roots.is_empty() {
1274                    return Err(format!(
1275                        "No usable certificates in the OS trust store - {:?}",
1276                        loaded.errors
1277                    ));
1278                }
1279                Ok(Arc::new(
1280                    rustls::ClientConfig::builder()
1281                        .with_root_certificates(roots)
1282                        .with_no_client_auth(),
1283                ))
1284            })
1285            .clone()
1286            .map_err(|e| AppError::new(500, e))?
1287    };
1288    Ok(TlsConnector::from(config))
1289}
1290
1291/// Certificate verifier that accepts any server certificate (chain
1292/// validation skipped; handshake signatures still verified) — the rustls
1293/// mirror of Java's `InsecureTrustManagerFactory`.
1294#[derive(Debug)]
1295struct TrustAllVerifier;
1296
1297impl rustls::client::danger::ServerCertVerifier for TrustAllVerifier {
1298    fn verify_server_cert(
1299        &self,
1300        _end_entity: &rustls::pki_types::CertificateDer<'_>,
1301        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
1302        _server_name: &ServerName<'_>,
1303        _ocsp_response: &[u8],
1304        _now: rustls::pki_types::UnixTime,
1305    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
1306        Ok(rustls::client::danger::ServerCertVerified::assertion())
1307    }
1308
1309    fn verify_tls12_signature(
1310        &self,
1311        message: &[u8],
1312        cert: &rustls::pki_types::CertificateDer<'_>,
1313        dss: &rustls::DigitallySignedStruct,
1314    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1315        rustls::crypto::verify_tls12_signature(
1316            message,
1317            cert,
1318            dss,
1319            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
1320        )
1321    }
1322
1323    fn verify_tls13_signature(
1324        &self,
1325        message: &[u8],
1326        cert: &rustls::pki_types::CertificateDer<'_>,
1327        dss: &rustls::DigitallySignedStruct,
1328    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1329        rustls::crypto::verify_tls13_signature(
1330            message,
1331            cert,
1332            dss,
1333            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
1334        )
1335    }
1336
1337    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
1338        rustls::crypto::ring::default_provider()
1339            .signature_verification_algorithms
1340            .supported_schemes()
1341    }
1342}
1343
1344#[allow(clippy::too_many_arguments)]
1345fn apply_headers(
1346    mut builder: hyper::http::request::Builder,
1347    invocation_headers: &HashMap<String, String>,
1348    request: &AsyncHttpRequest,
1349    event: &EventEnvelope,
1350    host: &str,
1351    port: u16,
1352    secure: bool,
1353) -> hyper::http::request::Builder {
1354    // omit the scheme's default port from the host header (80 / 443)
1355    let default_port = if secure { 443 } else { 80 };
1356    let authority = if port == default_port {
1357        host.to_string()
1358    } else {
1359        format!("{host}:{port}")
1360    };
1361    builder = builder.header("host", authority);
1362    builder = builder.header("user-agent", USER_AGENT_NAME);
1363    // request headers (session info becomes headers, Java parity)
1364    let mut merged: Vec<(String, String)> = request.headers.clone();
1365    merged.extend(request.session.iter().cloned());
1366    for (key, value) in &merged {
1367        if permitted_http_header(key) {
1368            // trim optional whitespace (RFC 7230 OWS) — netty strips it on
1369            // write, hyper strictly rejects it, so trimming keeps parity
1370            // (e.g. a token loaded from a file with a trailing newline)
1371            builder = builder.header(key.as_str(), value.trim());
1372        }
1373    }
1374    // best practice (maintainer ruling): send a default 'Accept: */*' when the
1375    // caller gives none — the Java engine's reactor-netty client does this
1376    // implicitly, and the REST edge negotiates the response content-type from
1377    // Accept, so the default keeps JSON decoding identical on both engines
1378    if !merged.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")) {
1379        builder = builder.header("accept", "*/*");
1380    }
1381    // distributed trace propagation: this route is untraced by default
1382    // (skip.rpc.tracing, Java parity), so the trace rides the ENVELOPE and
1383    // the injected invocation headers, not the ambient trace state — exactly
1384    // like Java's PostOffice.trackable(headers).
1385    // The engine's own stamps use INSERT semantics (Java `http.set`): a
1386    // same-named header forwarded from the request object above is replaced,
1387    // never duplicated (append-vs-insert wire hygiene — the Event-over-HTTP
1388    // leg pre-sets the same trace headers on its request).
1389    let config = AppConfigReader::get_instance();
1390    if let Some(trace_id) = event.trace_id() {
1391        let trace_header = config.get_property_or("http.trace.id.header", "X-Trace-Id");
1392        stamp_header(&mut builder, &trace_header, trace_id);
1393        if let Some(traceparent) = w3c_trace::format(trace_id, event.span_id().unwrap_or_default())
1394        {
1395            stamp_header(&mut builder, w3c_trace::TRACEPARENT, &traceparent);
1396            // when a custom traceparent header name is configured
1397            // (http.traceparent.header), stamp the same value under that name
1398            // too, so the W3C trace context survives an intermediary that
1399            // strips the standard header
1400            let custom_traceparent =
1401                config.get_property_or("http.traceparent.header", w3c_trace::TRACEPARENT);
1402            if !custom_traceparent.eq_ignore_ascii_case(w3c_trace::TRACEPARENT) {
1403                stamp_header(&mut builder, &custom_traceparent, &traceparent);
1404            }
1405        }
1406    }
1407    // propagate the business correlation-id (unless the caller set it).
1408    // The engine's own Event-over-HTTP transport leg (the x-event-api client
1409    // instruction) is exempt: the business cid rides INSIDE the envelope
1410    // (my_cid tag) and the HTTP-level header is absent on that path (Java
1411    // parity — its EventEmitter leg carries no ambient business context).
1412    if request.header(super::event_api::X_EVENT_API).is_none() {
1413        if let Some(business_cid) = invocation_headers.get(crate::automation::MY_CORRELATION_ID) {
1414            let cid_header =
1415                config.get_property_or("http.correlation.id.header", "X-Correlation-Id");
1416            if request.header(&cid_header).is_none() {
1417                stamp_header(&mut builder, &cid_header, business_cid.as_str());
1418            }
1419        }
1420    }
1421    // cookies
1422    if !request.cookies.is_empty() {
1423        let cookie = request
1424            .cookies
1425            .iter()
1426            .map(|(k, v)| format!("{k}={}", url_encode(v)))
1427            .collect::<Vec<_>>()
1428            .join("; ");
1429        builder = builder.header("cookie", cookie.as_str());
1430    }
1431    builder
1432}
1433
1434fn permitted_http_header(header: &str) -> bool {
1435    !HEADERS_TO_IGNORE
1436        .iter()
1437        .any(|ignored| header.eq_ignore_ascii_case(ignored))
1438}
1439
1440/// Insert-or-replace an engine-stamped header on the outgoing request (Java
1441/// `http.set` semantics): a same-named header already forwarded from the
1442/// request object is replaced, never duplicated. An invalid name/value is
1443/// dropped silently — the same outcome `Builder::header` would produce as a
1444/// deferred build error, but without failing the whole request.
1445fn stamp_header(builder: &mut hyper::http::request::Builder, name: &str, value: &str) {
1446    if let Some(headers) = builder.headers_mut() {
1447        if let (Ok(name), Ok(value)) = (
1448            hyper::header::HeaderName::from_bytes(name.as_bytes()),
1449            hyper::header::HeaderValue::from_str(value),
1450        ) {
1451            headers.insert(name, value);
1452        }
1453    }
1454}
1455
1456fn url_encode(text: &str) -> String {
1457    let mut out = String::with_capacity(text.len());
1458    for byte in text.bytes() {
1459        match byte {
1460            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'*' => {
1461                out.push(byte as char)
1462            }
1463            b' ' => out.push('+'),
1464            other => out.push_str(&format!("%{other:02X}")),
1465        }
1466    }
1467    out
1468}
1469
1470fn request_body_bytes(request: &AsyncHttpRequest, method: &str) -> Result<Vec<u8>, AppError> {
1471    if !matches!(method, "POST" | "PUT" | "PATCH") {
1472        return Ok(Vec::new());
1473    }
1474    match request.body() {
1475        Value::Nil => Ok(Vec::new()),
1476        Value::Binary(bytes) => Ok(bytes.clone()),
1477        Value::String(text) => Ok(text.as_str().unwrap_or_default().as_bytes().to_vec()),
1478        value @ (Value::Map(_) | Value::Array(_)) => {
1479            // maps and lists serialize as JSON (the Java XML writer path is a
1480            // documented deferral); Nil map entries are omitted unless
1481            // serializer.null.transport=true (Java Gson parity)
1482            let json = serde_json::to_value(crate::serializer::strip_nulls(value))
1483                .map_err(|e| AppError::new(400, format!("Invalid HTTP request body - {e}")))?;
1484            serde_json::to_vec(&json)
1485                .map_err(|e| AppError::new(400, format!("Invalid HTTP request body - {e}")))
1486        }
1487        _ => Err(AppError::new(400, "Invalid HTTP request body")),
1488    }
1489}
1490
1491/// Decode the response body by content type (Java `sendFixedLengthResponse`):
1492/// JSON objects/arrays become maps/lists, text stays text, XML passes
1493/// through as raw text (parser deferral), anything else is bytes.
1494fn decode_response_body(bytes: &[u8], content_type: Option<&str>) -> Value {
1495    let Some(content_type) = content_type else {
1496        return Value::from(bytes.to_vec());
1497    };
1498    if content_type.starts_with("application/json") {
1499        let text = String::from_utf8_lossy(bytes).trim().to_string();
1500        if text.is_empty() {
1501            return Value::Map(vec![]);
1502        }
1503        if (text.starts_with('{') && text.ends_with('}'))
1504            || (text.starts_with('[') && text.ends_with(']'))
1505        {
1506            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
1507                if let Ok(value) = rmpv::ext::to_value(&json) {
1508                    return value;
1509                }
1510            }
1511        }
1512        return Value::from(text);
1513    }
1514    if content_type.starts_with("text/")
1515        || content_type.starts_with("application/javascript")
1516        || content_type.starts_with("application/xml")
1517    {
1518        return Value::from(String::from_utf8_lossy(bytes).to_string());
1519    }
1520    Value::from(bytes.to_vec())
1521}
1522
1523/// The registered service wrapper (Java `AsyncHttpClient` implements
1524/// `TypedLambdaFunction`; registered by the app starter at 500 instances).
1525/// Holds the platform it is registered on so its manual replies route to
1526/// that platform's `temporary.inbox` reply listener.
1527pub struct AsyncHttpClientService {
1528    platform: Platform,
1529}
1530
1531impl AsyncHttpClientService {
1532    pub fn new(platform: &Platform) -> Self {
1533        AsyncHttpClientService {
1534            platform: platform.clone(),
1535        }
1536    }
1537}
1538
1539#[async_trait::async_trait]
1540impl crate::function::ComposableFunction for AsyncHttpClientService {
1541    async fn handle_event(
1542        &self,
1543        headers: HashMap<String, String>,
1544        input: EventEnvelope,
1545        _instance: usize,
1546    ) -> Result<EventEnvelope, AppError> {
1547        // reply through the platform this service was registered on — its
1548        // manual reply must reach THAT platform's temporary.inbox listener
1549        // (the global instance may live on another runtime in tests)
1550        handle(&self.platform, headers, input).await
1551    }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556    use super::*;
1557
1558    /// The server-side request dataset, FLUENT-BUILT (the maintainer's
1559    /// requirement: the test setup creates a new AsyncHttpRequest and sets
1560    /// every value through the fluent API — the test doubles as living
1561    /// documentation of the builder surface, exercising every setter).
1562    fn server_dataset_request() -> AsyncHttpRequest {
1563        AsyncHttpRequest::new()
1564            .set_method("POST")
1565            .set_url("/api/typed/alice")
1566            .set_remote_ip("127.0.0.1")
1567            .set_secure(true)
1568            .set_target_host("localhost:8085")
1569            .set_header("accept", "application/json")
1570            .set_header("x-api-key", "open-sesame")
1571            .set_header("x-ttl", "5000")
1572            .set_path_parameter("user", "alice")
1573            .set_query_parameter_values("q", &["a", "b"])
1574            .set_query_parameter("single", "1")
1575            .set_query_string("q=a&q=b&single=1")
1576            .set_route_timeout_seconds(30)
1577            .set_cookie("first", "alpha")
1578            .set_session_info("user_id", "u-1")
1579            .set_body(Value::Map(vec![(
1580                Value::from("note"),
1581                Value::from("hello"),
1582            )]))
1583    }
1584
1585    /// Round-trip integrity of the typed contract: every fluent-set field is
1586    /// visible through its accessor, survives to_value → from_value, and the
1587    /// serde impls delegate to exactly that pair.
1588    ///
1589    /// Division of labor: this fluent-built round-trip proves INTERNAL
1590    /// consistency (builder ↔ accessors ↔ map shape ↔ serde). The REAL
1591    /// server-shape contract is pinned by the end-to-end test
1592    /// `typed_async_http_request_function_serves_a_real_request` in
1593    /// tests/rest_automation.rs (a typed function behind /api/typed/{user}
1594    /// over the live automation server) — do not "simplify" that e2e away
1595    /// in favor of this unit test.
1596    #[test]
1597    fn server_dataset_round_trips_through_the_typed_contract() {
1598        let request = server_dataset_request();
1599        // every field is visible through the typed accessors
1600        assert_eq!(request.method(), "POST");
1601        assert_eq!(request.url(), "/api/typed/alice");
1602        assert_eq!(request.remote_ip(), Some("127.0.0.1"));
1603        assert!(request.is_secure());
1604        assert_eq!(request.target_host(), Some("localhost:8085"));
1605        assert_eq!(request.path_parameter("user"), Some("alice"));
1606        assert_eq!(request.query_parameter("single").as_deref(), Some("1"));
1607        assert_eq!(request.query_parameter("q").as_deref(), Some("a"));
1608        assert_eq!(request.query_parameters("q"), vec!["a", "b"]);
1609        assert_eq!(request.query_parameters("single"), vec!["1"]);
1610        assert_eq!(request.query_string(), Some("q=a&q=b&single=1"));
1611        assert_eq!(
1612            request.header("Accept"),
1613            Some("application/json"),
1614            "case-insensitive"
1615        );
1616        assert_eq!(request.cookie("first"), Some("alpha"));
1617        assert_eq!(request.session_info("user_id"), Some("u-1"));
1618        // caller-sent x-ttl (5000 ms -> 5 s) wins over the route timeout (30)
1619        assert_eq!(request.timeout_seconds(), 5);
1620        #[derive(serde::Deserialize)]
1621        struct Note {
1622            note: String,
1623        }
1624        assert_eq!(request.body_as::<Note>().expect("typed body").note, "hello");
1625        // to_value emits what from_value parses: a second pass is identical
1626        let round = AsyncHttpRequest::from_value(&request.to_value());
1627        assert_eq!(round.to_value(), request.to_value());
1628        // the serde impls are thin delegates onto the same pair
1629        let deserialized: AsyncHttpRequest =
1630            rmpv::ext::from_value(request.to_value()).expect("serde deserialize");
1631        assert_eq!(deserialized.to_value(), request.to_value());
1632        let serialized = rmpv::ext::to_value(&request).expect("serde serialize");
1633        assert_eq!(
1634            AsyncHttpRequest::from_value(&serialized).to_value(),
1635            request.to_value()
1636        );
1637    }
1638
1639    /// The route timeout (`set_route_timeout_seconds` / the REST edge's
1640    /// "timeout" dataset key) is the fallback when no x-ttl header rides
1641    /// the request.
1642    #[test]
1643    fn route_timeout_is_the_fallback_without_x_ttl() {
1644        let request = AsyncHttpRequest::new()
1645            .set_method("GET")
1646            .set_url("/x")
1647            .set_route_timeout_seconds(30);
1648        assert_eq!(request.timeout_seconds(), 30);
1649        // and it round-trips through the map shape
1650        assert_eq!(
1651            AsyncHttpRequest::from_value(&request.to_value()).timeout_seconds(),
1652            30
1653        );
1654    }
1655}