platform_core/automation/event_api.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//! Event over HTTP (increment 61) — Rust port of Java
18//! `org.platformlambda.core.services.EventApiService` + the `EventEmitter`
19//! event-over-http client. A serialized [`EventEnvelope`] (the **standard**
20//! wire format, increment 59) is exchanged over `POST /api/event`, so a
21//! function in one application instance can call a **public** function
22//! (`is_private = false`) in another — the only cross-instance coupling, and
23//! opt-in by design.
24//!
25//! The service is reached through REST automation (the `/api/event` entry
26//! ships in the default `rest.yaml`, merged like the actuators). It decodes
27//! the posted envelope, enforces the visibility boundary (403 for a private
28//! target — a remote caller must never reach engine internals or an
29//! unpublished function), and dispatches: async (`x-async: true`) is
30//! drop-n-forget with a 202 ack; otherwise RPC up to `x-ttl` ms.
31
32use std::collections::HashMap;
33use std::sync::OnceLock;
34use std::time::Duration;
35
36use async_trait::async_trait;
37use rmpv::Value;
38
39use crate::automation::http_client::{AsyncHttpRequest, ASYNC_HTTP_REQUEST};
40use crate::envelope::EventEnvelope;
41use crate::function::{AppError, ComposableFunction};
42use crate::platform::Platform;
43use crate::post_office::PostOffice;
44use crate::util::app_config_reader::AppConfigReader;
45use crate::util::config_reader::ConfigReader;
46use crate::util::multi_level_map::ConfigValue;
47use crate::util::w3c_trace;
48
49/// Route of the event-over-http service (Java `EventApiService.EVENT_API_SERVICE`).
50pub const EVENT_API_SERVICE: &str = "event.api.service";
51
52/// Envelope header marking an event that already crossed an Event-over-HTTP
53/// hop (Java `EventEmitter.X_EVENT_API`) — the recursion guard: the send and
54/// request hooks never re-forward an event carrying it, so a declaratively
55/// routed event crosses the wire exactly once. Visible to the receiving
56/// function like any other envelope header.
57pub const X_EVENT_API: &str = "x-event-api";
58
59const OCTET_STREAM: &str = "application/octet-stream";
60const TEXT_EVENT_STREAM: &str = "text/event-stream";
61const STREAM_CALLER_REQUIRED: &str =
62 "Streaming function requires a caller that accepts text/event-stream";
63const X_TTL: &str = "x-ttl";
64const X_ASYNC: &str = "x-async";
65/// Java `EventEmitter.X_NO_STREAM`: instructs the receiving side to return a
66/// small payload as bytes — part of the /api/event request's wire header set.
67const X_NO_STREAM: &str = "x-small-payload-as-bytes";
68
69/// Application key naming the declarative routing map
70/// (Java `EventEmitter.EVENT_OVER_HTTP_YAML`).
71const EVENT_OVER_HTTP_YAML: &str = "yaml.event.over.http";
72const DEFAULT_EVENT_OVER_HTTP_YAML: &str = "classpath:/event-over-http.yaml";
73
74/// Fixed forward timeout for the send-path (fire-and-forget / callback) hook
75/// (Java `EventEmitter.ASYNC_EVENT_HTTP_TIMEOUT` = 60s).
76const ASYNC_EVENT_HTTP_TIMEOUT: Duration = Duration::from_secs(60);
77
78/// The `/api/event` service (Java `EventApiService`). Registered PRIVATE — it
79/// is reached only through the REST boundary, never as a remote target.
80pub struct EventApiService {
81 platform: Platform,
82}
83
84impl EventApiService {
85 pub fn new(platform: &Platform) -> Self {
86 EventApiService {
87 platform: platform.clone(),
88 }
89 }
90}
91
92#[async_trait]
93impl ComposableFunction for EventApiService {
94 async fn handle_event(
95 &self,
96 _headers: HashMap<String, String>,
97 input: EventEnvelope,
98 _instance: usize,
99 ) -> Result<EventEnvelope, AppError> {
100 // registered as an event interceptor (Java @EventInterceptor parity):
101 // replies are sent manually to the edge's reply route with the edge
102 // context id, so the streaming branch can withhold its auto-reply
103 let Some(reply_to) = input.reply_to().map(str::to_string) else {
104 return Ok(EventEnvelope::new());
105 };
106 let context_id = input.correlation_id().unwrap_or_default().to_string();
107 let po = PostOffice::new(&self.platform);
108 if let Some(response) = self.dispatch(&po, &reply_to, &context_id, input).await? {
109 let _ = po
110 .send(response.set_to(&reply_to).set_correlation_id(&context_id))
111 .await;
112 }
113 Ok(EventEnvelope::new())
114 }
115}
116
117impl EventApiService {
118 /// Decode, validate and dispatch one /api/event call. Returns the reply
119 /// envelope for the single-shot modes, or None when the streaming relay
120 /// rewired the inner request onto the edge's reply lane.
121 async fn dispatch(
122 &self,
123 po: &PostOffice,
124 reply_to: &str,
125 context_id: &str,
126 input: EventEnvelope,
127 ) -> Result<Option<EventEnvelope>, AppError> {
128 let request = AsyncHttpRequest::from_value(input.body());
129 let timeout_ms = request
130 .header(X_TTL)
131 .and_then(|v| v.parse::<u64>().ok())
132 .unwrap_or(0)
133 .max(1000);
134 let is_async = request.header(X_ASYNC) == Some("true");
135 let accepts_sse = request
136 .header("accept")
137 .is_some_and(|accept| accept.contains(TEXT_EVENT_STREAM));
138 // on the streaming-capable path the edge dispatched through a reply
139 // lane (envelope mode) and wraps every single-shot lane reply into the
140 // classic wire itself - so errors ride RAW here, exactly once wrapped;
141 // the other paths pack the classic wire as before
142 let capable = accepts_sse && !is_async;
143 let answer = |status: i32, message: &str| -> EventEnvelope {
144 if capable {
145 EventEnvelope::new()
146 .set_status(status)
147 .set_raw_body(Value::from(message))
148 } else {
149 reply(status, error_envelope(status, message))
150 }
151 };
152 // the HTTP body is the serialized envelope; octet-stream arrives as a
153 // MsgPack-binary body on the request map (Java parity)
154 let Value::Binary(bytes) = request.body() else {
155 return Ok(Some(if capable {
156 answer(500, "Invalid event-over-http data format")
157 } else {
158 reply(500, b"Invalid event-over-http data format".to_vec())
159 }));
160 };
161 // v1 accepts the standard wire format only (phase-2 decision); a
162 // compact (all single-char keys) envelope is rejected clearly
163 if is_compact_envelope(bytes) {
164 return Ok(Some(answer(
165 400,
166 "compact format not supported - set event.over.http.format=standard on the sender",
167 )));
168 }
169 let inner = match EventEnvelope::from_bytes(bytes) {
170 Ok(envelope) => envelope,
171 // the format is unknown when decode fails (Java falls back to a
172 // compact error reply; we answer 400 with a plain message)
173 Err(e) => return Ok(Some(answer(400, e.message()))),
174 };
175 // an inbound '@origin' suffix from a legacy/mesh-era peer is parsed
176 // away — this port never generates one (Eric's ruling)
177 let Some(to) = inner
178 .to()
179 .map(|to| crate::platform::bare_route(to).to_string())
180 else {
181 return Ok(Some(answer(400, "Missing routing path")));
182 };
183 // session info injected by an authentication service on this /api/event
184 // entry rides to the target function as read-only headers (Java parity:
185 // sessionInfo.forEach(request::setHeader))
186 let mut inner = inner;
187 for (key, value) in request.session() {
188 inner = inner.set_header(key, value);
189 }
190 if !self.platform.has_route(&to) {
191 return Ok(Some(answer(404, &format!("Route {to} not found"))));
192 }
193 if self.platform.is_private(&to) == Some(true) {
194 return Ok(Some(answer(403, &format!("{to} is private"))));
195 }
196 if is_async {
197 // drop-n-forget: deliver and acknowledge (Java 202 ack shape)
198 po.send(inner).await?;
199 let ack = EventEnvelope::new()
200 .set_status(202)
201 .set_body(serde_json::json!({
202 "type": "async",
203 "delivered": true,
204 "time": crate::trace::iso8601_utc_now(),
205 }))?;
206 Ok(Some(reply(200, ack.to_bytes()?)))
207 } else if accepts_sse {
208 // streaming-capable relay: the edge dispatched this call through a
209 // dedicated reply lane (stream_dispatch, envelope mode) - rewire
210 // the inner request onto that lane so the target streams straight
211 // to it, with the edge context id as the correlation id (exactly
212 // the rewrite the RPC inbox would make). A non-streaming target's
213 // single reply takes the same lane and renders byte-identical to
214 // the classic RPC response.
215 let inner = inner.set_reply_to(reply_to).set_correlation_id(context_id);
216 po.send(inner).await?;
217 Ok(None)
218 } else {
219 // RPC: forward and mirror the target's envelope back (or 408).
220 // A streaming reply cannot ride a single-shot response: answer
221 // with an explicit refusal instead of a truncated first segment.
222 match po.request(inner, Duration::from_millis(timeout_ms)).await {
223 Ok(result) => {
224 if has_stream_marker(&result) {
225 Ok(Some(reply(
226 406,
227 error_envelope(406, STREAM_CALLER_REQUIRED),
228 )))
229 } else {
230 Ok(Some(reply(200, result.to_bytes()?)))
231 }
232 }
233 Err(e) => Ok(Some(reply(408, error_envelope(408, e.message())))),
234 }
235 }
236 }
237}
238
239/// True when the envelope carries the reserved x-event-stream marker.
240fn has_stream_marker(event: &EventEnvelope) -> bool {
241 event
242 .headers()
243 .iter()
244 .any(|(name, _)| name.eq_ignore_ascii_case(crate::event_stream::X_EVENT_STREAM))
245}
246
247/// Build the HTTP response envelope: the body is a serialized envelope carried
248/// raw as `application/octet-stream` (Java `sendResponse`/`sendError`). The
249/// outer status is 200 for a successful dispatch (the real result status rides
250/// inside the serialized body) and the error code for a service-level error.
251fn reply(http_status: i32, body: Vec<u8>) -> EventEnvelope {
252 EventEnvelope::new()
253 .set_status(http_status)
254 .set_header("content-type", OCTET_STREAM)
255 .set_raw_body(Value::Binary(body))
256}
257
258/// A serialized error envelope (status + message body) — the payload the
259/// client deserializes and hands back to its caller.
260fn error_envelope(status: i32, message: &str) -> Vec<u8> {
261 EventEnvelope::new()
262 .set_status(status)
263 .set_raw_body(Value::from(message))
264 .to_bytes()
265 .unwrap_or_default()
266}
267
268/// A compact (legacy Java) envelope has ONLY single-character top-level keys;
269/// the standard format's keys are all longer, so the namespaces are disjoint.
270fn is_compact_envelope(bytes: &[u8]) -> bool {
271 match rmp_serde::from_slice::<Value>(bytes) {
272 Ok(Value::Map(entries)) if !entries.is_empty() => entries
273 .iter()
274 .all(|(k, _)| k.as_str().is_some_and(|s| s.chars().count() == 1)),
275 _ => false,
276 }
277}
278
279/// Event-over-http client (Java `EventEmitter.asyncRequest`/`eRequest` over an
280/// endpoint): POST `event` to `{endpoint}` as a serialized standard envelope.
281/// `rpc = true` awaits the target's reply up to `timeout`; `rpc = false` is
282/// drop-n-forget (the 202 ack envelope is returned). Trace context propagates
283/// via `x-trace-id` + W3C `traceparent` so cross-language traces chain.
284pub async fn event_over_http(
285 po: &PostOffice,
286 endpoint: &str,
287 event: EventEnvelope,
288 timeout: Duration,
289 rpc: bool,
290) -> Result<EventEnvelope, AppError> {
291 static NO_HEADERS: OnceLock<HashMap<String, String>> = OnceLock::new();
292 event_over_http_with_headers(
293 po,
294 endpoint,
295 event,
296 timeout,
297 rpc,
298 NO_HEADERS.get_or_init(HashMap::new),
299 )
300 .await
301}
302
303/// [`event_over_http`] with additional per-call HTTP headers — the carrier of
304/// the per-target security headers (e.g. `authorization`) declared in
305/// `yaml.event.over.http` (Java `EventEmitter.asyncRequest(event, timeout,
306/// headers, endpoint, rpc)`).
307pub async fn event_over_http_with_headers(
308 po: &PostOffice,
309 endpoint: &str,
310 event: EventEnvelope,
311 timeout: Duration,
312 rpc: bool,
313 security_headers: &HashMap<String, String>,
314) -> Result<EventEnvelope, AppError> {
315 let (host, path) = split_endpoint(endpoint)?;
316 // stamp the calling function's trace context onto the WIRE envelope
317 // (fill-if-absent trace id/path; the caller's span unconditionally) —
318 // Java parity: the trace-aware po.request(..., endpoint, rpc) touches the
319 // event before serialization, so the remote function parents onto the
320 // caller's span. The declarative hook already applied this in request();
321 // a second application is idempotent. A PROGRAMMATIC caller passing a
322 // fresh envelope gets the same lineage automatically.
323 let event = crate::post_office::apply_current_trace(event);
324 let trace_id = event.trace_id().map(str::to_string);
325 let span_id = event.span_id().map(str::to_string);
326 let payload = event.to_bytes()?;
327 let mut http = AsyncHttpRequest::new()
328 .set_method("POST")
329 .set_url(&path)
330 .set_target_host(&host)
331 .set_header("content-type", OCTET_STREAM)
332 // Java EventEmitter wire parity: both engines' /api/event requests
333 // carry the same header set (x-small-payload-as-bytes + accept)
334 .set_header(X_NO_STREAM, "true")
335 .set_header("accept", "*/*")
336 .set_header(X_TTL, &timeout.as_millis().max(1000).to_string())
337 // a client-side instruction, consumed by the async HTTP client and
338 // never sent to the peer (the x-event-format precedent in Java):
339 // this is the engine's own Event-over-HTTP transport leg, so the
340 // client must not stamp the business correlation-id header on it —
341 // the business cid rides INSIDE the envelope (my_cid tag; Java's
342 // EventEmitter leg carries no ambient business context)
343 .set_header(X_EVENT_API, "true")
344 .set_body(Value::Binary(payload));
345 if !rpc {
346 http = http.set_header(X_ASYNC, "true");
347 }
348 // per-target security headers ride the HTTP call; the framework headers
349 // above stay authoritative on a name clash (first match wins on read)
350 for (key, value) in security_headers {
351 http = http.set_header(key, value);
352 }
353 http = stamp_trace_headers(http, trace_id.as_deref(), span_id.as_deref());
354 let http_event = EventEnvelope::new()
355 .set_to(ASYNC_HTTP_REQUEST)
356 .set_raw_body(http.to_value());
357 // the local wait gets a small grace over the remote TTL (Java parity:
358 // EventEmitter's inner deadline is timeout + 100ms) so a peer that spends
359 // its whole TTL still replies in-band — its 408 envelope must win the
360 // race against the local abort, never lose it. This internal RPC bypasses
361 // the declarative hook (request_direct) — the HTTP client leg of a
362 // forward must never consult the registry itself.
363 let response = po
364 .request_direct(http_event, timeout + Duration::from_millis(100))
365 .await?;
366 // the response body is the serialized reply envelope (octet-stream →
367 // binary); a non-envelope response — e.g. an authentication-layer 401 in
368 // the REST error JSON shape, produced before the Event API service ever
369 // ran — is returned as-is with its HTTP status (Java parity:
370 // EventEmitter.handleFutureResponse)
371 match response.body() {
372 Value::Binary(bytes) => EventEnvelope::from_bytes(bytes),
373 _ => Ok(response),
374 }
375}
376
377/// Trace propagation of an Event-over-HTTP call (Java sets both headers so the
378/// receiver chains onto this span as its parent): `x-trace-id` plus the W3C
379/// `traceparent`. When a custom traceparent header name is configured
380/// (`http.traceparent.header`), the same value is stamped under that name too,
381/// so the trace context survives an intermediary that strips the standard header.
382fn stamp_trace_headers(
383 mut http: AsyncHttpRequest,
384 trace_id: Option<&str>,
385 span_id: Option<&str>,
386) -> AsyncHttpRequest {
387 if let Some(trace_id) = trace_id {
388 http = http.set_header("x-trace-id", trace_id);
389 if let Some(span_id) = span_id {
390 if let Some(traceparent) = w3c_trace::format(trace_id, span_id) {
391 http = http.set_header(w3c_trace::TRACEPARENT, &traceparent);
392 let custom_traceparent = AppConfigReader::get_instance()
393 .get_property_or("http.traceparent.header", w3c_trace::TRACEPARENT);
394 if !custom_traceparent.eq_ignore_ascii_case(w3c_trace::TRACEPARENT) {
395 http = http.set_header(&custom_traceparent, &traceparent);
396 }
397 }
398 }
399 }
400 http
401}
402
403/// Split `http://host:port/api/event` into (`http://host:port`, `/api/event`).
404fn split_endpoint(endpoint: &str) -> Result<(String, String), AppError> {
405 let scheme_end = endpoint
406 .find("://")
407 .map(|i| i + 3)
408 .ok_or_else(|| AppError::new(400, format!("Invalid endpoint {endpoint}")))?;
409 match endpoint[scheme_end..].find('/') {
410 Some(offset) => {
411 let split = scheme_end + offset;
412 Ok((endpoint[..split].to_string(), endpoint[split..].to_string()))
413 }
414 None => Ok((endpoint.to_string(), "/api/event".to_string())),
415 }
416}
417
418// ---- declarative Event over HTTP (Java `yaml.event.over.http`) ----
419
420/// One declarative routing entry: the peer's `/api/event` URL plus optional
421/// per-target security headers (Java `EventEmitter.eventHttpTargets` +
422/// `eventHttpHeaders`, folded into one struct).
423#[derive(Debug)]
424pub struct EventHttpTarget {
425 /// The peer endpoint, e.g. `http://127.0.0.1:8085/api/event`.
426 pub target: String,
427 /// HTTP headers added to every forwarded call (e.g. `authorization`).
428 pub headers: HashMap<String, String>,
429}
430
431/// The route → target map, loaded once on first use (Java loads it in the
432/// `EventEmitter` constructor; the Rust port has no such singleton, so the
433/// registry initializes lazily from the same configuration).
434fn event_http_registry() -> &'static HashMap<String, EventHttpTarget> {
435 static REGISTRY: OnceLock<HashMap<String, EventHttpTarget>> = OnceLock::new();
436 REGISTRY.get_or_init(load_event_http_routes)
437}
438
439/// Declarative Event-over-HTTP lookup (Java `EventEmitter.getEventHttpTarget`
440/// and `getEventHttpHeaders`): the configured target for a route, with any
441/// `@instance` suffix stripped for the lookup. `None` = the route is local.
442pub fn get_event_http_target(route: &str) -> Option<&'static EventHttpTarget> {
443 let base = match route.find('@') {
444 Some(at) => &route[..at],
445 None => route,
446 };
447 event_http_registry().get(base)
448}
449
450/// Load the `event.http[]` entries from the file named by
451/// `yaml.event.over.http` (default `classpath:/event-over-http.yaml`).
452/// An absent file simply disables the feature; `${...}` references in the
453/// values (environment variables, base configuration keys) resolve at load
454/// time (Java `EventEmitter.loadHttpRoutes`).
455fn load_event_http_routes() -> HashMap<String, EventHttpTarget> {
456 let mut targets = HashMap::new();
457 let explicit = AppConfigReader::get_instance().get_property(EVENT_OVER_HTTP_YAML);
458 let path = explicit
459 .clone()
460 .unwrap_or_else(|| DEFAULT_EVENT_OVER_HTTP_YAML.to_string());
461 let reader = match ConfigReader::load(&path) {
462 Ok(reader) => reader,
463 Err(e) => {
464 // only an explicitly configured file is worth an error — the
465 // default location is optional by design
466 if explicit.is_some() {
467 log::error!("Unable to load event-over-http config - {e}");
468 }
469 return targets;
470 }
471 };
472 let Some(ConfigValue::List(entries)) = reader.get("event.http") else {
473 log::error!(
474 "Invalid config {path} - the event.http section should be a list of route and target"
475 );
476 return targets;
477 };
478 for i in 0..entries.len() {
479 let route = reader
480 .get_property(&format!("event.http[{i}].route"))
481 .unwrap_or_default();
482 let target = reader
483 .get_property(&format!("event.http[{i}].target"))
484 .unwrap_or_default();
485 if route.is_empty() || target.is_empty() {
486 continue;
487 }
488 if crate::platform::validate_route(&route).is_err() {
489 log::error!("Invalid Event over HTTP config entry - check route {route}");
490 continue;
491 }
492 if split_endpoint(&target).is_err() {
493 log::error!("Invalid Event over HTTP config entry - check target {target}");
494 continue;
495 }
496 let mut headers = HashMap::new();
497 if let Some(ConfigValue::Map(map)) = reader.get(&format!("event.http[{i}].headers")) {
498 for key in map.keys() {
499 if let Some(value) = reader.get_property(&format!("event.http[{i}].headers.{key}"))
500 {
501 headers.insert(key.clone(), value);
502 }
503 }
504 }
505 log::info!(
506 "Event-over-HTTP {route} -> {target} with {} header{}",
507 headers.len(),
508 if headers.len() == 1 { "" } else { "s" }
509 );
510 targets.insert(route, EventHttpTarget { target, headers });
511 }
512 log::info!(
513 "Total {} event-over-http target{} configured",
514 targets.len(),
515 if targets.len() == 1 { "" } else { "s" }
516 );
517 targets
518}
519
520/// The send-path forward (Java `EventEmitter.sendWithEventHttp`): an event
521/// whose route is declaratively mapped crosses to the peer instead of the
522/// local bus. With a `reply_to` it is a **callback**: the reply address is
523/// withheld from the wire, the forward runs as RPC, and the peer's response
524/// is delivered to the original `reply_to` locally (restoring `from`, trace,
525/// and the business correlation-id). Without one it is **async**: forwarded
526/// drop-n-forget, expecting the peer's 202 ack. Both run detached — like
527/// Java, `send` returns as soon as the forward is scheduled.
528pub(crate) fn send_with_event_http(
529 platform: &Platform,
530 event: EventEnvelope,
531 to: &str,
532 entry: &'static EventHttpTarget,
533) -> Result<(), AppError> {
534 let callback = event.reply_to().map(str::to_string);
535 if let Some(callback) = &callback {
536 if accepts_event_stream_header(&event) {
537 // the event-level opt-in for progressive streaming over
538 // Event-over-HTTP: "accept: text/event-stream"
539 return relay_event_stream(platform, event, to, entry, callback.clone());
540 }
541 }
542 let event_api_type = if callback.is_some() {
543 "callback"
544 } else {
545 "async"
546 };
547 let trace_id = event.trace_id().map(str::to_string);
548 let trace_path = event.trace_path().map(str::to_string);
549 let cid = event.correlation_id().map(str::to_string);
550 let forward = event
551 .clear_reply_to()
552 .set_header(X_EVENT_API, event_api_type);
553 let platform = platform.clone();
554 let to = to.to_string();
555 tokio::spawn(async move {
556 let po = PostOffice::new(&platform);
557 let outcome = event_over_http_with_headers(
558 &po,
559 &entry.target,
560 forward,
561 ASYNC_EVENT_HTTP_TIMEOUT,
562 callback.is_some(),
563 &entry.headers,
564 )
565 .await;
566 match outcome {
567 Ok(reply) => {
568 if let Some(callback) = callback {
569 // deliver the peer's response to the original reply_to
570 // locally, restoring sender, trace, and correlation-id
571 let mut response = reply.set_to(&callback).clear_reply_to().set_from(&to);
572 if let (Some(id), Some(path)) = (&trace_id, &trace_path) {
573 response = response.set_trace(id, path);
574 }
575 if let Some(cid) = &cid {
576 response = response.set_correlation_id(cid);
577 }
578 if let Err(e) = po.send(response).await {
579 log::error!(
580 "Error in sending callback event {to} from {} to {callback} - {}",
581 entry.target,
582 e.message()
583 );
584 }
585 } else if reply.status() != 202 {
586 log::error!(
587 "Error in sending async event {to} to {} - status={}, error={}",
588 entry.target,
589 reply.status(),
590 reply
591 .body_as::<String>()
592 .unwrap_or_else(|_| format!("{}", reply.body()))
593 );
594 }
595 }
596 Err(e) => {
597 log::error!(
598 "Error in sending event {to} to {} - {}",
599 entry.target,
600 e.message()
601 );
602 }
603 }
604 });
605 Ok(())
606}
607
608/// The event-level opt-in for progressive streaming over Event-over-HTTP:
609/// the outbound event declares the header `accept: text/event-stream`.
610fn accepts_event_stream_header(event: &EventEnvelope) -> bool {
611 event.headers().iter().any(|(name, value)| {
612 name.eq_ignore_ascii_case("accept") && value.contains(TEXT_EVENT_STREAM)
613 })
614}
615
616/// Streaming-capable Event-over-HTTP relay (callback mode with the accept
617/// opt-in, Java `EventEmitter.relayEventStream`): the POST advertises
618/// `Accept: text/event-stream` and the caller's reply route and correlation id
619/// pass through to the HTTP client, which consumes the peer's SSE response
620/// progressively (the envelope-mode wire dialect) and forwards each decoded
621/// event to the callback. A peer that answers single-shot - a non-streaming
622/// target, or an older engine - falls back to the classic callback delivery.
623/// The event's `x-ttl` header (milliseconds, default 60 seconds) is the idle
624/// allowance between stream events on both hops.
625fn relay_event_stream(
626 platform: &Platform,
627 event: EventEnvelope,
628 to: &str,
629 entry: &'static EventHttpTarget,
630 callback: String,
631) -> Result<(), AppError> {
632 let ttl_ms = event
633 .headers()
634 .iter()
635 .find(|(name, _)| name.eq_ignore_ascii_case(X_TTL))
636 .and_then(|(_, value)| value.trim().parse::<u64>().ok())
637 .map_or(ASYNC_EVENT_HTTP_TIMEOUT.as_millis() as u64, |v| v.max(1000));
638 // the wire envelope carries the caller's trace lineage (fill-if-absent id
639 // and path, the caller's span) so the remote function parents onto it
640 let forward = crate::post_office::apply_current_trace(
641 event
642 .clear_reply_to()
643 .set_header(X_EVENT_API, crate::automation::http_client::STREAM_RELAY),
644 );
645 let cid = forward.correlation_id().map(str::to_string);
646 let trace_id = forward.trace_id().map(str::to_string);
647 let trace_path = forward.trace_path().map(str::to_string);
648 let span_id = forward.span_id().map(str::to_string);
649 let platform = platform.clone();
650 let to = to.to_string();
651 tokio::spawn(async move {
652 let (host, path) = match split_endpoint(&entry.target) {
653 Ok(parts) => parts,
654 Err(e) => {
655 log::error!(
656 "Unable to relay event stream {to} to {} - {}",
657 entry.target,
658 e.message()
659 );
660 return;
661 }
662 };
663 let payload = match forward.to_bytes() {
664 Ok(bytes) => bytes,
665 Err(e) => {
666 log::error!(
667 "Unable to relay event stream {to} to {} - {}",
668 entry.target,
669 e.message()
670 );
671 return;
672 }
673 };
674 let mut http = AsyncHttpRequest::new()
675 .set_method("POST")
676 .set_url(&path)
677 .set_target_host(&host)
678 .set_header("content-type", OCTET_STREAM)
679 .set_header(X_NO_STREAM, "true")
680 .set_header("accept", TEXT_EVENT_STREAM)
681 .set_header(X_TTL, &ttl_ms.to_string())
682 // the engine's own transport leg: the client must not stamp the
683 // business correlation-id header on it (event_over_http parity)
684 .set_header(X_EVENT_API, "true")
685 .set_body(Value::Binary(payload));
686 for (key, value) in &entry.headers {
687 http = http.set_header(key, value);
688 }
689 http = stamp_trace_headers(http, trace_id.as_deref(), span_id.as_deref());
690 let mut http_event = EventEnvelope::new()
691 .set_to(ASYNC_HTTP_REQUEST)
692 .set_raw_body(http.to_value())
693 .set_reply_to(&callback)
694 .set_from(&to)
695 .set_header(X_EVENT_API, crate::automation::http_client::STREAM_RELAY);
696 if let Some(cid) = &cid {
697 http_event = http_event.set_correlation_id(cid);
698 }
699 if let (Some(id), Some(path)) = (&trace_id, &trace_path) {
700 http_event = http_event.set_trace(id, path);
701 }
702 let po = PostOffice::new(&platform);
703 if let Err(e) = po.send(http_event).await {
704 log::error!(
705 "Unable to relay event stream {to} to {} - {}",
706 entry.target,
707 e.message()
708 );
709 }
710 });
711 Ok(())
712}