Skip to main content

platform_core/automation/
server.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 HTTP protocol boundary — Rust port of the Java `HttpRouter` dispatch
18//! (`org.platformlambda.automation.services.HttpRouter`), on **hyper**
19//! (design D10: `rest.yaml` *is* the router, so no web framework).
20//!
21//! For each request: match the routing table → CORS preflight for `OPTIONS` →
22//! apply request-header transforms → **ensure a business correlation-id**
23//! (always, independent of tracing) → **start a trace** when the entry says
24//! `tracing: true` (a valid W3C `traceparent` wins and contributes the
25//! caller's span as our parent; else the trace-id header; else generated) →
26//! optional authentication (an RPC; verdict headers become **session info**)
27//! → build the `AsyncHttpRequest`-shaped event → **CALLBACK dispatch** to the
28//! target service (Java `HttpRouter` parity: the event carries
29//! `reply_to = async.http.response` and `cid` = the HTTP context id, so the
30//! endpoint service's worker self-records its span — the first leg is a real
31//! span record — and the response leg is itself a function span; the business
32//! correlation-id rides the `my_correlation_id` envelope header) → the
33//! [`AsyncHttpResponseService`] correlates the reply back to the waiting
34//! connection → map the response envelope back to HTTP (status, body by type,
35//! response-header transforms + CORS headers; the reserved `my_*` metadata is
36//! stripped, Java `copyResponseHeaders` parity). Errors use the Java JSON
37//! shape `{status, message, type: "error"}`.
38
39use std::collections::{HashMap, VecDeque};
40use std::net::SocketAddr;
41use std::pin::Pin;
42use std::sync::{Arc, Mutex, OnceLock};
43use std::task::{Context, Poll};
44use std::time::Duration;
45
46use async_trait::async_trait;
47use http_body_util::combinators::BoxBody;
48use http_body_util::{BodyExt, Full};
49use hyper::body::{Bytes, Frame};
50use hyper::service::service_fn;
51use hyper::{Request, Response, StatusCode};
52use hyper_util::rt::TokioIo;
53use tokio::sync::{mpsc, oneshot};
54
55use crate::envelope::EventEnvelope;
56use crate::event_stream;
57use crate::function::{AppError, ComposableFunction};
58use crate::platform::Platform;
59use crate::post_office::PostOffice;
60use crate::trace;
61use crate::util::app_config_reader::AppConfigReader;
62use crate::util::config_reader::ConfigReader;
63use crate::util::w3c_trace;
64
65use super::routing::{AssignedRoute, RouteInfo, RoutingTable};
66
67/// Reserved read-only request header exposing the business correlation-id to
68/// the target function (Java `HttpRouter.MY_CORRELATION_ID`).
69pub const MY_CORRELATION_ID: &str = "my_correlation_id";
70
71/// Route of the HTTP response-correlation service (Java
72/// `AsyncHttpClient.ASYNC_HTTP_RESPONSE`).
73pub const ASYNC_HTTP_RESPONSE: &str = "async.http.response";
74
75/// Route-name base of the streaming reply-lane route pool (Java
76/// `AsyncHttpClient.ASYNC_HTTP_RESPONSE_STREAM_POOL`). A streaming request
77/// checks out one dedicated single-instance lane for its lifetime, so its
78/// segments render in strict FIFO order while different requests stream
79/// concurrently through their own lanes.
80pub const ASYNC_HTTP_RESPONSE_STREAM_POOL: &str = "async.http.response.stream";
81
82/// Shared by `async.http.response` and the streaming reply-lane pool
83/// (one lane per instance — Java `AppStarter.RESPONSE_HANDLER_INSTANCES`).
84const RESPONSE_HANDLER_INSTANCES: usize = 500;
85
86/// Buffered segment events per in-flight stream (producer → renderer).
87const STREAM_EVENT_BUFFER: usize = 64;
88/// Buffered wire frames per in-flight stream (renderer → socket).
89const STREAM_FRAME_BUFFER: usize = 64;
90
91/// The response body type: complete payloads and progressive streams share
92/// one boxed body so every handler path composes (Java: vert.x chunked writes).
93type HttpBody = BoxBody<Bytes, std::convert::Infallible>;
94
95/// A complete in-memory response body.
96fn full(bytes: Bytes) -> HttpBody {
97    BoxBody::new(Full::new(bytes))
98}
99
100/// Available streaming reply lanes — a rotating FIFO queue (a rotating variant
101/// of the "ready" signal pattern of the reactive manager/worker design):
102/// checkout takes from the head and a released lane rejoins at the tail, so
103/// selection round-robins through the pool (`.0`, `.1`, `.2` ...) and a
104/// just-released lane rests the full pool length before reuse. Filled once at
105/// server start in member order.
106fn lane_pool() -> &'static Mutex<VecDeque<String>> {
107    static POOL: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
108    POOL.get_or_init(|| Mutex::new(VecDeque::new()))
109}
110
111/// Check out a dedicated ordered reply lane for one streaming request.
112/// Returns None when the pool is exhausted.
113pub fn checkout_lane() -> Option<String> {
114    lane_pool().lock().expect("lane pool poisoned").pop_front()
115}
116
117/// Return a reply lane to the tail of the pool — called when the owning
118/// request ends, and at startup to fill the pool.
119pub fn release_lane(route: String) {
120    lane_pool()
121        .lock()
122        .expect("lane pool poisoned")
123        .push_back(route);
124}
125
126/// The number of reply lanes currently available for checkout.
127pub fn available_lanes() -> usize {
128    lane_pool().lock().expect("lane pool poisoned").len()
129}
130
131/// In-flight streaming HTTP contexts — each entry forwards segment events
132/// from the request's reply lane to its renderer task (Java: the
133/// AsyncContextHolder + EventStreamState pair).
134fn pending_streams() -> &'static Mutex<HashMap<String, mpsc::Sender<EventEnvelope>>> {
135    static PENDING: OnceLock<Mutex<HashMap<String, mpsc::Sender<EventEnvelope>>>> = OnceLock::new();
136    PENDING.get_or_init(|| Mutex::new(HashMap::new()))
137}
138
139/// Remove a streaming context and return its lane to the pool. The map
140/// removal is the exactly-once gate (Java `HttpRouter.closeContext`).
141fn cleanup_stream(context_id: &str, lane: &str) {
142    let removed = pending_streams()
143        .lock()
144        .expect("pending streams poisoned")
145        .remove(context_id);
146    if removed.is_some() {
147        release_lane(lane.to_string());
148    }
149}
150
151/// The streaming reply-lane service — one shared handler behind every
152/// `async.http.response.stream.{n}` route (each registered with a single
153/// instance, so per-request segment order is preserved end-to-end). It
154/// forwards each event into the owning request's renderer; a missing context
155/// (completed, timed out or disconnected) makes late segments no-op drops.
156pub struct StreamLaneService;
157
158#[async_trait]
159impl ComposableFunction for StreamLaneService {
160    async fn handle_event(
161        &self,
162        _headers: HashMap<String, String>,
163        input: EventEnvelope,
164        _instance: usize,
165    ) -> Result<EventEnvelope, AppError> {
166        if let Some(context_id) = input.correlation_id().map(str::to_string) {
167            let sender = pending_streams()
168                .lock()
169                .expect("pending streams poisoned")
170                .get(&context_id)
171                .cloned();
172            if let Some(sender) = sender {
173                // bounded back-pressure toward the renderer; a dropped
174                // receiver (client gone) turns this into a no-op drop
175                let _ = sender.send(input).await;
176            }
177        }
178        Ok(EventEnvelope::new())
179    }
180}
181
182/// A channel-backed streaming response body: the renderer task pushes wire
183/// frames; hyper pulls them as the socket drains. Dropping the sender ends
184/// the response body.
185struct ChannelBody {
186    rx: mpsc::Receiver<Frame<Bytes>>,
187}
188
189impl hyper::body::Body for ChannelBody {
190    type Data = Bytes;
191    type Error = std::convert::Infallible;
192
193    fn poll_frame(
194        mut self: Pin<&mut Self>,
195        cx: &mut Context<'_>,
196    ) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
197        self.rx.poll_recv(cx).map(|frame| frame.map(Ok))
198    }
199}
200
201/// SSE keep-alive comment interval in ms (`event.stream.keep.alive`,
202/// default 30s; 0 disables — Java parity).
203fn keep_alive_ms() -> u64 {
204    static KEEP_ALIVE: OnceLock<u64> = OnceLock::new();
205    *KEEP_ALIVE.get_or_init(|| {
206        let config = AppConfigReader::get_instance();
207        let text = config.get_property_or("event.stream.keep.alive", "30s");
208        let trimmed = text.trim().to_lowercase();
209        if trimmed == "0" || trimmed == "0s" || trimmed == "0ms" || trimmed == "0m" {
210            0
211        } else {
212            super::routing::parse_timeout(Some(&trimmed)).as_millis() as u64
213        }
214    })
215}
216
217/// Reserved `my_*` metadata headers that must never reach the HTTP wire
218/// (Java `WorkerHandler.copyResponseHeaders` protected-metadata handling).
219const PROTECTED_METADATA: [&str; 5] = [
220    "my_route",
221    "my_trace_id",
222    "my_trace_path",
223    MY_CORRELATION_ID,
224    "x-event-api",
225];
226
227/// Pending HTTP contexts awaiting their response envelope — keyed by the
228/// per-request context id that rides the dispatched event's `cid`
229/// (Java `HttpRouter` contexts + `AsyncContextHolder`).
230fn pending_responses() -> &'static Mutex<HashMap<String, oneshot::Sender<EventEnvelope>>> {
231    static PENDING: OnceLock<Mutex<HashMap<String, oneshot::Sender<EventEnvelope>>>> =
232        OnceLock::new();
233    PENDING.get_or_init(|| Mutex::new(HashMap::new()))
234}
235
236/// The `async.http.response` service (Java `AsyncHttpResponse`) — the HTTP
237/// response leg as a REAL registered function: a REST-automation dispatch is a
238/// **callback** to the endpoint service, whose reply (or a flow's response)
239/// arrives here carrying the HTTP context id as its correlation id, and this
240/// service hands the envelope back to the waiting connection. Because it is
241/// an ordinary traced worker, the response leg is a visible span that parents
242/// onto the replying function's span — exactly the Java reference topology.
243/// A missing context (the connection timed out) drops the reply silently.
244pub struct AsyncHttpResponseService;
245
246#[async_trait]
247impl ComposableFunction for AsyncHttpResponseService {
248    async fn handle_event(
249        &self,
250        _headers: HashMap<String, String>,
251        input: EventEnvelope,
252        _instance: usize,
253    ) -> Result<EventEnvelope, AppError> {
254        if let Some(context_id) = input.correlation_id().map(str::to_string) {
255            let sender = pending_responses()
256                .lock()
257                .expect("pending http contexts poisoned")
258                .remove(&context_id);
259            if let Some(sender) = sender {
260                let _ = sender.send(input);
261            }
262        }
263        Ok(EventEnvelope::new())
264    }
265}
266
267/// The address the first HTTP server bound to in this process (first-bind
268/// wins; `start_http_server` still binds a fresh listener on every call).
269/// Intended for a test or embedder that boots the app once on an ephemeral
270/// port (`rest.server.port=0`) and needs the assigned port afterwards.
271static SERVER_ADDR: OnceLock<SocketAddr> = OnceLock::new();
272
273/// The address the HTTP server bound to, if one has started (see
274/// [`SERVER_ADDR`]). With `rest.server.port=0` (ephemeral) this is how a
275/// single-server app recovers the port the OS assigned at bind time.
276pub fn server_address() -> Option<SocketAddr> {
277    SERVER_ADDR.get().copied()
278}
279
280struct RouterState {
281    table: RoutingTable,
282    platform: Platform,
283    trace_header: String,
284    cid_header: String,
285    /// Configurable traceparent header name (`http.traceparent.header`): an
286    /// escape hatch for an intermediary (e.g. an API gateway) that strips the
287    /// standard W3C `traceparent` header. When customized, the same W3C-format
288    /// value travels under BOTH names on outbound calls. Inbound, the standard
289    /// `traceparent` always wins; the custom name is read only when the
290    /// standard header is absent or malformed — a well-formed standard
291    /// traceparent means the caller already speaks W3C/OTel, so a proprietary
292    /// header alongside it is residual and safely ignored.
293    traceparent_header: String,
294}
295
296/// Start the REST automation server (Java: the Vert.x HTTP server started by
297/// `AppStarter` when `rest.automation=true`). Reads `rest.yaml` from
298/// `yaml.rest.automation` (default `classpath:/rest.yaml`) and binds
299/// `rest.server.port` (default 8085; port 0 = ephemeral, for tests). Returns
300/// the bound address; the accept loop runs as a background task.
301pub async fn start_http_server(platform: &Platform) -> Result<SocketAddr, AppError> {
302    let config = AppConfigReader::get_instance();
303    // the response-correlation service is part of the HTTP boundary itself
304    // (Java AppStarter registers AsyncHttpResponse with the server, private,
305    // 500 instances); idempotent — tolerate a concurrent registration
306    if !platform.has_route(ASYNC_HTTP_RESPONSE) {
307        if let Err(e) = platform.register_private(
308            ASYNC_HTTP_RESPONSE,
309            Arc::new(AsyncHttpResponseService),
310            RESPONSE_HANDLER_INSTANCES,
311        ) {
312            if !platform.has_route(ASYNC_HTTP_RESPONSE) {
313                return Err(e);
314            }
315        }
316    }
317    // streaming responses use a route pool of dedicated single-instance reply
318    // lanes: a streaming request checks out one lane for its lifetime (strict
319    // FIFO for its segments) and returns it when its context closes; the pool
320    // size matches the async.http.response instances, and an idle lane costs
321    // only a little memory (Java AppStarter parity). Registration runs on
322    // EVERY server start — the pool reload rebinds the lane workers to the
323    // current runtime (the per-test-runtime idiom of this port) — but the
324    // checkout POOL is filled exactly once per process: get_or_init blocks a
325    // concurrent second server start until the fill completes, so the pool can
326    // never be refilled or double-filled while requests are in flight
327    let members = platform.register_route_pool(
328        ASYNC_HTTP_RESPONSE_STREAM_POOL,
329        Arc::new(StreamLaneService),
330        RESPONSE_HANDLER_INSTANCES,
331    )?;
332    static POOL_FILLED: OnceLock<()> = OnceLock::new();
333    POOL_FILLED.get_or_init(|| {
334        for lane_route in members {
335            release_lane(lane_route);
336        }
337    });
338    let rest_yaml = config.get_property_or("yaml.rest.automation", "classpath:/rest.yaml");
339    let reader = ConfigReader::load(&rest_yaml)
340        .map_err(|e| AppError::new(500, format!("Unable to load {rest_yaml} - {e}")))?;
341    let mut table = RoutingTable::load(&reader)?;
342    merge_default_endpoints(&mut table)?;
343    let table = table;
344    for route in table.routes() {
345        log::info!(
346            "{} {} -> {}",
347            route.methods.join(","),
348            route.url,
349            route.service
350        );
351    }
352    let port: u16 = config
353        .get_property_or("rest.server.port", "8085")
354        .parse()
355        .map_err(|_| AppError::new(500, "Invalid rest.server.port"))?;
356    let state = Arc::new(RouterState {
357        table,
358        platform: platform.clone(),
359        trace_header: config.get_property_or("http.trace.id.header", "X-Trace-Id"),
360        cid_header: config.get_property_or("http.correlation.id.header", "X-Correlation-Id"),
361        traceparent_header: config
362            .get_property_or("http.traceparent.header", w3c_trace::TRACEPARENT),
363    });
364    // startup announcement of the resolved header names (Java HttpRouter
365    // parity — same wording, presentation parity for side-by-side log review)
366    log::info!("Correlation-id HTTP header is '{}'", state.cid_header);
367    log::info!("Trace-id HTTP header is '{}'", state.trace_header);
368    log::info!("Traceparent HTTP header is '{}'", state.traceparent_header);
369    let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
370        .await
371        .map_err(|e| AppError::new(500, format!("Unable to bind port {port} - {e}")))?;
372    let addr = listener
373        .local_addr()
374        .map_err(|e| AppError::new(500, e.to_string()))?;
375    let _ = SERVER_ADDR.set(addr);
376    log::info!("REST automation service started on port {}", addr.port());
377    tokio::spawn(async move {
378        loop {
379            let Ok((stream, peer)) = listener.accept().await else {
380                break;
381            };
382            let state = state.clone();
383            tokio::spawn(async move {
384                let io = TokioIo::new(stream);
385                let service = service_fn(move |request| {
386                    let state = state.clone();
387                    async move { handle(state, request, peer).await }
388                });
389                if let Err(e) = hyper::server::conn::http1::Builder::new()
390                    .serve_connection(io, service)
391                    .with_upgrades()
392                    .await
393                {
394                    log::debug!("HTTP connection ended - {e}");
395                }
396            });
397        }
398    });
399    Ok(addr)
400}
401
402async fn handle(
403    state: Arc<RouterState>,
404    request: Request<hyper::body::Incoming>,
405    peer: SocketAddr,
406) -> Result<Response<HttpBody>, hyper::Error> {
407    // websocket upgrade on a registered `/ws/{name}/{token}` path takes the
408    // connection out of the HTTP request/response cycle (Java parity)
409    if super::ws_server::is_ws_upgrade(&request) {
410        return Ok(super::ws_server::handle_ws_upgrade(
411            &state.platform,
412            request,
413            peer.ip().to_string(),
414        )
415        .map(BoxBody::new));
416    }
417    let method = request.method().as_str().to_uppercase();
418    let path = request.uri().path().to_string();
419    let query_text = request.uri().query().unwrap_or("").to_string();
420    // header map (lowercase names — deterministic matching)
421    let mut headers: HashMap<String, String> = HashMap::new();
422    for (name, value) in request.headers() {
423        if let Ok(value) = value.to_str() {
424            headers.insert(name.as_str().to_lowercase(), value.to_string());
425        }
426    }
427    let body_bytes = match request.into_body().collect().await {
428        Ok(collected) => collected.to_bytes(),
429        Err(_) => Bytes::new(),
430    };
431    let Some(assigned) = state.table.find(&method, &path) else {
432        // Java HttpRequestHandler: a known path under a WRONG method is 405,
433        // never 404 (increment 56, parity F14c — the getSimilarRoute marker)
434        if state.table.path_matches_any_method(&path) {
435            return Ok(error_response(405, "Method not allowed"));
436        }
437        // static HTML content from resources/public — including "/" →
438        // index.html — served only when rest.yaml claims no route (a "/"
439        // entry in rest.yaml always wins)
440        if method == "GET" || method == "HEAD" {
441            if let Some(response) =
442                serve_static(&state, &path, &query_text, &headers, peer, method == "HEAD").await
443            {
444                return Ok(response);
445            }
446        }
447        return Ok(error_response(404, "Resource not found"));
448    };
449    // CORS preflight (OPTIONS is auto-added per the grammar). Java
450    // handleOptionsMethod: without a CORS block (or with empty options) the
451    // answer is 405 "Method not allowed", never a bare 204 (increment 56,
452    // parity F14c)
453    if method == "OPTIONS" {
454        let Some(cors) = assigned
455            .info
456            .cors
457            .as_ref()
458            .filter(|c| !c.options.is_empty())
459        else {
460            return Ok(error_response(405, "Method not allowed"));
461        };
462        let mut response = Response::builder().status(StatusCode::NO_CONTENT);
463        for (name, value) in &cors.options {
464            response = response.header(name, value);
465        }
466        return Ok(response.body(full(Bytes::new())).expect("static response"));
467    }
468    match process(
469        &state, assigned, method, path, query_text, headers, body_bytes, peer,
470    )
471    .await
472    {
473        Ok(response) => Ok(response),
474        Err(e) => Ok(error_response(e.status(), e.message())),
475    }
476}
477
478#[allow(clippy::too_many_arguments)]
479async fn process(
480    state: &RouterState,
481    assigned: AssignedRoute<'_>,
482    method: String,
483    path: String,
484    query_text: String,
485    mut headers: HashMap<String, String>,
486    body_bytes: Bytes,
487    peer: SocketAddr,
488) -> Result<Response<HttpBody>, AppError> {
489    let info = assigned.info;
490    // request-header transforms
491    if let Some(header_info) = &info.headers {
492        header_info.request.apply(&mut headers);
493    }
494    // event-script flow binding: rest.yaml `flow:` becomes the x-flow-id
495    // header the flow adapter reads (Java parity; increment E-3)
496    if let Some(flow) = &info.flow {
497        headers.insert("x-flow-id".to_string(), flow.clone());
498    }
499    // effective header names (per-entry impedance override > global > default)
500    let trace_header = info
501        .trace_id_header
502        .as_deref()
503        .unwrap_or(&state.trace_header)
504        .to_lowercase();
505    let cid_header = info
506        .correlation_id_header
507        .as_deref()
508        .unwrap_or(&state.cid_header)
509        .to_lowercase();
510    // trace resolution: a valid W3C traceparent wins and contributes the
511    // caller's span as our parent; else the trace-id header; else generated.
512    // The standard "traceparent" header always wins; the custom name
513    // (per-entry 'traceparent.header' in rest.yaml, else the global
514    // http.traceparent.header) is read only when the standard header is
515    // absent or malformed. Rationale: a well-formed standard traceparent
516    // means the caller already speaks the W3C/OpenTelemetry standard - a
517    // proprietary header alongside it is residual and safely ignored.
518    let traceparent = headers
519        .get(w3c_trace::TRACEPARENT)
520        .and_then(|value| w3c_trace::parse(value))
521        .or_else(|| {
522            let traceparent_header = info
523                .traceparent_header
524                .as_deref()
525                .unwrap_or(&state.traceparent_header)
526                .to_lowercase();
527            if traceparent_header == w3c_trace::TRACEPARENT {
528                None
529            } else {
530                headers
531                    .get(&traceparent_header)
532                    .and_then(|value| w3c_trace::parse(value))
533            }
534        });
535    let (trace_id, parent_span) = match &traceparent {
536        Some((trace_id, parent)) => (Some(trace_id.clone()), Some(parent.clone())),
537        None => (headers.get(&trace_header).cloned(), None),
538    };
539    let trace_id = if info.tracing {
540        Some(trace_id.unwrap_or_else(trace::new_trace_id))
541    } else {
542        None
543    };
544    // a business correlation-id is ALWAYS ensured, independent of tracing;
545    // legacy conflation (one shared header name) yields one id, not two
546    let cid = headers.get(&cid_header).cloned().unwrap_or_else(|| {
547        if cid_header == trace_header {
548            trace_id
549                .clone()
550                .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string())
551        } else {
552            uuid::Uuid::new_v4().simple().to_string()
553        }
554    });
555    // stamp the resolved correlation-id onto the request dataset under the
556    // configured header name (Java parity): the target function and the flow
557    // engine see the SAME edge-resolved value even when the caller sent none
558    headers.insert(cid_header.clone(), cid.clone());
559    // the endpoint timeout is represented AS the x-ttl request header in
560    // milliseconds — Java parity: HttpRouter calls req.setTimeoutSeconds(
561    // route timeout) at ingress and AsyncHttpRequest stores/reads the TTL as
562    // this header (one representation), so a flow's input.header view carries
563    // the same key on both engines. A caller-sent x-ttl WINS — Java copies
564    // the inbound headers after the stamp, which is how the Event-over-HTTP
565    // client's own TTL rides through the /api/event endpoint.
566    headers
567        .entry("x-ttl".to_string())
568        .or_insert_with(|| (info.timeout.as_secs().max(1) * 1000).to_string());
569    // AsyncHttpRequest-shaped event body (Java parity keys).
570    // Repeated query parameters keep EVERY value — one occurrence is a
571    // string, more become a list (Java HttpRouter: params.getAll;
572    // increment 56, parity F14a — previously last-wins)
573    let mut query: HashMap<String, serde_json::Value> = HashMap::new();
574    for pair in query_text.split('&').filter(|p| !p.is_empty()) {
575        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
576        let (name, value) = (url_decode(name), url_decode(value));
577        match query.get_mut(&name) {
578            None => {
579                query.insert(name, serde_json::Value::String(value));
580            }
581            Some(serde_json::Value::Array(values)) => {
582                values.push(serde_json::Value::String(value));
583            }
584            Some(existing) => {
585                let first = existing.clone();
586                *existing = serde_json::Value::Array(vec![first, serde_json::Value::String(value)]);
587            }
588        }
589    }
590    let path_params: HashMap<String, String> = assigned
591        .path_params
592        .iter()
593        .map(|(k, v)| (k.clone(), url_decode(v)))
594        .collect();
595    // the cookie header becomes a parsed cookies map and is WITHHELD from
596    // the request headers (Java setRequestCookies; increment 56, parity
597    // F14d — previously the raw header rode through and no map existed)
598    let cookies: HashMap<String, String> = headers
599        .remove("cookie")
600        .map(|header| {
601            header
602                .split(';')
603                .filter_map(|item| item.split_once('='))
604                .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
605                .collect()
606        })
607        .unwrap_or_default();
608    // the request's Accept header drives the response's fallback content
609    // negotiation (Java AsyncContextHolder.accept), captured before the
610    // headers map moves into the event body
611    let accept = headers.get("accept").cloned();
612    let parsed = parse_body(&headers, &body_bytes);
613    // form fields become query parameters, on top of the URL's own
614    // (Java handleTextContent's url-encode branch: setQueryParameter each —
615    // single values, replacing)
616    if let ParsedBody::Form(form) = &parsed {
617        for (name, value) in form {
618            query.insert(name.clone(), serde_json::Value::String(value.clone()));
619        }
620    }
621    // ONE definition of the wire shape: the dataset is constructed through
622    // AsyncHttpRequest's fluent API and rendered by its to_value() — the
623    // same builder/parser pair a typed function deserializes through, so
624    // server↔struct drift is impossible by construction (previously this
625    // was a hand-assembled JSON literal, which is exactly how the server
626    // came to emit keys from_value never parsed).
627    let mut http_request = crate::automation::AsyncHttpRequest::new()
628        .set_method(&method)
629        .set_url(&path)
630        .set_remote_ip(&peer.ip().to_string())
631        // Java: setSecure(x-forwarded-proto == "https") — increment 56,
632        // parity F14d (previously hardcoded false)
633        .set_secure(headers.get("x-forwarded-proto").map(String::as_str) == Some("https"))
634        .set_target_host(&headers.get("host").cloned().unwrap_or_default())
635        // Java AsyncHttpRequest.getTimeoutSeconds (the flow adapter derives
636        // the flow TTL from it)
637        .set_route_timeout_seconds(info.timeout.as_secs());
638    for (key, value) in &headers {
639        http_request = http_request.set_header(key, value);
640    }
641    for (key, value) in &path_params {
642        http_request = http_request.set_path_parameter(key, value);
643    }
644    for (key, value) in &query {
645        http_request = match value {
646            serde_json::Value::Array(values) => {
647                let values: Vec<&str> = values
648                    .iter()
649                    .map(|v| v.as_str().unwrap_or_default())
650                    .collect();
651                http_request.set_query_parameter_values(key, &values)
652            }
653            serde_json::Value::String(value) => http_request.set_query_parameter(key, value),
654            other => http_request.set_query_parameter(key, &other.to_string()),
655        };
656    }
657    // the request body: a JSON-shaped payload rides as-is; a binary body
658    // (unknown content type) rides as MsgPack binary (Java: byte[] on the
659    // AsyncHttpRequest); a form body already became query parameters, so
660    // the body key stays an explicit null — exactly the previous shape
661    http_request = match &parsed {
662        ParsedBody::Value(value) => http_request
663            .set_body(rmpv::ext::to_value(value).map_err(|e| AppError::new(500, e.to_string()))?),
664        ParsedBody::Bytes(bytes) => http_request.set_body(rmpv::Value::Binary(bytes.clone())),
665        ParsedBody::Form(_) => http_request.set_body(rmpv::Value::Nil),
666    };
667    // the raw query string rides as Java's top-level "query" key; cookies
668    // appear only when present (Java toMap omits empty)
669    if !query_text.is_empty() {
670        http_request = http_request.set_query_string(&query_text);
671    }
672    for (key, value) in &cookies {
673        http_request = http_request.set_cookie(key, value);
674    }
675    let po = PostOffice::new(&state.platform);
676    // Java appends the query string to the trace path (HttpRouter)
677    let trace_path = if query_text.is_empty() {
678        format!("{method} {path}")
679    } else {
680        format!("{method} {path}?{query_text}")
681    };
682    // optional authentication before dispatch (simple route form) — an RPC,
683    // so the auth verdict reports as a round_trip record (Java parity)
684    if let Some(auth_route) = &info.authentication {
685        let auth_event = build_event(
686            auth_route,
687            &http_request,
688            &cid,
689            &trace_id,
690            &trace_path,
691            &parent_span,
692        )?;
693        let verdict = po.request(auth_event, info.timeout).await?;
694        if verdict.has_error() {
695            return Err(AppError::new(
696                verdict.status(),
697                verdict
698                    .body_as::<String>()
699                    .unwrap_or_else(|_| "Unauthorized".to_string()),
700            ));
701        }
702        if !verdict.body_as::<bool>().unwrap_or(false) {
703            return Err(AppError::new(401, "Unauthorized"));
704        }
705        // headers on the auth verdict become SESSION INFO that rides to the
706        // target function as read-only headers (Java HttpRouter parity —
707        // e.g. the event.api.auth demo injects `user: demo`)
708        for (key, value) in verdict.headers() {
709            http_request = http_request.set_session_info(key, value);
710        }
711    }
712    let is_head = method == "HEAD";
713    // a streaming-capable /api/event call (Accept: text/event-stream, not
714    // drop-n-forget) dispatches through a dedicated reply lane rendering the
715    // envelope-mode wire dialect - so a remote peer's streaming function can
716    // answer the one POST progressively; plain RPC calls never consume a lane
717    let envelope_stream = !is_head && is_event_api_stream(info, &http_request);
718    // a streaming endpoint (rest.yaml `stream: true`) uses the multi-shot
719    // reply route; HEAD requests never stream (Java parity)
720    let result = if (info.stream_response && !is_head) || envelope_stream {
721        match stream_dispatch(
722            state,
723            info,
724            &http_request,
725            &cid,
726            &cid_header,
727            &trace_id,
728            &trace_path,
729            &parent_span,
730            accept.clone(),
731            envelope_stream,
732        )
733        .await?
734        {
735            StreamOutcome::Streaming(response) => return Ok(response),
736            StreamOutcome::SingleShot(envelope) => envelope,
737        }
738    } else {
739        // CALLBACK dispatch (Java HttpRouter parity): the endpoint service is
740        // invoked with reply_to = async.http.response and cid = the HTTP context
741        // id — its worker self-records its span (no RPC suppression), and the
742        // response leg is a visible function span. The business correlation-id
743        // rides the my_correlation_id envelope header instead of the cid slot.
744        let context_id = uuid::Uuid::new_v4().simple().to_string();
745        let (tx, rx) = oneshot::channel();
746        pending_responses()
747            .lock()
748            .expect("pending http contexts poisoned")
749            .insert(context_id.clone(), tx);
750        let event = build_event(
751            &info.service,
752            &http_request,
753            &cid,
754            &trace_id,
755            &trace_path,
756            &parent_span,
757        )?
758        .set_correlation_id(&context_id)
759        .set_reply_to(ASYNC_HTTP_RESPONSE);
760        if let Err(e) = po.send(event).await {
761            pending_responses()
762                .lock()
763                .expect("pending http contexts poisoned")
764                .remove(&context_id);
765            return Err(e);
766        }
767        match tokio::time::timeout(info.timeout, rx).await {
768            Ok(Ok(envelope)) => envelope,
769            Ok(Err(_)) => {
770                return Err(AppError::new(500, "Response channel closed unexpectedly"));
771            }
772            Err(_) => {
773                pending_responses()
774                    .lock()
775                    .expect("pending http contexts poisoned")
776                    .remove(&context_id);
777                return Err(AppError::new(
778                    408,
779                    format!("Timeout for {} ms", info.timeout.as_millis()),
780                ));
781            }
782        }
783    };
784    // map the response envelope back to HTTP (Java AsyncHttpResponse:
785    // updateHeadersAndContentType + updateHeaders)
786    let status = status_of(result.status());
787    let mut content_type: Option<String> = None;
788    let mut set_cookies: Vec<String> = Vec::new();
789    let mut response_headers: HashMap<String, String> = HashMap::new();
790    for (name, value) in result.headers() {
791        let key = name.to_lowercase();
792        // the reserved my_* metadata never reaches the HTTP wire (Java
793        // WorkerHandler.copyResponseHeaders protected-metadata parity)
794        if PROTECTED_METADATA.contains(&key.as_str()) {
795            continue;
796        }
797        match key.as_str() {
798            // the response-streaming contract (x-stream-id + x-ttl) is a
799            // documented deferral in this port (D10) — recognized like Java
800            // and withheld from the wire, never leaked as literal headers
801            "x-stream-id" if value.starts_with("stream.") && value.contains(".in") => {}
802            "x-ttl" => {}
803            // a function-set content type overrides negotiation
804            // (Java: response.putHeader directly, lowercased; skipped for HEAD)
805            "content-type" => {
806                if !is_head {
807                    content_type = Some(value.to_lowercase());
808                }
809            }
810            // repeated cookies ride one envelope header, "|"-separated
811            // (Java SimpleHttpUtility.setCookies -> one header line each)
812            "set-cookie" => {
813                set_cookies.extend(value.split('|').map(|c| c.trim().to_string()));
814            }
815            _ => {
816                response_headers.insert(key, value.clone());
817            }
818        }
819    }
820    // Without a function-set type, the fallback comes from the request's
821    // Accept header (Java updateContentType — increment 56, the negotiation
822    // sub-item queued at increment 50; previously derived from body shape),
823    // and map/list bodies render per the negotiated type (handleMapContent).
824    if content_type.is_none() && !is_head {
825        content_type = accept_fallback_type(accept.as_deref(), result.body());
826    }
827    let payload = render_payload(result.body(), content_type.as_deref());
828    // the rest.yaml response transform filters the merged header map (Java
829    // filterHeaders); content-type and cookies bypass it, as in Java
830    if let Some(header_info) = &info.headers {
831        header_info.response.apply(&mut response_headers);
832    }
833    // echo the request's business correlation-id (inbound or edge-generated)
834    // under the configured header name so the caller can correlate without
835    // parsing the body; a function-set response header of the same name wins
836    // (Java AsyncHttpResponse parity)
837    response_headers.entry(cid_header.clone()).or_insert(cid);
838    if let Some(content_type) = content_type {
839        response_headers.insert("content-type".to_string(), content_type);
840    }
841    if let Some(cors) = &info.cors {
842        for (name, value) in &cors.headers {
843            response_headers.insert(name.to_lowercase(), value.clone());
844        }
845    }
846    let mut response = Response::builder().status(status);
847    for (name, value) in response_headers {
848        response = response.header(name, value);
849    }
850    for cookie in set_cookies {
851        if !cookie.is_empty() {
852            response = response.header("set-cookie", cookie);
853        }
854    }
855    // a HEAD response never carries a body (Java: isHeadMethod skips content)
856    let payload = if is_head { Bytes::new() } else { payload };
857    response
858        .body(full(payload))
859        .map_err(|e| AppError::new(500, e.to_string()))
860}
861
862/// Outcome of a streaming dispatch: a committed progressive response, or the
863/// first event turned out to be an ordinary single-shot reply.
864/// (A short-lived by-value carrier - the size difference between variants is
865/// one stack move per request, not worth a heap allocation.)
866#[allow(clippy::large_enum_variant)]
867enum StreamOutcome {
868    Streaming(Response<HttpBody>),
869    SingleShot(EventEnvelope),
870}
871
872/// The first event's stream marker: `Ok(Some(marker))` for a valid
873/// `x-event-stream` value, `Ok(None)` when the header is absent (single-shot),
874/// `Err(())` for a present-but-invalid value (drop the event, Java parity).
875fn stream_marker(event: &EventEnvelope) -> Result<Option<&'static str>, ()> {
876    for (name, value) in event.headers() {
877        if name.eq_ignore_ascii_case(event_stream::X_EVENT_STREAM) {
878            return match value.to_lowercase().as_str() {
879                event_stream::DATA => Ok(Some(event_stream::DATA)),
880                event_stream::EOF => Ok(Some(event_stream::EOF)),
881                event_stream::EXCEPTION => Ok(Some(event_stream::EXCEPTION)),
882                _ => Err(()),
883            };
884        }
885    }
886    Ok(None)
887}
888
889/// Error text from an exception event body (Java `EventStreamRenderer.errorMessage`).
890fn stream_error_message(event: &EventEnvelope) -> String {
891    match event.body() {
892        rmpv::Value::Map(entries) => entries
893            .iter()
894            .find(|(key, _)| key.as_str() == Some("message"))
895            .map(|(_, value)| stream_text(value))
896            .unwrap_or_else(|| "Stream failed".to_string()),
897        rmpv::Value::Nil => "Stream failed".to_string(),
898        other => stream_text(other),
899    }
900}
901
902/// The fallback content type for a streaming response from the request's
903/// Accept header (Java `EventStreamRenderer.negotiateContentType`).
904fn negotiate_stream_type(accept: Option<&str>) -> String {
905    let Some(accept) = accept else {
906        return "application/json".to_string();
907    };
908    if accept.contains("*/*") || accept.contains("application/json") {
909        "application/json".to_string()
910    } else if accept.contains("text/event-stream") {
911        "text/event-stream".to_string()
912    } else if accept.contains("text/html") {
913        "text/html".to_string()
914    } else if accept.contains("application/xml") {
915        "application/xml".to_string()
916    } else {
917        "text/plain".to_string()
918    }
919}
920
921/// A segment body as line-oriented text: strings ride as-is; binary as UTF-8;
922/// structured bodies render as COMPACT one-line JSON — stream framing is
923/// line-oriented on both engines (Java uses the compact Gson for frames).
924fn stream_text(body: &rmpv::Value) -> String {
925    match body {
926        rmpv::Value::Nil => String::new(),
927        rmpv::Value::String(text) => text.as_str().unwrap_or_default().to_string(),
928        rmpv::Value::Binary(bytes) => String::from_utf8_lossy(bytes).to_string(),
929        other => {
930            let stripped = crate::serializer::strip_nulls(other);
931            let json = serde_json::to_value(&stripped).unwrap_or_default();
932            serde_json::to_string(&json).unwrap_or_default()
933        }
934    }
935}
936
937/// One SSE frame: optional `event:` line, one `data:` line per text line
938/// (multi-line data splits per the SSE specification), then a blank line.
939fn sse_frame(event_name: Option<&str>, text: &str) -> Bytes {
940    let mut frame = String::new();
941    if let Some(name) = event_name.filter(|n| !n.is_empty()) {
942        frame.push_str("event: ");
943        frame.push_str(name);
944        frame.push('\n');
945    }
946    for line in text.split('\n') {
947        frame.push_str("data: ");
948        frame.push_str(line);
949        frame.push('\n');
950    }
951    frame.push('\n');
952    Bytes::from(frame)
953}
954
955/// One chunked-mode segment: strings and bytes append verbatim; structured
956/// bodies stream as JSON Lines (one compact JSON object per line).
957fn chunk_bytes(body: &rmpv::Value) -> Bytes {
958    match body {
959        rmpv::Value::Nil => Bytes::new(),
960        rmpv::Value::String(text) => Bytes::from(text.as_str().unwrap_or_default().to_string()),
961        rmpv::Value::Binary(bytes) => Bytes::from(bytes.clone()),
962        other => {
963            let mut line = stream_text(other);
964            line.push('\n');
965            Bytes::from(line)
966        }
967    }
968}
969
970/// The `x-event-name` companion header (SSE `event:` field), if any.
971fn stream_event_name(event: &EventEnvelope) -> Option<&str> {
972    event
973        .headers()
974        .iter()
975        .find(|(name, _)| name.eq_ignore_ascii_case(event_stream::X_EVENT_NAME))
976        .map(|(_, value)| value.as_str())
977}
978
979/// True when this request is a streaming-capable Event-over-HTTP call: the
980/// /api/event service, invoked with `Accept: text/event-stream` and not
981/// drop-n-forget. Such a call dispatches through a reply lane in envelope
982/// mode - the EventApiService rewires the inner request onto the lane so a
983/// streaming target's segments relay straight to the wire.
984fn is_event_api_stream(info: &RouteInfo, request: &crate::automation::AsyncHttpRequest) -> bool {
985    info.service == super::event_api::EVENT_API_SERVICE
986        && request.header("x-async") != Some("true")
987        && request
988            .header("accept")
989            .is_some_and(|accept| accept.contains("text/event-stream"))
990}
991
992/// The idle allowance of a streaming-capable Event-over-HTTP call: the POST's
993/// x-ttl header in milliseconds (the caller's declaration), floor one second -
994/// the same reading the EventApiService applies (Java parity).
995fn event_api_idle(request: &crate::automation::AsyncHttpRequest) -> Duration {
996    let ttl_ms = request
997        .header("x-ttl")
998        .and_then(|v| v.trim().parse::<u64>().ok())
999        .unwrap_or(0)
1000        .max(1000);
1001    Duration::from_millis(ttl_ms)
1002}
1003
1004/// Dispatch to a streaming endpoint: check out a dedicated ordered reply
1005/// lane, send the request with `reply_to` = that lane, and turn the event
1006/// sequence into a progressive HTTP response. The first event decides the
1007/// shape: unmarked = ordinary single-shot; `exception` before the head = a
1008/// normal HTTP error; `data`/`eof` commit the head and start the renderer.
1009/// In envelope mode (the Event-over-HTTP streaming relay) the wire is the
1010/// hybrid dialect and a pre-head exception still rides the stream, so the
1011/// caller always receives the exact envelope.
1012#[allow(clippy::too_many_arguments)]
1013async fn stream_dispatch(
1014    state: &RouterState,
1015    info: &RouteInfo,
1016    http_request: &crate::automation::AsyncHttpRequest,
1017    cid: &str,
1018    cid_header: &str,
1019    trace_id: &Option<String>,
1020    trace_path: &str,
1021    parent_span: &Option<String>,
1022    accept: Option<String>,
1023    envelope_mode: bool,
1024) -> Result<StreamOutcome, AppError> {
1025    // a streaming endpoint borrows a dedicated ordered reply lane for the
1026    // lifetime of the request - an empty pool means full streaming capacity
1027    let Some(lane) = checkout_lane() else {
1028        return Err(AppError::new(503, "Streaming response pool exhausted"));
1029    };
1030    let po = PostOffice::new(&state.platform);
1031    let context_id = uuid::Uuid::new_v4().simple().to_string();
1032    let (tx, mut rx) = mpsc::channel::<EventEnvelope>(STREAM_EVENT_BUFFER);
1033    pending_streams()
1034        .lock()
1035        .expect("pending streams poisoned")
1036        .insert(context_id.clone(), tx);
1037    let event = build_event(
1038        &info.service,
1039        http_request,
1040        cid,
1041        trace_id,
1042        trace_path,
1043        parent_span,
1044    )?
1045    .set_correlation_id(&context_id)
1046    .set_reply_to(&lane);
1047    if let Err(e) = po.send(event).await {
1048        cleanup_stream(&context_id, &lane);
1049        return Err(e);
1050    }
1051    // the idle allowance: the endpoint timeout, or the caller-declared x-ttl
1052    // for a streaming-capable Event-over-HTTP call
1053    let base_idle = if envelope_mode {
1054        event_api_idle(http_request)
1055    } else {
1056        info.timeout
1057    };
1058    // await the first event within the idle allowance
1059    let (first, marker) = loop {
1060        match tokio::time::timeout(base_idle, rx.recv()).await {
1061            Ok(Some(envelope)) => match stream_marker(&envelope) {
1062                Ok(Some(marker)) => break (envelope, Some(marker)),
1063                Ok(None) => break (envelope, None),
1064                Err(()) => {
1065                    // present-but-invalid marker: drop the event (Java parity)
1066                    log::warn!(
1067                        "Dropping event for {context_id} - invalid {} signal",
1068                        event_stream::X_EVENT_STREAM
1069                    );
1070                }
1071            },
1072            Ok(None) => {
1073                cleanup_stream(&context_id, &lane);
1074                return Err(AppError::new(500, "Response channel closed unexpectedly"));
1075            }
1076            Err(_) => {
1077                cleanup_stream(&context_id, &lane);
1078                return Err(AppError::new(
1079                    408,
1080                    format!("Timeout for {} ms", base_idle.as_millis()),
1081                ));
1082            }
1083        }
1084    };
1085    let Some(marker) = marker else {
1086        // the endpoint answered single-shot - render exactly as before; in
1087        // envelope mode the reply is wrapped into the classic Event-over-HTTP
1088        // wire (the whole envelope as a serialized octet-stream body), so a
1089        // non-streaming target stays byte-identical to the RPC path
1090        cleanup_stream(&context_id, &lane);
1091        let reply = if envelope_mode {
1092            wire_single_shot(first)?
1093        } else {
1094            first
1095        };
1096        return Ok(StreamOutcome::SingleShot(reply));
1097    };
1098    if marker == event_stream::EXCEPTION && !envelope_mode {
1099        // failure before the head is committed - render a normal HTTP error
1100        // (in envelope mode a pre-head failure still rides the stream, so the
1101        // caller receives the exact error envelope)
1102        cleanup_stream(&context_id, &lane);
1103        let status = if first.status() >= 400 {
1104            first.status()
1105        } else {
1106            500
1107        };
1108        return Err(AppError::new(status, stream_error_message(&first)));
1109    }
1110    // ---- the first data/eof event commits the HTTP head ----
1111    if first
1112        .headers()
1113        .keys()
1114        .any(|k| k.eq_ignore_ascii_case("x-stream-id"))
1115    {
1116        // mutual exclusivity rule: x-event-stream wins over a stray x-stream-id
1117        log::warn!("Ignoring x-stream-id on a streaming response for {context_id}");
1118    }
1119    let mut response_headers: HashMap<String, String> = HashMap::new();
1120    let mut set_cookies: Vec<String> = Vec::new();
1121    let mut content_type: Option<String> = None;
1122    let mut idle_override: Option<Duration> = None;
1123    for (name, value) in first.headers() {
1124        let key = name.to_lowercase();
1125        match key.as_str() {
1126            // reserved envelope headers - never on the wire
1127            event_stream::X_EVENT_STREAM | event_stream::X_EVENT_NAME | "x-stream-id" => {}
1128            // idle-allowance override in seconds (producer head control)
1129            "x-ttl" => {
1130                if let Ok(seconds) = value.trim().parse::<u64>() {
1131                    if seconds > 0 {
1132                        idle_override = Some(Duration::from_secs(seconds));
1133                    }
1134                }
1135            }
1136            // in envelope mode the target's own headers stay inside the
1137            // envelope frames; only endpoint-level headers reach the wire
1138            _ if envelope_mode => {}
1139            "content-type" => content_type = Some(value.to_lowercase()),
1140            "set-cookie" => {
1141                set_cookies.extend(value.split('|').map(|c| c.trim().to_string()));
1142            }
1143            _ => {
1144                response_headers.insert(key, value.clone());
1145            }
1146        }
1147    }
1148    // the rest.yaml response transform applies to the streamed head exactly
1149    // as it does to a single-shot response (single-shot parity)
1150    if let Some(header_info) = &info.headers {
1151        header_info.response.apply(&mut response_headers);
1152    }
1153    // echo the business correlation-id; a function-set header of the same name wins
1154    response_headers
1155        .entry(cid_header.to_string())
1156        .or_insert_with(|| cid.to_string());
1157    if let Some(cors) = &info.cors {
1158        for (name, value) in &cors.headers {
1159            response_headers.insert(name.to_lowercase(), value.clone());
1160        }
1161    }
1162    // envelope mode is always SSE on the wire; raw mode negotiates
1163    let content_type = if envelope_mode {
1164        "text/event-stream".to_string()
1165    } else {
1166        content_type.unwrap_or_else(|| negotiate_stream_type(accept.as_deref()))
1167    };
1168    let sse = content_type.starts_with("text/event-stream");
1169    if sse {
1170        // default for SSE - an explicit event header or transform add wins
1171        response_headers
1172            .entry("cache-control".to_string())
1173            .or_insert_with(|| "no-cache".to_string());
1174    }
1175    let idle = idle_override.unwrap_or(base_idle);
1176    let mut builder = Response::builder().status(status_of(first.status()));
1177    for (name, value) in &response_headers {
1178        builder = builder.header(name, value);
1179    }
1180    for cookie in set_cookies {
1181        if !cookie.is_empty() {
1182            builder = builder.header("set-cookie", cookie);
1183        }
1184    }
1185    builder = builder.header("content-type", &content_type);
1186    let (body_tx, body_rx) = mpsc::channel::<Frame<Bytes>>(STREAM_FRAME_BUFFER);
1187    let response = builder
1188        .body(BoxBody::new(ChannelBody { rx: body_rx }))
1189        .map_err(|e| AppError::new(500, e.to_string()))?;
1190    tokio::spawn(render_stream(
1191        rx,
1192        body_tx,
1193        sse,
1194        idle,
1195        context_id,
1196        lane,
1197        first,
1198        marker,
1199        envelope_mode,
1200    ));
1201    Ok(StreamOutcome::Streaming(response))
1202}
1203
1204/// What the renderer observed while waiting for the next segment event.
1205/// (A short-lived by-value carrier on the per-segment path - boxing the
1206/// envelope would trade one stack move for a heap allocation per segment.)
1207#[allow(clippy::large_enum_variant)]
1208enum Waited {
1209    Event(EventEnvelope),
1210    Idle,
1211    Closed,
1212}
1213
1214/// Wait for the next event within the idle allowance, emitting SSE keep-alive
1215/// comments while the producer is quiet (best-effort; pings never extend the
1216/// idle allowance).
1217async fn next_stream_event(
1218    rx: &mut mpsc::Receiver<EventEnvelope>,
1219    body_tx: &mpsc::Sender<Frame<Bytes>>,
1220    sse: bool,
1221    idle: Duration,
1222) -> Waited {
1223    let ping_every = keep_alive_ms();
1224    let idle_deadline = tokio::time::sleep(idle);
1225    tokio::pin!(idle_deadline);
1226    loop {
1227        if sse && ping_every > 0 {
1228            let ping = tokio::time::sleep(Duration::from_millis(ping_every));
1229            tokio::pin!(ping);
1230            tokio::select! {
1231                received = rx.recv() => {
1232                    return match received {
1233                        Some(event) => Waited::Event(event),
1234                        None => Waited::Closed,
1235                    };
1236                }
1237                _ = &mut idle_deadline => return Waited::Idle,
1238                _ = &mut ping => {
1239                    let _ = body_tx.try_send(Frame::data(Bytes::from_static(b": ping\n\n")));
1240                }
1241            }
1242        } else {
1243            tokio::select! {
1244                received = rx.recv() => {
1245                    return match received {
1246                        Some(event) => Waited::Event(event),
1247                        None => Waited::Closed,
1248                    };
1249                }
1250                _ = &mut idle_deadline => return Waited::Idle,
1251            }
1252        }
1253    }
1254}
1255
1256/// Push one wire frame with back-pressure, bounded by the idle allowance —
1257/// a client that stops reading beyond it gets truncated (the missing
1258/// terminal event is the in-band truncation signal). Returns false when the
1259/// stream can no longer be written (client gone or too slow).
1260async fn push_frame(
1261    body_tx: &mpsc::Sender<Frame<Bytes>>,
1262    idle: Duration,
1263    context_id: &str,
1264    bytes: Bytes,
1265) -> bool {
1266    if bytes.is_empty() {
1267        return true;
1268    }
1269    match tokio::time::timeout(idle, body_tx.send(Frame::data(bytes))).await {
1270        Ok(Ok(())) => true,
1271        Ok(Err(_)) => {
1272            log::debug!("Client disconnected from event stream {context_id}");
1273            false
1274        }
1275        Err(_) => {
1276            log::error!("Closing event stream for {context_id} - client too slow");
1277            false
1278        }
1279    }
1280}
1281
1282/// The per-request renderer: consumes segment events from the reply lane and
1283/// writes SSE or chunked frames until end of transmission, an in-band error,
1284/// an idle timeout, or a gone/too-slow client. Always returns the lane to
1285/// the pool at the end (Java: closeContext, the termination funnel).
1286/// In envelope mode the wire is the hybrid dialect: envelope frames wherever
1287/// envelope semantics matter (the first event, the terminals, non-text
1288/// segments), raw SSE frames for plain text - and no cosmetic done/error
1289/// frames, because the decoded terminal envelope is the signal.
1290#[allow(clippy::too_many_arguments)]
1291async fn render_stream(
1292    mut rx: mpsc::Receiver<EventEnvelope>,
1293    body_tx: mpsc::Sender<Frame<Bytes>>,
1294    sse: bool,
1295    idle: Duration,
1296    context_id: String,
1297    lane: String,
1298    first: EventEnvelope,
1299    first_marker: &'static str,
1300    envelope_mode: bool,
1301) {
1302    let mut pending = Some((first, first_marker));
1303    let mut first_frame = true;
1304    loop {
1305        let (event, marker) = match pending.take() {
1306            Some(next) => next,
1307            None => match next_stream_event(&mut rx, &body_tx, sse, idle).await {
1308                Waited::Event(event) => match stream_marker(&event) {
1309                    Ok(Some(marker)) => (event, marker),
1310                    Ok(None) | Err(()) => {
1311                        log::warn!(
1312                            "Dropping event for {context_id} - invalid {} signal",
1313                            event_stream::X_EVENT_STREAM
1314                        );
1315                        continue;
1316                    }
1317                },
1318                Waited::Idle => {
1319                    // fail the stream in-band (Java housekeeper parity)
1320                    if envelope_mode {
1321                        let frame = idle_timeout_envelope_frame(idle);
1322                        let _ = push_frame(&body_tx, idle, &context_id, frame).await;
1323                    } else if sse {
1324                        let error = serde_json::json!({
1325                            "status": 408,
1326                            "message": format!("Timeout for {} seconds", idle.as_secs()),
1327                            "type": "error",
1328                        });
1329                        let frame = sse_frame(Some("error"), &error.to_string());
1330                        let _ = push_frame(&body_tx, idle, &context_id, frame).await;
1331                    }
1332                    break;
1333                }
1334                Waited::Closed => break,
1335            },
1336        };
1337        match marker {
1338            event_stream::DATA => {
1339                let bytes = if envelope_mode {
1340                    envelope_mode_data_frame(&event, first_frame)
1341                } else if sse {
1342                    if matches!(event.body(), rmpv::Value::Nil) {
1343                        Bytes::new()
1344                    } else {
1345                        sse_frame(stream_event_name(&event), &stream_text(event.body()))
1346                    }
1347                } else {
1348                    chunk_bytes(event.body())
1349                };
1350                first_frame = false;
1351                if !push_frame(&body_tx, idle, &context_id, bytes).await {
1352                    break;
1353                }
1354            }
1355            event_stream::EOF => {
1356                if envelope_mode {
1357                    let frame = envelope_wire_frame(&event);
1358                    let _ = push_frame(&body_tx, idle, &context_id, frame).await;
1359                } else if sse {
1360                    let text = if matches!(event.body(), rmpv::Value::Nil) {
1361                        "{}".to_string()
1362                    } else {
1363                        stream_text(event.body())
1364                    };
1365                    let frame = sse_frame(Some("done"), &text);
1366                    let _ = push_frame(&body_tx, idle, &context_id, frame).await;
1367                }
1368                break;
1369            }
1370            _ => {
1371                // in-band failure after the head is committed: envelope mode
1372                // frames the exact envelope; SSE renders an error event;
1373                // chunked mode truncates (Java parity)
1374                if envelope_mode {
1375                    let frame = envelope_wire_frame(&event);
1376                    let _ = push_frame(&body_tx, idle, &context_id, frame).await;
1377                } else if sse {
1378                    let status = if event.status() >= 400 {
1379                        event.status()
1380                    } else {
1381                        500
1382                    };
1383                    let error = serde_json::json!({
1384                        "status": status,
1385                        "message": stream_error_message(&event),
1386                        "type": "error",
1387                    });
1388                    let frame = sse_frame(Some("error"), &error.to_string());
1389                    let _ = push_frame(&body_tx, idle, &context_id, frame).await;
1390                }
1391                break;
1392            }
1393        }
1394    }
1395    cleanup_stream(&context_id, &lane);
1396}
1397
1398/// One envelope-mode data frame: the first event always rides an envelope
1399/// frame (it carries the head control), a losslessly raw-able text segment
1400/// rides a raw SSE frame, a bare no-op segment carries nothing, and anything
1401/// else takes the envelope-frame escape hatch.
1402fn envelope_mode_data_frame(event: &EventEnvelope, first_frame: bool) -> Bytes {
1403    if first_frame || !raw_streamable(event) {
1404        envelope_wire_frame(event)
1405    } else if matches!(event.body(), rmpv::Value::Nil) {
1406        Bytes::new()
1407    } else {
1408        sse_frame(stream_event_name(event), &stream_text(event.body()))
1409    }
1410}
1411
1412/// A data segment may ride a raw SSE frame only when the frame carries it
1413/// losslessly: a 200 status, no custom envelope headers, a user event name
1414/// clear of the reserved word, and a Nil-or-text body without a carriage
1415/// return (SSE normalizes line endings). Everything else takes the
1416/// envelope-frame escape hatch.
1417fn raw_streamable(event: &EventEnvelope) -> bool {
1418    if event.status() != 200 {
1419        return false;
1420    }
1421    for (name, value) in event.headers() {
1422        let key = name.to_lowercase();
1423        let reserved = key == event_stream::X_EVENT_STREAM
1424            || key == event_stream::X_EVENT_NAME
1425            || key == "x-ttl";
1426        if !reserved || (key == event_stream::X_EVENT_NAME && value == event_stream::ENVELOPE) {
1427            return false;
1428        }
1429    }
1430    match event.body() {
1431        rmpv::Value::Nil => true,
1432        rmpv::Value::String(text) => !text.as_str().unwrap_or_default().contains('\r'),
1433        _ => false,
1434    }
1435}
1436
1437/// The classic Event-over-HTTP single-shot wire: the whole reply envelope as
1438/// a serialized byte body with an octet-stream content type and outer status
1439/// 200 (the real status rides inside - Java sendResponse parity).
1440fn wire_single_shot(result: EventEnvelope) -> Result<EventEnvelope, AppError> {
1441    let bytes = result.clear_to().clear_reply_to().to_bytes()?;
1442    Ok(EventEnvelope::new()
1443        .set_status(200)
1444        .set_header("content-type", "application/octet-stream")
1445        .set_raw_body(rmpv::Value::Binary(bytes)))
1446}
1447
1448/// One envelope-mode wire frame: the envelope serialized verbatim - with the
1449/// server-internal addressing cleared, because the consuming relay rewrites
1450/// addressing to the original caller - as base64 under the reserved SSE event
1451/// name "envelope".
1452fn envelope_wire_frame(event: &EventEnvelope) -> Bytes {
1453    use base64::Engine as _;
1454    let wire = event.clone().clear_to().clear_reply_to();
1455    match wire.to_bytes() {
1456        Ok(bytes) => sse_frame(
1457            Some(event_stream::ENVELOPE),
1458            &base64::engine::general_purpose::STANDARD.encode(bytes),
1459        ),
1460        Err(_) => Bytes::new(),
1461    }
1462}
1463
1464/// The in-band idle-timeout terminal of an envelope-mode stream: an exception
1465/// envelope with the standard error key-values, framed for the wire
1466/// (Java housekeeper-abort parity).
1467fn idle_timeout_envelope_frame(idle: Duration) -> Bytes {
1468    let message = format!("Timeout for {} seconds", idle.as_secs());
1469    let error = EventEnvelope::new()
1470        .set_header(event_stream::X_EVENT_STREAM, event_stream::EXCEPTION)
1471        .set_status(408)
1472        .set_body(serde_json::json!({"type": "error", "status": 408, "message": message}));
1473    match error {
1474        Ok(envelope) => envelope_wire_frame(&envelope),
1475        Err(_) => Bytes::new(),
1476    }
1477}
1478
1479fn build_event(
1480    to: &str,
1481    http_request: &crate::automation::AsyncHttpRequest,
1482    cid: &str,
1483    trace_id: &Option<String>,
1484    trace_path: &str,
1485    parent_span: &Option<String>,
1486) -> Result<EventEnvelope, AppError> {
1487    let mut event = EventEnvelope::new()
1488        .set_to(to)
1489        .set_from("http.request")
1490        .set_correlation_id(cid)
1491        // the business correlation-id rides the engine-managed envelope tag
1492        // (never a header): it survives when the dispatch overwrites cid with
1493        // the HTTP context id, and the worker injects my_correlation_id into
1494        // the target function's input copy at delivery (Java parity)
1495        .add_tag(crate::post_office::BUSINESS_CID_TAG, cid)
1496        // the struct's to_value() IS the wire shape (single source of truth)
1497        // — a binary body rides natively as MsgPack binary (Java: byte[] on
1498        // the AsyncHttpRequest)
1499        .set_raw_body(http_request.to_value());
1500    if let Some(trace_id) = trace_id {
1501        event = event.set_trace(trace_id, trace_path);
1502        if let Some(parent) = parent_span {
1503            // the caller's span (from traceparent) becomes our parent
1504            event = event.set_span_id(parent);
1505        }
1506    }
1507    Ok(event)
1508}
1509
1510/// Outcome of the request-body dispatch (Java `HttpRouter.handlePayload`).
1511enum ParsedBody {
1512    /// JSON map/list, text, or null — representable in the JSON-shaped event.
1513    Value(serde_json::Value),
1514    /// `application/x-www-form-urlencoded` — fields become query parameters.
1515    Form(HashMap<String, String>),
1516    /// Unknown content type (Java `handleBinaryContent`) — raw bytes.
1517    Bytes(Vec<u8>),
1518}
1519
1520/// The content-type without any `;charset=...` suffix (Java
1521/// `CustomContentTypeResolver.getContentType`; the optional
1522/// `custom.content.types` mapping feature is deferred). Like Java, the
1523/// value is matched case-sensitively — only the header name is normalized.
1524fn base_content_type(headers: &HashMap<String, String>) -> Option<String> {
1525    headers
1526        .get("content-type")
1527        .map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
1528}
1529
1530/// Parse the request body by declared content type — the Java `HttpRouter`
1531/// dispatch (`handlePayload` + its per-type handlers), mirrored exactly:
1532///
1533/// - `application/json`: empty → `{}`; a body wrapped in matching JSON
1534///   brackets is parsed (a parse failure falls back to the raw text);
1535///   anything else stays the raw text. There is **no** JSON sniffing under
1536///   other content types.
1537/// - `application/xml`: raw text (the XML-to-map parse is deferred with the
1538///   rest of the XML surface, exactly like the HTTP client's response side).
1539/// - `application/x-www-form-urlencoded` (exact match): fields decode into
1540///   query parameters; the body stays null.
1541/// - `text/html` / `text/plain`: raw text.
1542/// - anything else — including a missing content type: raw bytes (Java
1543///   `handleBinaryContent`; its no-content-length streaming variant is the
1544///   existing response-streaming deferral — hyper hands us the aggregated
1545///   body, matching Java's fixed-length path). An empty payload stays null.
1546fn parse_body(headers: &HashMap<String, String>, bytes: &Bytes) -> ParsedBody {
1547    let content_type = base_content_type(headers);
1548    let ct = content_type.as_deref().unwrap_or("?");
1549    if ct.starts_with("application/json") {
1550        let text = String::from_utf8_lossy(bytes).to_string();
1551        let trimmed = text.trim();
1552        let parsed = if trimmed.is_empty() {
1553            Some(serde_json::Value::Object(serde_json::Map::new()))
1554        } else if (trimmed.starts_with('{') && trimmed.ends_with('}'))
1555            || (trimmed.starts_with('[') && trimmed.ends_with(']'))
1556        {
1557            serde_json::from_str(&text).ok()
1558        } else {
1559            None
1560        };
1561        ParsedBody::Value(parsed.unwrap_or(serde_json::Value::String(text)))
1562    } else if ct == "application/x-www-form-urlencoded" {
1563        let text = String::from_utf8_lossy(bytes);
1564        let mut form = HashMap::new();
1565        for pair in text.split('&').filter(|p| !p.is_empty()) {
1566            let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
1567            form.insert(url_decode(name), url_decode(value));
1568        }
1569        ParsedBody::Form(form)
1570    } else if ct.starts_with("application/xml")
1571        || ct.starts_with("text/html")
1572        || ct.starts_with("text/plain")
1573    {
1574        ParsedBody::Value(serde_json::Value::String(
1575            String::from_utf8_lossy(bytes).to_string(),
1576        ))
1577    } else if bytes.is_empty() {
1578        ParsedBody::Value(serde_json::Value::Null)
1579    } else {
1580        ParsedBody::Bytes(bytes.to_vec())
1581    }
1582}
1583
1584/// Minimal percent-decoding (+ `+` → space) for path/query values.
1585fn url_decode(text: &str) -> String {
1586    let bytes = text.as_bytes();
1587    let mut out = Vec::with_capacity(bytes.len());
1588    let mut i = 0;
1589    while i < bytes.len() {
1590        match bytes[i] {
1591            b'+' => {
1592                out.push(b' ');
1593                i += 1;
1594            }
1595            b'%' if i + 2 < bytes.len() => {
1596                let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
1597                match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
1598                    Some(byte) => {
1599                        out.push(byte);
1600                        i += 3;
1601                    }
1602                    None => {
1603                        out.push(bytes[i]);
1604                        i += 1;
1605                    }
1606                }
1607            }
1608            other => {
1609                out.push(other);
1610                i += 1;
1611            }
1612        }
1613    }
1614    String::from_utf8_lossy(&out).to_string()
1615}
1616
1617/// The built-in default endpoints (Java `default-rest.yaml`): added only when
1618/// `rest.yaml` does not already claim the URL — user entries always win.
1619/// Shipped as a real resource file embedded at compile time (the
1620/// default-log-context.yaml pattern), so it is discoverable where a Java
1621/// developer expects it and byte-diffable against the Java repo's copy.
1622/// `/info/lib` is the one deferred Java default (see the actuator module doc).
1623const DEFAULT_REST_YAML: &str = include_str!("../../resources/default-rest.yaml");
1624
1625fn merge_default_endpoints(table: &mut RoutingTable) -> Result<(), AppError> {
1626    let defaults = RoutingTable::from_yaml_text(DEFAULT_REST_YAML)?;
1627    for route in defaults.routes() {
1628        if !table.has_url(&route.url) {
1629            table.add_route(route.clone());
1630        }
1631    }
1632    Ok(())
1633}
1634
1635/// Serve static HTML content from the `resources/public` folder with the
1636/// full Java static-content behavior:
1637///
1638/// 1. **path resolution** (Java `getStaticFile`): `/` and trailing-`/` paths
1639///    resolve to `index.html`; an extensionless filename assumes `.html`;
1640///    parent traversal is rejected;
1641/// 2. **optional request filter** (`static-content.filter`): a composable
1642///    function inspects matching requests (e.g. SSO redirection for a UI
1643///    bundle) — its response **headers are always copied** onto the HTTP
1644///    response; status 200 continues to serve, any other status (or a
1645///    redirect) passes the filter's response through;
1646/// 3. **no-cache pages** (`static-content.no-cache-pages`, default `/` and
1647///    `/index.html`): `Cache-Control: no-cache, no-store` + `Pragma` +
1648///    `Expires` instead of caching — entry pages must always revalidate;
1649/// 4. **etag protocol** for everything else: a quoted SHA-256 content hash;
1650///    a matching `If-None-Match` (comma-list aware) → **HTTP 304** with an
1651///    empty body.
1652async fn serve_static(
1653    state: &RouterState,
1654    path: &str,
1655    query_text: &str,
1656    headers: &HashMap<String, String>,
1657    peer: SocketAddr,
1658    head_only: bool,
1659) -> Option<Response<HttpBody>> {
1660    let (bytes, filename) = resolve_static_file(path)?;
1661    let static_content = state.table.static_content();
1662    let no_cache = super::routing::matched_element(&static_content.no_cache_pages, path);
1663    // the optional request filter (Java handleFilter)
1664    let mut filter_headers: Vec<(String, String)> = Vec::new();
1665    if let Some(filter) = &static_content.filter {
1666        let applies = super::routing::matched_element(&filter.path_list, path)
1667            && !super::routing::matched_element(&filter.exclusion_list, path);
1668        if applies {
1669            if state.platform.has_route(&filter.service) {
1670                match run_static_filter(state, filter, path, query_text, headers, peer).await {
1671                    Ok(filtered) => {
1672                        // the filter may set HTTP response headers (Java parity)
1673                        for (name, value) in filtered.headers() {
1674                            filter_headers.push((name.clone(), value.clone()));
1675                        }
1676                        if filtered.status() != 200 {
1677                            // redirect / rejection: pass the filter's response through
1678                            let (content_type, payload) = envelope_payload(&filtered);
1679                            let mut response =
1680                                Response::builder().status(status_of(filtered.status()));
1681                            let mut has_content_type = false;
1682                            for (name, value) in &filter_headers {
1683                                has_content_type |= name.eq_ignore_ascii_case("content-type");
1684                                response = response.header(name, value);
1685                            }
1686                            if let (Some(content_type), false) = (content_type, has_content_type) {
1687                                response = response.header("content-type", content_type);
1688                            }
1689                            return response.body(full(payload)).ok();
1690                        }
1691                    }
1692                    Err(e) => {
1693                        // resilient divergence from Java (which leaves the request
1694                        // to time out): log and serve the static file anyway
1695                        log::error!(
1696                            "Unable to filter static content HTTP-GET {} - {}",
1697                            filter.service,
1698                            e.message()
1699                        );
1700                    }
1701                }
1702            } else {
1703                log::warn!(
1704                    "Static content filter {} ignored because it does not exist",
1705                    filter.service
1706                );
1707            }
1708        }
1709    }
1710    // serve the file: no-cache headers or the etag protocol
1711    let mime = mime_for(
1712        std::path::Path::new(&filename)
1713            .extension()
1714            .and_then(|e| e.to_str())
1715            .unwrap_or(""),
1716    );
1717    let mut response = Response::builder().status(StatusCode::OK);
1718    for (name, value) in &filter_headers {
1719        response = response.header(name, value);
1720    }
1721    response = response.header("content-type", mime);
1722    if no_cache {
1723        response = response
1724            .header("Cache-Control", "no-cache, no-store")
1725            .header("Pragma", "no-cache")
1726            .header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT");
1727    } else {
1728        use sha2::Digest;
1729        let etag = format!("\"{:x}\"", sha2::Sha256::digest(&bytes));
1730        // If-None-Match may carry a comma-separated list (Java EtagFile.sameTag)
1731        let matched = headers
1732            .get("if-none-match")
1733            .is_some_and(|inm| inm.split(',').any(|tag| tag.trim() == etag));
1734        if matched {
1735            return Response::builder()
1736                .status(StatusCode::NOT_MODIFIED)
1737                .header("content-length", "0")
1738                .body(full(Bytes::new()))
1739                .ok();
1740        }
1741        response = response.header("ETag", etag);
1742    }
1743    let payload = if head_only {
1744        Bytes::new()
1745    } else {
1746        Bytes::from(bytes)
1747    };
1748    response.body(full(payload)).ok()
1749}
1750
1751/// Resolve a request path to a file under `resources/public`
1752/// (Java `getStaticFile` rules).
1753fn resolve_static_file(path: &str) -> Option<(Vec<u8>, String)> {
1754    if path.contains("..") {
1755        return None; // traversal guard
1756    }
1757    let rel = path.trim_start_matches('/');
1758    let relative = if rel.is_empty() || path.ends_with('/') {
1759        format!("{rel}/index.html")
1760            .trim_start_matches('/')
1761            .to_string()
1762    } else {
1763        let filename = rel.rsplit('/').next().unwrap_or(rel);
1764        if filename.contains('.') {
1765            rel.to_string()
1766        } else {
1767            format!("{rel}.html") // assume .html for extensionless paths
1768        }
1769    };
1770    let file = crate::util::resources::resolve_classpath(&format!("public/{relative}"))?;
1771    let bytes = std::fs::read(&file).ok()?;
1772    let filename = relative.rsplit('/').next().unwrap_or(&relative).to_string();
1773    Some((bytes, filename))
1774}
1775
1776/// Invoke the static-content filter with an AsyncHttpRequest-shaped event
1777/// (no body, no path parameters — Java `createHttpRequest`).
1778async fn run_static_filter(
1779    state: &RouterState,
1780    filter: &super::routing::SimpleHttpFilter,
1781    path: &str,
1782    query_text: &str,
1783    headers: &HashMap<String, String>,
1784    peer: SocketAddr,
1785) -> Result<EventEnvelope, AppError> {
1786    // the same single source of truth as the main dispatch: the filter's
1787    // request dataset is constructed through AsyncHttpRequest and rendered
1788    // by its to_value()
1789    let mut request = crate::automation::AsyncHttpRequest::new()
1790        .set_method("GET")
1791        .set_url(path)
1792        .set_remote_ip(&peer.ip().to_string())
1793        .set_secure(false)
1794        .set_target_host(&headers.get("host").cloned().unwrap_or_default())
1795        .set_body(rmpv::Value::Nil);
1796    for (key, value) in headers {
1797        request = request.set_header(key, value);
1798    }
1799    for pair in query_text.split('&').filter(|p| !p.is_empty()) {
1800        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
1801        request = request.set_query_parameter(&url_decode(name), &url_decode(value));
1802    }
1803    let event = EventEnvelope::new()
1804        .set_to(&filter.service)
1805        .set_raw_body(request.to_value());
1806    let po = PostOffice::new(&state.platform);
1807    // Java FILTER_TIMEOUT = 10 seconds
1808    po.request(event, std::time::Duration::from_secs(10)).await
1809}
1810
1811/// Map an envelope body to HTTP payload + content type (shared by the normal
1812/// dispatch and the filter pass-through).
1813/// The fallback response content type from the request's Accept header —
1814/// Java `AsyncHttpResponse.updateContentType` (increment 56): html → html,
1815/// json or `*/*` → json, no Accept → NO content-type header at all; anything
1816/// else → text/plain. Java's `application/xml` branch renders XML, which this
1817/// port defers (D10) — an xml Accept negotiates JSON instead, never claiming
1818/// xml on the wire.
1819fn accept_fallback_type(accept: Option<&str>, _body: &rmpv::Value) -> Option<String> {
1820    let accept = accept?;
1821    if accept.contains("text/html") {
1822        Some("text/html".to_string())
1823    } else if accept.contains("application/json")
1824        || accept.contains("*/*")
1825        || accept.contains("application/xml")
1826    {
1827        Some("application/json".to_string())
1828    } else {
1829        Some("text/plain".to_string())
1830    }
1831}
1832
1833/// Render the response body per the effective content type — Java
1834/// `AsyncHttpResponse.handleContent`: strings and bytes ride raw regardless
1835/// of the negotiated type; map/list bodies render as JSON, wrapped in
1836/// `<html><body><pre>` when the effective type is text/html
1837/// (`handleMapContent`/`handleArrayContent`).
1838fn render_payload(body: &rmpv::Value, content_type: Option<&str>) -> Bytes {
1839    match body {
1840        rmpv::Value::Nil => Bytes::new(),
1841        rmpv::Value::String(text) => Bytes::from(text.as_str().unwrap_or_default().to_string()),
1842        rmpv::Value::Binary(bytes) => Bytes::from(bytes.clone()),
1843        _ => {
1844            // Omit Nil map entries unless serializer.null.transport=true (Java Gson parity).
1845            let stripped = crate::serializer::strip_nulls(body);
1846            let json = serde_json::to_value(&stripped).unwrap_or_default();
1847            // PRETTY-printed (presentation parity, 2026-07-26): Java renders
1848            // map/list bodies through SimpleMapper's default mapper, a
1849            // pretty-printing Gson (2-space indent) — interop drives showed
1850            // Java echoes multi-line and Rust echoes single-line. serde_json's
1851            // pretty writer matches the Gson shape. The HTML shell wraps the
1852            // same pretty text (Java AsyncHttpResponse HTML_START + text).
1853            let text = serde_json::to_string_pretty(&json).unwrap_or_default();
1854            if content_type.is_some_and(|t| t.starts_with("text/html"))
1855                && matches!(body, rmpv::Value::Map(_) | rmpv::Value::Array(_))
1856            {
1857                Bytes::from(format!("<html><body><pre>\n{text}\n</pre></body></html>"))
1858            } else {
1859                Bytes::from(text)
1860            }
1861        }
1862    }
1863}
1864
1865fn envelope_payload(result: &EventEnvelope) -> (Option<&'static str>, Bytes) {
1866    match result.body() {
1867        rmpv::Value::Nil => (None, Bytes::new()),
1868        rmpv::Value::String(text) => (
1869            Some("text/plain"),
1870            Bytes::from(text.as_str().unwrap_or_default().to_string()),
1871        ),
1872        rmpv::Value::Binary(bytes) => {
1873            (Some("application/octet-stream"), Bytes::from(bytes.clone()))
1874        }
1875        _ => {
1876            // Omit Nil map entries unless serializer.null.transport=true (Java Gson parity).
1877            let body = crate::serializer::strip_nulls(result.body());
1878            let json = serde_json::to_value(&body).unwrap_or_default();
1879            // pretty-printed like render_payload (Java SimpleMapper parity)
1880            (
1881                Some("application/json"),
1882                Bytes::from(serde_json::to_string_pretty(&json).unwrap_or_default()),
1883            )
1884        }
1885    }
1886}
1887
1888fn status_of(code: i32) -> StatusCode {
1889    StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
1890}
1891
1892/// Minimal content-type resolution by extension (the Java `MimeTypeResolver`
1893/// analog; `mime-types.yml` customization is deferred).
1894fn mime_for(extension: &str) -> &'static str {
1895    match extension.to_ascii_lowercase().as_str() {
1896        "html" | "htm" => "text/html",
1897        "css" => "text/css",
1898        "js" | "mjs" => "text/javascript",
1899        "json" => "application/json",
1900        "png" => "image/png",
1901        "jpg" | "jpeg" => "image/jpeg",
1902        "gif" => "image/gif",
1903        "svg" => "image/svg+xml",
1904        "ico" => "image/x-icon",
1905        "txt" => "text/plain",
1906        "pdf" => "application/pdf",
1907        "woff2" => "font/woff2",
1908        "xml" => "application/xml",
1909        _ => "application/octet-stream",
1910    }
1911}
1912
1913/// The Java error shape: `{"status": n, "message": "...", "type": "error"}`.
1914fn error_response(status: i32, message: &str) -> Response<HttpBody> {
1915    let body = serde_json::json!({"status": status, "message": message, "type": "error"});
1916    Response::builder()
1917        .status(StatusCode::from_u16(status as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
1918        .header("content-type", "application/json")
1919        .body(full(Bytes::from(body.to_string())))
1920        .expect("static response")
1921}
1922
1923#[cfg(test)]
1924mod tests {
1925    use super::*;
1926
1927    #[test]
1928    fn url_decoding() {
1929        assert_eq!(url_decode("hello%20world"), "hello world");
1930        assert_eq!(url_decode("a+b"), "a b");
1931        assert_eq!(url_decode("plain"), "plain");
1932        assert_eq!(url_decode("bad%zz"), "bad%zz");
1933    }
1934
1935    fn headers_of(content_type: &str) -> HashMap<String, String> {
1936        HashMap::from([("content-type".to_string(), content_type.to_string())])
1937    }
1938
1939    fn value_of(parsed: ParsedBody) -> serde_json::Value {
1940        match parsed {
1941            ParsedBody::Value(value) => value,
1942            ParsedBody::Form(_) => panic!("expected a value, got form fields"),
1943            ParsedBody::Bytes(_) => panic!("expected a value, got bytes"),
1944        }
1945    }
1946
1947    /// The dispatch mirrors Java `HttpRouter.handlePayload` exactly — see the
1948    /// `parse_body` doc for the per-content-type rules being asserted here.
1949    #[test]
1950    fn body_parsing() {
1951        // application/json: bracket-wrapped bodies parse; charset suffix ignored
1952        let json = headers_of("application/json; charset=utf-8");
1953        let value = value_of(parse_body(&json, &Bytes::from(r#"{"a":1}"#)));
1954        assert_eq!(value["a"], 1);
1955        // a non-JSON body under application/json stays the raw text (no error)
1956        let text = value_of(parse_body(&json, &Bytes::from("import graph from x")));
1957        assert_eq!(
1958            text,
1959            serde_json::Value::String("import graph from x".into())
1960        );
1961        // malformed JSON falls back to the raw text
1962        let bad = value_of(parse_body(&json, &Bytes::from("{broken")));
1963        assert_eq!(bad, serde_json::Value::String("{broken".into()));
1964        // an empty application/json body is an empty map
1965        let empty = value_of(parse_body(&json, &Bytes::new()));
1966        assert_eq!(empty, serde_json::json!({}));
1967        // no JSON sniffing under text/plain: a JSON-looking body stays text
1968        let plain = headers_of("text/plain");
1969        let unsniffed = value_of(parse_body(&plain, &Bytes::from(r#"{"a":1}"#)));
1970        assert_eq!(unsniffed, serde_json::Value::String(r#"{"a":1}"#.into()));
1971        // XML rides as raw text (parser deferral, like the client's response side)
1972        let xml = value_of(parse_body(
1973            &headers_of("application/xml"),
1974            &Bytes::from("<a>1</a>"),
1975        ));
1976        assert_eq!(xml, serde_json::Value::String("<a>1</a>".into()));
1977        // form fields decode into query parameters, not the body
1978        let form = parse_body(
1979            &headers_of("application/x-www-form-urlencoded"),
1980            &Bytes::from("a=1&b=hello+world"),
1981        );
1982        match form {
1983            ParsedBody::Form(fields) => {
1984                assert_eq!(fields["a"], "1");
1985                assert_eq!(fields["b"], "hello world");
1986            }
1987            _ => panic!("expected form fields"),
1988        }
1989        // unknown or missing content type: bytes (Java handleBinaryContent)
1990        match parse_body(&HashMap::new(), &Bytes::from("hello")) {
1991            ParsedBody::Bytes(bytes) => assert_eq!(bytes, b"hello"),
1992            _ => panic!("expected bytes for a missing content type"),
1993        }
1994        // ...and an empty unknown-type payload leaves the body null
1995        assert_eq!(
1996            value_of(parse_body(&HashMap::new(), &Bytes::new())),
1997            serde_json::Value::Null
1998        );
1999    }
2000}