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