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