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    /// the client execution's own trace - the relay task outlives the worker
657    trace: Option<RelayTrace>,
658}
659
660/// The client execution's own trace, captured on the worker thread (the relay
661/// task outlives it): the head, eof and exception segments a relay synthesizes
662/// ride it, so the caller's reply-lane records for them parent onto this client
663/// leg. Decoded envelope frames keep the producer's own span, and raw data
664/// frames carry no trace at all - a stream is traced at its head and its tail,
665/// never per token (Java AsyncHttpClient parity).
666#[derive(Clone)]
667struct RelayTrace {
668    trace_id: String,
669    trace_path: String,
670    span_id: Option<String>,
671}
672
673impl RelayTrace {
674    fn capture(po: &PostOffice) -> Option<Self> {
675        Some(RelayTrace {
676            trace_id: po.my_trace_id()?,
677            trace_path: po.my_trace_path()?,
678            span_id: po.my_span_id(),
679        })
680    }
681
682    /// Stamp the trace (and the client leg's span) onto a synthesized segment
683    fn stamp(trace: &Option<RelayTrace>, segment: EventEnvelope) -> EventEnvelope {
684        match trace {
685            Some(t) => {
686                let segment = segment.set_trace(&t.trace_id, &t.trace_path);
687                match &t.span_id {
688                    Some(span) => segment.set_span_id(span),
689                    None => segment,
690                }
691            }
692            None => segment,
693        }
694    }
695}
696
697pub(crate) async fn handle(
698    platform: &Platform,
699    _headers: HashMap<String, String>,
700    event: EventEnvelope,
701) -> Result<EventEnvelope, AppError> {
702    let po = PostOffice::new(platform);
703    let Some(reply_to) = event.reply_to().map(str::to_string) else {
704        // fire-and-forget: errors are logged, successes discarded (Java parity)
705        if let Err(e) = process_request(platform, &po, &_headers, &event, None).await {
706            log::error!("Unhandled exception (no reply-to) - {}", e.message());
707        }
708        return EventEnvelope::new().set_body("ignored");
709    };
710    let cid = event.correlation_id().unwrap_or_default().to_string();
711    let envelope_mode = event.headers().iter().any(|(name, value)| {
712        name.eq_ignore_ascii_case(super::event_api::X_EVENT_API) && value == STREAM_RELAY
713    });
714    let stream_target = Some(StreamTarget {
715        reply_to: reply_to.clone(),
716        cid: cid.clone(),
717        envelope_mode,
718        trace: RelayTrace::capture(&po),
719    });
720    let response = match process_request(platform, &po, &_headers, &event, stream_target).await {
721        // a progressive SSE relay was spawned - it owns the reply route now
722        Ok(None) => return EventEnvelope::new().set_body("ignored"),
723        Ok(Some(response)) => response,
724        Err(e) => EventEnvelope::new()
725            .set_status(e.status())
726            .set_raw_body(Value::from(e.message())),
727    };
728    let _ = po
729        .send(response.set_to(&reply_to).set_correlation_id(&cid))
730        .await;
731    EventEnvelope::new().set_body("ignored")
732}
733
734/// A request is a progressive-SSE candidate when the caller explicitly accepts
735/// text/event-stream AND supplied a reply_to (a multi-shot-capable consumer) -
736/// everything else keeps the buffered single-shot behavior (D1).
737fn accepts_event_stream(request: &AsyncHttpRequest) -> bool {
738    request.headers().iter().any(|(name, value)| {
739        name.eq_ignore_ascii_case("accept") && value.contains("text/event-stream")
740    })
741}
742
743async fn process_request(
744    platform: &Platform,
745    po: &PostOffice,
746    invocation_headers: &HashMap<String, String>,
747    event: &EventEnvelope,
748    stream_target: Option<StreamTarget>,
749) -> Result<Option<EventEnvelope>, AppError> {
750    let request = AsyncHttpRequest::from_value(event.body());
751    let (secure, host, port) = validate_url(&request)?;
752    let uri = request.finalized_url();
753    po.annotate_trace(
754        "destination",
755        format!(
756            "{}{}",
757            request.target_host().unwrap_or_default(),
758            raw_url(&uri)
759        ),
760    );
761    let method = request.method().to_string();
762    if !matches!(
763        method.as_str(),
764        "GET" | "HEAD" | "PUT" | "POST" | "PATCH" | "DELETE" | "OPTIONS"
765    ) {
766        return Err(AppError::new(405, "Method not allowed"));
767    }
768    // connect with the configured timeout (Java http.client.connection.timeout)
769    let connect_ms = connect_timeout_ms();
770    let stream = tokio::time::timeout(
771        Duration::from_millis(connect_ms),
772        tokio::net::TcpStream::connect((host.as_str(), port)),
773    )
774    .await
775    .map_err(|_| AppError::new(408, format!("Connection timeout for {host}:{port}")))?
776    .map_err(|e| AppError::new(500, format!("Unable to connect to {host}:{port} - {e}")))?;
777    // http and https produce different stream types but the same SendRequest
778    let mut sender = if secure {
779        let connector = tls_connector(request.trust_all_cert())?;
780        let server_name = ServerName::try_from(host.clone())
781            .map_err(|e| AppError::new(400, format!("Invalid TLS server name {host} - {e}")))?;
782        let tls_stream = connector
783            .connect(server_name, stream)
784            .await
785            .map_err(|e| AppError::new(500, format!("TLS handshake failed for {host} - {e}")))?;
786        let io = hyper_util::rt::TokioIo::new(tls_stream);
787        let (sender, connection) = hyper::client::conn::http1::handshake(io)
788            .await
789            .map_err(|e| AppError::new(500, format!("HTTP handshake failed - {e}")))?;
790        tokio::spawn(async move {
791            let _ = connection.await;
792        });
793        sender
794    } else {
795        let io = hyper_util::rt::TokioIo::new(stream);
796        let (sender, connection) = hyper::client::conn::http1::handshake(io)
797            .await
798            .map_err(|e| AppError::new(500, format!("HTTP handshake failed - {e}")))?;
799        tokio::spawn(async move {
800            let _ = connection.await;
801        });
802        sender
803    };
804    let mut builder = hyper::Request::builder()
805        .method(method.as_str())
806        .uri(if uri.is_empty() { "/" } else { &uri });
807    builder = apply_headers(
808        builder,
809        invocation_headers,
810        &request,
811        event,
812        &host,
813        port,
814        secure,
815    );
816    let body_bytes = request_body_bytes(&request, &method)?;
817    let http_request = builder
818        .body(Full::new(Bytes::from(body_bytes)))
819        .map_err(|e| AppError::new(400, format!("Invalid HTTP request - {e}")))?;
820    // per-request response timeout (the x-ttl header, default 30s) with one
821    // extra second of wire-level grace so a peer that spends its whole TTL
822    // and replies AT the deadline is still readable; the caller's own RPC
823    // timeout, not this read timeout, governs the user-visible deadline
824    // (Java parity: AsyncHttpClient responseTimeout = getTimeoutSeconds() + 1)
825    let ttl = Duration::from_secs(request.timeout_seconds() + 1);
826    let http_response = tokio::time::timeout(ttl, sender.send_request(http_request))
827        .await
828        .map_err(|_| AppError::new(408, format!("Timeout for {} ms", ttl.as_millis())))?
829        .map_err(|e| AppError::new(500, format!("HTTP request failed - {e}")))?;
830    let mut response = EventEnvelope::new().set_status(http_response.status().as_u16() as i32);
831    let mut content_type: Option<String> = None;
832    let mut has_content_length = false;
833    for (name, value) in http_response.headers() {
834        let key = name.as_str();
835        let text = value.to_str().unwrap_or_default();
836        if key.eq_ignore_ascii_case("content-type") {
837            content_type = Some(text.to_lowercase());
838        }
839        if key.eq_ignore_ascii_case("content-length") {
840            has_content_length = true;
841        }
842        response = response.set_header(key, text);
843    }
844    // progressive SSE consumption (D1): the caller opted in with Accept AND the
845    // upstream actually answers text/event-stream - relay each SSE event as one
846    // x-event-stream data envelope to the caller's reply route (the producer
847    // contract the HTTP edge consumes), then eof. The relay runs in its own task
848    // so this worker is freed - a long stream never holds a client instance.
849    let envelope_mode = stream_target.as_ref().is_some_and(|t| t.envelope_mode);
850    if let Some(target) = stream_target {
851        let sse = content_type
852            .as_deref()
853            .is_some_and(|ct| ct.starts_with("text/event-stream"));
854        if sse && accepts_event_stream(&request) {
855            let status = http_response.status().as_u16() as i32;
856            let relay_platform = platform.clone();
857            if target.envelope_mode {
858                // one extra second so the peer's in-band 408, sent AT the
859                // deadline, wins the race against the local idle timer
860                let idle = Duration::from_secs(request.timeout_seconds().max(1) + 1);
861                tokio::spawn(relay_envelope_sse(
862                    relay_platform,
863                    http_response.into_body(),
864                    target.reply_to,
865                    target.cid,
866                    idle,
867                    target.trace,
868                ));
869            } else {
870                let idle = Duration::from_secs(request.timeout_seconds().max(1));
871                tokio::spawn(relay_sse(
872                    relay_platform,
873                    http_response.into_body(),
874                    status,
875                    target.reply_to,
876                    target.cid,
877                    idle,
878                    target.trace,
879                ));
880            }
881            return Ok(None);
882        }
883    }
884    let bytes = http_response
885        .into_body()
886        .collect()
887        .await
888        .map_err(|e| AppError::new(500, format!("Unable to read HTTP response - {e}")))?
889        .to_bytes();
890    if envelope_mode {
891        // the peer answered single-shot (a non-streaming target, or an edge
892        // error) - decode and deliver with the classic callback semantics
893        return Ok(Some(decode_relay_reply(&bytes, response.status())));
894    }
895    if !has_content_length {
896        response = response.set_header("x-content-length", &bytes.len().to_string());
897    }
898    Ok(Some(response.set_raw_body(decode_response_body(
899        &bytes,
900        content_type.as_deref(),
901    ))))
902}
903
904/// Decode a single-shot Event-over-HTTP reply: a serialized envelope normally,
905/// with the classic tolerant handling of an edge-level REST error body
906/// (`'{"type": "error", "status": n, "message": text}'` JSON) and of a payload
907/// that is not a serialized envelope at all.
908fn decode_relay_reply(bytes: &[u8], http_status: i32) -> EventEnvelope {
909    if bytes.is_empty() {
910        return EventEnvelope::new().set_status(http_status);
911    }
912    match EventEnvelope::from_bytes(bytes) {
913        Ok(envelope) => envelope.clear_reply_to(),
914        Err(e) => rest_error_reply(bytes, http_status).unwrap_or_else(|| {
915            EventEnvelope::new()
916                .set_status(400)
917                .set_raw_body(Value::from(format!(
918                    "Did you configure rest.yaml correctly? Invalid result set - {}",
919                    e.message()
920                )))
921        }),
922    }
923}
924
925/// An edge-level REST error arrives as JSON, not as a serialized envelope -
926/// unwrap it exactly as the classic relay does.
927fn rest_error_reply(bytes: &[u8], http_status: i32) -> Option<EventEnvelope> {
928    if http_status < 400 {
929        return None;
930    }
931    let data = serde_json::from_slice::<serde_json::Value>(bytes).ok()?;
932    if data.get("type").and_then(|v| v.as_str()) != Some("error") {
933        return None;
934    }
935    let message = data.get("message").and_then(|v| v.as_str())?;
936    Some(
937        EventEnvelope::new()
938            .set_status(http_status)
939            .set_raw_body(Value::from(message)),
940    )
941}
942
943/// Incremental SSE frame parser (raw mode): byte-level line split (a newline
944/// is a single byte, so this is UTF-8 safe), one-leading-space value strip,
945/// comment/id/retry suppression, multi-line data joined per the SSE
946/// specification. Mirrors the Java SseRelay parser.
947#[derive(Default)]
948struct SseParser {
949    pending: Vec<u8>,
950    data_lines: Vec<String>,
951    event_name: Option<String>,
952}
953
954impl SseParser {
955    /// Feed one body chunk; return the completed (event_name, data) events.
956    fn feed(&mut self, chunk: &[u8]) -> Vec<(Option<String>, String)> {
957        self.pending.extend_from_slice(chunk);
958        let mut events = Vec::new();
959        let mut start = 0;
960        let buffer = std::mem::take(&mut self.pending);
961        for i in 0..buffer.len() {
962            if buffer[i] == b'\n' {
963                let end = if i > start && buffer[i - 1] == b'\r' {
964                    i - 1
965                } else {
966                    i
967                };
968                let line = String::from_utf8_lossy(&buffer[start..end]).to_string();
969                start = i + 1;
970                if line.is_empty() {
971                    // blank line dispatches the pending event (SSE specification)
972                    if !self.data_lines.is_empty() {
973                        events.push((self.event_name.take(), self.data_lines.join("\n")));
974                    }
975                    self.data_lines.clear();
976                    self.event_name = None;
977                } else if !line.starts_with(':') {
978                    // a comment line (leading colon) is consumed, never forwarded
979                    let (field, value) = match line.find(':') {
980                        Some(colon) => (&line[..colon], &line[colon + 1..]),
981                        None => (line.as_str(), ""),
982                    };
983                    let value = value.strip_prefix(' ').unwrap_or(value);
984                    match field {
985                        "data" => self.data_lines.push(value.to_string()),
986                        "event" => self.event_name = Some(value.to_string()),
987                        _ => { /* id, retry and unknown fields are ignored */ }
988                    }
989                }
990            }
991        }
992        self.pending = buffer[start..].to_vec();
993        events
994    }
995}
996
997/// The spawned relay of a progressive SSE response: one x-event-stream data
998/// envelope per upstream event to the caller's reply route (head control on
999/// the first), eof on a clean end, in-band exception on idle expiry or a
1000/// mid-stream transport error. The per-read idle allowance is the request
1001/// TTL - any upstream bytes, keep-alive comments included, reset it (D4).
1002#[allow(clippy::too_many_arguments)]
1003async fn relay_sse(
1004    platform: Platform,
1005    mut body: hyper::body::Incoming,
1006    status: i32,
1007    reply_to: String,
1008    cid: String,
1009    idle: Duration,
1010    trace: Option<RelayTrace>,
1011) {
1012    let po = PostOffice::new(&platform);
1013    let mut parser = SseParser::default();
1014    let mut head_sent = false;
1015    loop {
1016        match tokio::time::timeout(idle, body.frame()).await {
1017            Ok(Some(Ok(frame))) => {
1018                if let Some(data) = frame.data_ref() {
1019                    for (name, text) in parser.feed(data) {
1020                        let mut segment = EventEnvelope::new()
1021                            .set_header(event_stream::X_EVENT_STREAM, event_stream::DATA);
1022                        if let Some(name) = name.filter(|n| !n.is_empty()) {
1023                            segment = segment.set_header(event_stream::X_EVENT_NAME, &name);
1024                        }
1025                        segment = match segment.set_body(text) {
1026                            Ok(seg) => seg,
1027                            Err(_) => continue,
1028                        };
1029                        if !head_sent {
1030                            head_sent = true;
1031                            // head control rides the first envelope: upstream
1032                            // status + the SSE content type - and the trace:
1033                            // a stream is traced at its head and its tail, so
1034                            // only this segment and the terminal carry it
1035                            segment = RelayTrace::stamp(
1036                                &trace,
1037                                segment
1038                                    .set_status(status)
1039                                    .set_header("content-type", "text/event-stream"),
1040                            );
1041                        }
1042                        if send_segment(&po, segment, &reply_to, &cid).await.is_err() {
1043                            return;
1044                        }
1045                    }
1046                }
1047            }
1048            Ok(None) => {
1049                // clean end of transmission - an incomplete trailing event is
1050                // discarded (SSE specification)
1051                let mut eof = EventEnvelope::new()
1052                    .set_header(event_stream::X_EVENT_STREAM, event_stream::EOF);
1053                if !head_sent {
1054                    eof = eof
1055                        .set_status(status)
1056                        .set_header("content-type", "text/event-stream");
1057                }
1058                let _ = send_segment(&po, RelayTrace::stamp(&trace, eof), &reply_to, &cid).await;
1059                return;
1060            }
1061            Ok(Some(Err(e))) => {
1062                fail_in_band(&po, &reply_to, &cid, 500, &e.to_string(), head_sent, &trace).await;
1063                return;
1064            }
1065            Err(_) => {
1066                // idle expiry - the connection closes when the body is dropped
1067                let message = format!("Timeout for {} seconds", idle.as_secs());
1068                fail_in_band(&po, &reply_to, &cid, 408, &message, head_sent, &trace).await;
1069                return;
1070            }
1071        }
1072    }
1073}
1074
1075/// The spawned relay of an Event-over-HTTP streaming response (envelope mode):
1076/// an "envelope" frame carries one base64-encoded serialized EventEnvelope -
1077/// the head, the terminals and non-text segments; any other frame is a raw
1078/// text segment. Decoded events forward to the original caller's reply route
1079/// with the original correlation id; a decoded terminal (eof or exception)
1080/// ends the logical stream and trailing frames are discarded. Dialect guards:
1081/// the first frame must be an envelope frame, and a transport end without a
1082/// decoded terminal is a truncation - both fail in-band.
1083async fn relay_envelope_sse(
1084    platform: Platform,
1085    mut body: hyper::body::Incoming,
1086    reply_to: String,
1087    cid: String,
1088    idle: Duration,
1089    trace: Option<RelayTrace>,
1090) {
1091    let po = PostOffice::new(&platform);
1092    let mut parser = SseParser::default();
1093    let mut head_seen = false;
1094    loop {
1095        match tokio::time::timeout(idle, body.frame()).await {
1096            Ok(Some(Ok(frame))) => {
1097                if let Some(data) = frame.data_ref() {
1098                    for (name, text) in parser.feed(data) {
1099                        match relay_envelope_event(
1100                            &po,
1101                            name,
1102                            text,
1103                            &reply_to,
1104                            &cid,
1105                            &mut head_seen,
1106                            &trace,
1107                        )
1108                        .await
1109                        {
1110                            RelayFlow::Next => {}
1111                            // a decoded terminal ends the logical stream -
1112                            // frames after it (and the transport end) are
1113                            // discarded by returning here
1114                            RelayFlow::End => return,
1115                        }
1116                    }
1117                }
1118            }
1119            Ok(None) => {
1120                // the dialect ends with a decoded terminal - a bare transport
1121                // end is a truncation
1122                fail_in_band(
1123                    &po,
1124                    &reply_to,
1125                    &cid,
1126                    500,
1127                    "Event stream ended without eof",
1128                    head_seen,
1129                    &trace,
1130                )
1131                .await;
1132                return;
1133            }
1134            Ok(Some(Err(e))) => {
1135                fail_in_band(&po, &reply_to, &cid, 500, &e.to_string(), head_seen, &trace).await;
1136                return;
1137            }
1138            Err(_) => {
1139                let message = format!("Timeout for {} seconds", idle.as_secs());
1140                fail_in_band(&po, &reply_to, &cid, 408, &message, head_seen, &trace).await;
1141                return;
1142            }
1143        }
1144    }
1145}
1146
1147/// What one envelope-mode frame did to the relay.
1148enum RelayFlow {
1149    Next,
1150    End,
1151}
1152
1153async fn relay_envelope_event(
1154    po: &PostOffice,
1155    name: Option<String>,
1156    text: String,
1157    reply_to: &str,
1158    cid: &str,
1159    head_seen: &mut bool,
1160    trace: &Option<RelayTrace>,
1161) -> RelayFlow {
1162    use base64::Engine as _;
1163    if name.as_deref() == Some(event_stream::ENVELOPE) {
1164        let decoded = base64::engine::general_purpose::STANDARD
1165            .decode(&text)
1166            .ok()
1167            .and_then(|bytes| EventEnvelope::from_bytes(&bytes).ok());
1168        let Some(decoded) = decoded else {
1169            fail_in_band(
1170                po,
1171                reply_to,
1172                cid,
1173                500,
1174                "Invalid event stream - malformed envelope frame",
1175                *head_seen,
1176                trace,
1177            )
1178            .await;
1179            return RelayFlow::End;
1180        };
1181        *head_seen = true;
1182        let terminal = decoded.headers().iter().any(|(key, value)| {
1183            key.eq_ignore_ascii_case(event_stream::X_EVENT_STREAM)
1184                && (value.eq_ignore_ascii_case(event_stream::EOF)
1185                    || value.eq_ignore_ascii_case(event_stream::EXCEPTION))
1186        });
1187        let _ = send_segment(po, decoded.clear_reply_to(), reply_to, cid).await;
1188        if terminal {
1189            RelayFlow::End
1190        } else {
1191            RelayFlow::Next
1192        }
1193    } else if !*head_seen {
1194        // the dialect guarantees an envelope frame first (conformance guard)
1195        fail_in_band(
1196            po,
1197            reply_to,
1198            cid,
1199            500,
1200            "Invalid event stream - missing envelope head",
1201            false,
1202            trace,
1203        )
1204        .await;
1205        RelayFlow::End
1206    } else {
1207        // a raw frame is one plain text segment
1208        let mut segment =
1209            EventEnvelope::new().set_header(event_stream::X_EVENT_STREAM, event_stream::DATA);
1210        if let Some(name) = name.filter(|n| !n.is_empty()) {
1211            segment = segment.set_header(event_stream::X_EVENT_NAME, &name);
1212        }
1213        if let Ok(segment) = segment.set_body(text) {
1214            let _ = send_segment(po, segment, reply_to, cid).await;
1215        }
1216        RelayFlow::Next
1217    }
1218}
1219
1220async fn fail_in_band(
1221    po: &PostOffice,
1222    reply_to: &str,
1223    cid: &str,
1224    status: i32,
1225    message: &str,
1226    head_sent: bool,
1227    trace: &Option<RelayTrace>,
1228) {
1229    // the standard error key-values: '{"type": "error", "status": n, "message": text}'
1230    let body = serde_json::json!({"type": "error", "status": status, "message": message});
1231    let Ok(mut error) = EventEnvelope::new()
1232        .set_header(event_stream::X_EVENT_STREAM, event_stream::EXCEPTION)
1233        .set_status(status)
1234        .set_body(body)
1235    else {
1236        return;
1237    };
1238    if !head_sent {
1239        error = error.set_header("content-type", "text/event-stream");
1240    }
1241    // a synthesized terminal parents onto this client leg's own span
1242    let _ = send_segment(po, RelayTrace::stamp(trace, error), reply_to, cid).await;
1243}
1244
1245async fn send_segment(
1246    po: &PostOffice,
1247    segment: EventEnvelope,
1248    reply_to: &str,
1249    cid: &str,
1250) -> Result<(), AppError> {
1251    po.send(segment.set_to(reply_to).set_correlation_id(cid))
1252        .await
1253}
1254
1255fn raw_url(uri: &str) -> &str {
1256    match uri.rfind('?') {
1257        Some(mark) => &uri[..mark],
1258        None => uri,
1259    }
1260}
1261
1262fn connect_timeout_ms() -> u64 {
1263    let config = AppConfigReader::get_instance();
1264    config
1265        .get_property_or("http.client.connection.timeout", "5000")
1266        .parse::<u64>()
1267        .unwrap_or(5000)
1268        .max(2000)
1269}
1270
1271/// Java `validateUrl`: the target host must be `http(s)://host[:port]` with
1272/// no URI path. Default ports follow the scheme (80 / 443).
1273fn validate_url(request: &AsyncHttpRequest) -> Result<(bool, String, u16), AppError> {
1274    let Some(target) = request.target_host() else {
1275        return Err(AppError::new(
1276            400,
1277            "Missing target host. e.g. https://hostname",
1278        ));
1279    };
1280    let (secure, rest) = if let Some(rest) = target.strip_prefix("http://") {
1281        (false, rest)
1282    } else if let Some(rest) = target.strip_prefix("https://") {
1283        (true, rest)
1284    } else {
1285        return Err(AppError::new(400, "Protocol must be http or https"));
1286    };
1287    let authority = rest.trim_end_matches('/');
1288    if authority.contains('/') {
1289        return Err(AppError::new(400, "Target host must not contain URI path"));
1290    }
1291    let default_port = if secure { 443 } else { 80 };
1292    let (host, port) = match authority.rsplit_once(':') {
1293        Some((h, p)) => (
1294            h.to_string(),
1295            p.parse::<u16>()
1296                .map_err(|_| AppError::new(400, "Invalid port number in target host"))?,
1297        ),
1298        None => (authority.to_string(), default_port),
1299    };
1300    if host.trim().is_empty() {
1301        return Err(AppError::new(
1302            400,
1303            "Unable to resolve target host as domain or IP address",
1304        ));
1305    }
1306    Ok((secure, host, port))
1307}
1308
1309/// TLS client config, built once per verification mode. The strict config
1310/// trusts the OS certificate store (the closest analog of the JDK's default
1311/// truststore that the Java client uses); the trust-all config skips chain
1312/// validation only — TLS signatures are still verified — mirroring Java's
1313/// `InsecureTrustManagerFactory` escape hatch for self-signed endpoints.
1314fn tls_connector(trust_all_cert: bool) -> Result<TlsConnector, AppError> {
1315    static STRICT: OnceLock<Result<Arc<rustls::ClientConfig>, String>> = OnceLock::new();
1316    static TRUST_ALL: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
1317    let config = if trust_all_cert {
1318        TRUST_ALL
1319            .get_or_init(|| {
1320                let config = rustls::ClientConfig::builder()
1321                    .dangerous()
1322                    .with_custom_certificate_verifier(Arc::new(TrustAllVerifier))
1323                    .with_no_client_auth();
1324                Arc::new(config)
1325            })
1326            .clone()
1327    } else {
1328        STRICT
1329            .get_or_init(|| {
1330                let loaded = rustls_native_certs::load_native_certs();
1331                let mut roots = rustls::RootCertStore::empty();
1332                for cert in loaded.certs {
1333                    // tolerate individual unparsable certs (OS stores carry
1334                    // legacy entries); fail only if nothing loads at all
1335                    let _ = roots.add(cert);
1336                }
1337                if roots.is_empty() {
1338                    return Err(format!(
1339                        "No usable certificates in the OS trust store - {:?}",
1340                        loaded.errors
1341                    ));
1342                }
1343                Ok(Arc::new(
1344                    rustls::ClientConfig::builder()
1345                        .with_root_certificates(roots)
1346                        .with_no_client_auth(),
1347                ))
1348            })
1349            .clone()
1350            .map_err(|e| AppError::new(500, e))?
1351    };
1352    Ok(TlsConnector::from(config))
1353}
1354
1355/// Certificate verifier that accepts any server certificate (chain
1356/// validation skipped; handshake signatures still verified) — the rustls
1357/// mirror of Java's `InsecureTrustManagerFactory`.
1358#[derive(Debug)]
1359struct TrustAllVerifier;
1360
1361impl rustls::client::danger::ServerCertVerifier for TrustAllVerifier {
1362    fn verify_server_cert(
1363        &self,
1364        _end_entity: &rustls::pki_types::CertificateDer<'_>,
1365        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
1366        _server_name: &ServerName<'_>,
1367        _ocsp_response: &[u8],
1368        _now: rustls::pki_types::UnixTime,
1369    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
1370        Ok(rustls::client::danger::ServerCertVerified::assertion())
1371    }
1372
1373    fn verify_tls12_signature(
1374        &self,
1375        message: &[u8],
1376        cert: &rustls::pki_types::CertificateDer<'_>,
1377        dss: &rustls::DigitallySignedStruct,
1378    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1379        rustls::crypto::verify_tls12_signature(
1380            message,
1381            cert,
1382            dss,
1383            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
1384        )
1385    }
1386
1387    fn verify_tls13_signature(
1388        &self,
1389        message: &[u8],
1390        cert: &rustls::pki_types::CertificateDer<'_>,
1391        dss: &rustls::DigitallySignedStruct,
1392    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1393        rustls::crypto::verify_tls13_signature(
1394            message,
1395            cert,
1396            dss,
1397            &rustls::crypto::ring::default_provider().signature_verification_algorithms,
1398        )
1399    }
1400
1401    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
1402        rustls::crypto::ring::default_provider()
1403            .signature_verification_algorithms
1404            .supported_schemes()
1405    }
1406}
1407
1408#[allow(clippy::too_many_arguments)]
1409fn apply_headers(
1410    mut builder: hyper::http::request::Builder,
1411    invocation_headers: &HashMap<String, String>,
1412    request: &AsyncHttpRequest,
1413    event: &EventEnvelope,
1414    host: &str,
1415    port: u16,
1416    secure: bool,
1417) -> hyper::http::request::Builder {
1418    // omit the scheme's default port from the host header (80 / 443)
1419    let default_port = if secure { 443 } else { 80 };
1420    let authority = if port == default_port {
1421        host.to_string()
1422    } else {
1423        format!("{host}:{port}")
1424    };
1425    builder = builder.header("host", authority);
1426    builder = builder.header("user-agent", USER_AGENT_NAME);
1427    // request headers (session info becomes headers, Java parity)
1428    let mut merged: Vec<(String, String)> = request.headers.clone();
1429    merged.extend(request.session.iter().cloned());
1430    for (key, value) in &merged {
1431        if permitted_http_header(key) {
1432            // trim optional whitespace (RFC 7230 OWS) — netty strips it on
1433            // write, hyper strictly rejects it, so trimming keeps parity
1434            // (e.g. a token loaded from a file with a trailing newline)
1435            builder = builder.header(key.as_str(), value.trim());
1436        }
1437    }
1438    // best practice (maintainer ruling): send a default 'Accept: */*' when the
1439    // caller gives none — the Java engine's reactor-netty client does this
1440    // implicitly, and the REST edge negotiates the response content-type from
1441    // Accept, so the default keeps JSON decoding identical on both engines
1442    if !merged.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")) {
1443        builder = builder.header("accept", "*/*");
1444    }
1445    // distributed trace propagation: the trace rides the ENVELOPE and the
1446    // injected invocation headers, not the ambient trace state — exactly like
1447    // Java's PostOffice.trackable(headers). (An RPC-served execution of this
1448    // route is folded into the caller's record and skip.rpc.tracing suppresses
1449    // that round_trip record; a callback-mode execution - the stream relay's
1450    // client leg - records its own span, parented onto the sender.)
1451    // The engine's own stamps use INSERT semantics (Java `http.set`): a
1452    // same-named header forwarded from the request object above is replaced,
1453    // never duplicated (append-vs-insert wire hygiene — the Event-over-HTTP
1454    // leg pre-sets the same trace headers on its request).
1455    let config = AppConfigReader::get_instance();
1456    if let Some(trace_id) = event.trace_id() {
1457        let trace_header = config.get_property_or("http.trace.id.header", "X-Trace-Id");
1458        stamp_header(&mut builder, &trace_header, trace_id);
1459        if let Some(traceparent) = w3c_trace::format(trace_id, event.span_id().unwrap_or_default())
1460        {
1461            stamp_header(&mut builder, w3c_trace::TRACEPARENT, &traceparent);
1462            // when a custom traceparent header name is configured
1463            // (http.traceparent.header), stamp the same value under that name
1464            // too, so the W3C trace context survives an intermediary that
1465            // strips the standard header
1466            let custom_traceparent =
1467                config.get_property_or("http.traceparent.header", w3c_trace::TRACEPARENT);
1468            if !custom_traceparent.eq_ignore_ascii_case(w3c_trace::TRACEPARENT) {
1469                stamp_header(&mut builder, &custom_traceparent, &traceparent);
1470            }
1471        }
1472    }
1473    // propagate the business correlation-id (unless the caller set it).
1474    // The engine's own Event-over-HTTP transport leg (the x-event-api client
1475    // instruction) is exempt: the business cid rides INSIDE the envelope
1476    // (my_cid tag) and the HTTP-level header is absent on that path (Java
1477    // parity — its EventEmitter leg carries no ambient business context).
1478    if request.header(super::event_api::X_EVENT_API).is_none() {
1479        if let Some(business_cid) = invocation_headers.get(crate::automation::MY_CORRELATION_ID) {
1480            let cid_header =
1481                config.get_property_or("http.correlation.id.header", "X-Correlation-Id");
1482            if request.header(&cid_header).is_none() {
1483                stamp_header(&mut builder, &cid_header, business_cid.as_str());
1484            }
1485        }
1486    }
1487    // cookies
1488    if !request.cookies.is_empty() {
1489        let cookie = request
1490            .cookies
1491            .iter()
1492            .map(|(k, v)| format!("{k}={}", url_encode(v)))
1493            .collect::<Vec<_>>()
1494            .join("; ");
1495        builder = builder.header("cookie", cookie.as_str());
1496    }
1497    builder
1498}
1499
1500fn permitted_http_header(header: &str) -> bool {
1501    !HEADERS_TO_IGNORE
1502        .iter()
1503        .any(|ignored| header.eq_ignore_ascii_case(ignored))
1504}
1505
1506/// Insert-or-replace an engine-stamped header on the outgoing request (Java
1507/// `http.set` semantics): a same-named header already forwarded from the
1508/// request object is replaced, never duplicated. An invalid name/value is
1509/// dropped silently — the same outcome `Builder::header` would produce as a
1510/// deferred build error, but without failing the whole request.
1511fn stamp_header(builder: &mut hyper::http::request::Builder, name: &str, value: &str) {
1512    if let Some(headers) = builder.headers_mut() {
1513        if let (Ok(name), Ok(value)) = (
1514            hyper::header::HeaderName::from_bytes(name.as_bytes()),
1515            hyper::header::HeaderValue::from_str(value),
1516        ) {
1517            headers.insert(name, value);
1518        }
1519    }
1520}
1521
1522fn url_encode(text: &str) -> String {
1523    let mut out = String::with_capacity(text.len());
1524    for byte in text.bytes() {
1525        match byte {
1526            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'*' => {
1527                out.push(byte as char)
1528            }
1529            b' ' => out.push('+'),
1530            other => out.push_str(&format!("%{other:02X}")),
1531        }
1532    }
1533    out
1534}
1535
1536fn request_body_bytes(request: &AsyncHttpRequest, method: &str) -> Result<Vec<u8>, AppError> {
1537    if !matches!(method, "POST" | "PUT" | "PATCH") {
1538        return Ok(Vec::new());
1539    }
1540    match request.body() {
1541        Value::Nil => Ok(Vec::new()),
1542        Value::Binary(bytes) => Ok(bytes.clone()),
1543        Value::String(text) => Ok(text.as_str().unwrap_or_default().as_bytes().to_vec()),
1544        value @ (Value::Map(_) | Value::Array(_)) => {
1545            // maps and lists serialize as JSON (the Java XML writer path is a
1546            // documented deferral); Nil map entries are omitted unless
1547            // serializer.null.transport=true (Java Gson parity)
1548            let json = serde_json::to_value(crate::serializer::strip_nulls(value))
1549                .map_err(|e| AppError::new(400, format!("Invalid HTTP request body - {e}")))?;
1550            serde_json::to_vec(&json)
1551                .map_err(|e| AppError::new(400, format!("Invalid HTTP request body - {e}")))
1552        }
1553        _ => Err(AppError::new(400, "Invalid HTTP request body")),
1554    }
1555}
1556
1557/// Decode the response body by content type (Java `sendFixedLengthResponse`):
1558/// JSON objects/arrays become maps/lists, text stays text, XML passes
1559/// through as raw text (parser deferral), anything else is bytes.
1560fn decode_response_body(bytes: &[u8], content_type: Option<&str>) -> Value {
1561    let Some(content_type) = content_type else {
1562        return Value::from(bytes.to_vec());
1563    };
1564    if content_type.starts_with("application/json") {
1565        let text = String::from_utf8_lossy(bytes).trim().to_string();
1566        if text.is_empty() {
1567            return Value::Map(vec![]);
1568        }
1569        if (text.starts_with('{') && text.ends_with('}'))
1570            || (text.starts_with('[') && text.ends_with(']'))
1571        {
1572            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
1573                if let Ok(value) = rmpv::ext::to_value(&json) {
1574                    return value;
1575                }
1576            }
1577        }
1578        return Value::from(text);
1579    }
1580    if content_type.starts_with("text/")
1581        || content_type.starts_with("application/javascript")
1582        || content_type.starts_with("application/xml")
1583    {
1584        return Value::from(String::from_utf8_lossy(bytes).to_string());
1585    }
1586    Value::from(bytes.to_vec())
1587}
1588
1589/// The registered service wrapper (Java `AsyncHttpClient` implements
1590/// `TypedLambdaFunction`; registered by the app starter at 500 instances).
1591/// Holds the platform it is registered on so its manual replies route to
1592/// that platform's `temporary.inbox` reply listener.
1593pub struct AsyncHttpClientService {
1594    platform: Platform,
1595}
1596
1597impl AsyncHttpClientService {
1598    pub fn new(platform: &Platform) -> Self {
1599        AsyncHttpClientService {
1600            platform: platform.clone(),
1601        }
1602    }
1603}
1604
1605#[async_trait::async_trait]
1606impl crate::function::ComposableFunction for AsyncHttpClientService {
1607    async fn handle_event(
1608        &self,
1609        headers: HashMap<String, String>,
1610        input: EventEnvelope,
1611        _instance: usize,
1612    ) -> Result<EventEnvelope, AppError> {
1613        // reply through the platform this service was registered on — its
1614        // manual reply must reach THAT platform's temporary.inbox listener
1615        // (the global instance may live on another runtime in tests)
1616        handle(&self.platform, headers, input).await
1617    }
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623
1624    /// The server-side request dataset, FLUENT-BUILT (the maintainer's
1625    /// requirement: the test setup creates a new AsyncHttpRequest and sets
1626    /// every value through the fluent API — the test doubles as living
1627    /// documentation of the builder surface, exercising every setter).
1628    fn server_dataset_request() -> AsyncHttpRequest {
1629        AsyncHttpRequest::new()
1630            .set_method("POST")
1631            .set_url("/api/typed/alice")
1632            .set_remote_ip("127.0.0.1")
1633            .set_secure(true)
1634            .set_target_host("localhost:8085")
1635            .set_header("accept", "application/json")
1636            .set_header("x-api-key", "open-sesame")
1637            .set_header("x-ttl", "5000")
1638            .set_path_parameter("user", "alice")
1639            .set_query_parameter_values("q", &["a", "b"])
1640            .set_query_parameter("single", "1")
1641            .set_query_string("q=a&q=b&single=1")
1642            .set_route_timeout_seconds(30)
1643            .set_cookie("first", "alpha")
1644            .set_session_info("user_id", "u-1")
1645            .set_body(Value::Map(vec![(
1646                Value::from("note"),
1647                Value::from("hello"),
1648            )]))
1649    }
1650
1651    /// Round-trip integrity of the typed contract: every fluent-set field is
1652    /// visible through its accessor, survives to_value → from_value, and the
1653    /// serde impls delegate to exactly that pair.
1654    ///
1655    /// Division of labor: this fluent-built round-trip proves INTERNAL
1656    /// consistency (builder ↔ accessors ↔ map shape ↔ serde). The REAL
1657    /// server-shape contract is pinned by the end-to-end test
1658    /// `typed_async_http_request_function_serves_a_real_request` in
1659    /// tests/rest_automation.rs (a typed function behind /api/typed/{user}
1660    /// over the live automation server) — do not "simplify" that e2e away
1661    /// in favor of this unit test.
1662    #[test]
1663    fn server_dataset_round_trips_through_the_typed_contract() {
1664        let request = server_dataset_request();
1665        // every field is visible through the typed accessors
1666        assert_eq!(request.method(), "POST");
1667        assert_eq!(request.url(), "/api/typed/alice");
1668        assert_eq!(request.remote_ip(), Some("127.0.0.1"));
1669        assert!(request.is_secure());
1670        assert_eq!(request.target_host(), Some("localhost:8085"));
1671        assert_eq!(request.path_parameter("user"), Some("alice"));
1672        assert_eq!(request.query_parameter("single").as_deref(), Some("1"));
1673        assert_eq!(request.query_parameter("q").as_deref(), Some("a"));
1674        assert_eq!(request.query_parameters("q"), vec!["a", "b"]);
1675        assert_eq!(request.query_parameters("single"), vec!["1"]);
1676        assert_eq!(request.query_string(), Some("q=a&q=b&single=1"));
1677        assert_eq!(
1678            request.header("Accept"),
1679            Some("application/json"),
1680            "case-insensitive"
1681        );
1682        assert_eq!(request.cookie("first"), Some("alpha"));
1683        assert_eq!(request.session_info("user_id"), Some("u-1"));
1684        // caller-sent x-ttl (5000 ms -> 5 s) wins over the route timeout (30)
1685        assert_eq!(request.timeout_seconds(), 5);
1686        #[derive(serde::Deserialize)]
1687        struct Note {
1688            note: String,
1689        }
1690        assert_eq!(request.body_as::<Note>().expect("typed body").note, "hello");
1691        // to_value emits what from_value parses: a second pass is identical
1692        let round = AsyncHttpRequest::from_value(&request.to_value());
1693        assert_eq!(round.to_value(), request.to_value());
1694        // the serde impls are thin delegates onto the same pair
1695        let deserialized: AsyncHttpRequest =
1696            rmpv::ext::from_value(request.to_value()).expect("serde deserialize");
1697        assert_eq!(deserialized.to_value(), request.to_value());
1698        let serialized = rmpv::ext::to_value(&request).expect("serde serialize");
1699        assert_eq!(
1700            AsyncHttpRequest::from_value(&serialized).to_value(),
1701            request.to_value()
1702        );
1703    }
1704
1705    /// The route timeout (`set_route_timeout_seconds` / the REST edge's
1706    /// "timeout" dataset key) is the fallback when no x-ttl header rides
1707    /// the request.
1708    #[test]
1709    fn route_timeout_is_the_fallback_without_x_ttl() {
1710        let request = AsyncHttpRequest::new()
1711            .set_method("GET")
1712            .set_url("/x")
1713            .set_route_timeout_seconds(30);
1714        assert_eq!(request.timeout_seconds(), 30);
1715        // and it round-trips through the map shape
1716        assert_eq!(
1717            AsyncHttpRequest::from_value(&request.to_value()).timeout_seconds(),
1718            30
1719        );
1720    }
1721}