use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
use std::task::{Context, Poll};
use std::time::Duration;
use async_trait::async_trait;
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::{Bytes, Frame};
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::sync::{mpsc, oneshot};
use crate::envelope::EventEnvelope;
use crate::event_stream;
use crate::function::{AppError, ComposableFunction};
use crate::platform::Platform;
use crate::post_office::PostOffice;
use crate::trace;
use crate::util::app_config_reader::AppConfigReader;
use crate::util::config_reader::ConfigReader;
use crate::util::w3c_trace;
use super::routing::{AssignedRoute, RouteInfo, RoutingTable};
pub const MY_CORRELATION_ID: &str = "my_correlation_id";
pub const ASYNC_HTTP_RESPONSE: &str = "async.http.response";
pub const ASYNC_HTTP_RESPONSE_STREAM_POOL: &str = "async.http.response.stream";
const RESPONSE_HANDLER_INSTANCES: usize = 500;
const STREAM_EVENT_BUFFER: usize = 64;
const STREAM_FRAME_BUFFER: usize = 64;
type HttpBody = BoxBody<Bytes, std::convert::Infallible>;
fn full(bytes: Bytes) -> HttpBody {
BoxBody::new(Full::new(bytes))
}
fn lane_pool() -> &'static Mutex<VecDeque<String>> {
static POOL: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
POOL.get_or_init(|| Mutex::new(VecDeque::new()))
}
pub fn checkout_lane() -> Option<String> {
lane_pool().lock().expect("lane pool poisoned").pop_front()
}
pub fn release_lane(route: String) {
lane_pool()
.lock()
.expect("lane pool poisoned")
.push_back(route);
}
pub fn available_lanes() -> usize {
lane_pool().lock().expect("lane pool poisoned").len()
}
fn pending_streams() -> &'static Mutex<HashMap<String, mpsc::Sender<EventEnvelope>>> {
static PENDING: OnceLock<Mutex<HashMap<String, mpsc::Sender<EventEnvelope>>>> = OnceLock::new();
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cleanup_stream(context_id: &str, lane: &str) {
let removed = pending_streams()
.lock()
.expect("pending streams poisoned")
.remove(context_id);
if removed.is_some() {
release_lane(lane.to_string());
}
}
pub struct StreamLaneService;
#[async_trait]
impl ComposableFunction for StreamLaneService {
async fn handle_event(
&self,
_headers: HashMap<String, String>,
input: EventEnvelope,
_instance: usize,
) -> Result<EventEnvelope, AppError> {
if let Some(context_id) = input.correlation_id().map(str::to_string) {
let sender = pending_streams()
.lock()
.expect("pending streams poisoned")
.get(&context_id)
.cloned();
if let Some(sender) = sender {
let _ = sender.send(input).await;
}
}
Ok(EventEnvelope::new())
}
}
struct ChannelBody {
rx: mpsc::Receiver<Frame<Bytes>>,
}
impl hyper::body::Body for ChannelBody {
type Data = Bytes;
type Error = std::convert::Infallible;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
self.rx.poll_recv(cx).map(|frame| frame.map(Ok))
}
}
fn keep_alive_ms() -> u64 {
static KEEP_ALIVE: OnceLock<u64> = OnceLock::new();
*KEEP_ALIVE.get_or_init(|| {
let config = AppConfigReader::get_instance();
let text = config.get_property_or("event.stream.keep.alive", "30s");
let trimmed = text.trim().to_lowercase();
if trimmed == "0" || trimmed == "0s" || trimmed == "0ms" || trimmed == "0m" {
0
} else {
super::routing::parse_timeout(Some(&trimmed)).as_millis() as u64
}
})
}
const PROTECTED_METADATA: [&str; 5] = [
"my_route",
"my_trace_id",
"my_trace_path",
MY_CORRELATION_ID,
"x-event-api",
];
fn pending_responses() -> &'static Mutex<HashMap<String, oneshot::Sender<EventEnvelope>>> {
static PENDING: OnceLock<Mutex<HashMap<String, oneshot::Sender<EventEnvelope>>>> =
OnceLock::new();
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}
pub struct AsyncHttpResponseService;
#[async_trait]
impl ComposableFunction for AsyncHttpResponseService {
async fn handle_event(
&self,
_headers: HashMap<String, String>,
input: EventEnvelope,
_instance: usize,
) -> Result<EventEnvelope, AppError> {
if let Some(context_id) = input.correlation_id().map(str::to_string) {
let sender = pending_responses()
.lock()
.expect("pending http contexts poisoned")
.remove(&context_id);
if let Some(sender) = sender {
let _ = sender.send(input);
}
}
Ok(EventEnvelope::new())
}
}
static SERVER_ADDR: OnceLock<SocketAddr> = OnceLock::new();
pub fn server_address() -> Option<SocketAddr> {
SERVER_ADDR.get().copied()
}
struct RouterState {
table: RoutingTable,
platform: Platform,
trace_header: String,
cid_header: String,
traceparent_header: String,
}
pub async fn start_http_server(platform: &Platform) -> Result<SocketAddr, AppError> {
let config = AppConfigReader::get_instance();
if !platform.has_route(ASYNC_HTTP_RESPONSE) {
if let Err(e) = platform.register_private(
ASYNC_HTTP_RESPONSE,
Arc::new(AsyncHttpResponseService),
RESPONSE_HANDLER_INSTANCES,
) {
if !platform.has_route(ASYNC_HTTP_RESPONSE) {
return Err(e);
}
}
}
let members = platform.register_route_pool(
ASYNC_HTTP_RESPONSE_STREAM_POOL,
Arc::new(StreamLaneService),
RESPONSE_HANDLER_INSTANCES,
)?;
static POOL_FILLED: OnceLock<()> = OnceLock::new();
POOL_FILLED.get_or_init(|| {
for lane_route in members {
release_lane(lane_route);
}
});
let rest_yaml = config.get_property_or("yaml.rest.automation", "classpath:/rest.yaml");
let reader = ConfigReader::load(&rest_yaml)
.map_err(|e| AppError::new(500, format!("Unable to load {rest_yaml} - {e}")))?;
let mut table = RoutingTable::load(&reader)?;
merge_default_endpoints(&mut table)?;
for (methods, url, service) in table.retain_available(|service| platform.has_route(service)) {
log::warn!("Skip {methods} {url} - Service {service} not available");
}
let table = table;
for route in table.routes() {
log::info!(
"{} {} -> {}",
route.methods.join(","),
route.url,
route.service
);
}
let port: u16 = config
.get_property_or("rest.server.port", "8085")
.parse()
.map_err(|_| AppError::new(500, "Invalid rest.server.port"))?;
let state = Arc::new(RouterState {
table,
platform: platform.clone(),
trace_header: config.get_property_or("http.trace.id.header", "X-Trace-Id"),
cid_header: config.get_property_or("http.correlation.id.header", "X-Correlation-Id"),
traceparent_header: config
.get_property_or("http.traceparent.header", w3c_trace::TRACEPARENT),
});
log::info!("Correlation-id HTTP header is '{}'", state.cid_header);
log::info!("Trace-id HTTP header is '{}'", state.trace_header);
log::info!("Traceparent HTTP header is '{}'", state.traceparent_header);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.map_err(|e| AppError::new(500, format!("Unable to bind port {port} - {e}")))?;
let addr = listener
.local_addr()
.map_err(|e| AppError::new(500, e.to_string()))?;
let _ = SERVER_ADDR.set(addr);
log::info!("REST automation service started on port {}", addr.port());
tokio::spawn(async move {
loop {
let Ok((stream, peer)) = listener.accept().await else {
break;
};
let state = state.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
let service = service_fn(move |request| {
let state = state.clone();
async move { handle(state, request, peer).await }
});
if let Err(e) = hyper::server::conn::http1::Builder::new()
.serve_connection(io, service)
.with_upgrades()
.await
{
log::debug!("HTTP connection ended - {e}");
}
});
}
});
Ok(addr)
}
async fn handle(
state: Arc<RouterState>,
request: Request<hyper::body::Incoming>,
peer: SocketAddr,
) -> Result<Response<HttpBody>, hyper::Error> {
if super::ws_server::is_ws_upgrade(&request) {
return Ok(super::ws_server::handle_ws_upgrade(
&state.platform,
request,
peer.ip().to_string(),
)
.map(BoxBody::new));
}
let method = request.method().as_str().to_uppercase();
let path = request.uri().path().to_string();
let query_text = request.uri().query().unwrap_or("").to_string();
let mut headers: HashMap<String, String> = HashMap::new();
for (name, value) in request.headers() {
if let Ok(value) = value.to_str() {
headers.insert(name.as_str().to_lowercase(), value.to_string());
}
}
let body_bytes = match request.into_body().collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => Bytes::new(),
};
let assigned = state.table.find(&method, &path).or_else(|| {
if path == "/" {
state.table.find(&method, "/index.html")
} else {
None
}
});
let Some(assigned) = assigned else {
if state.table.path_matches_any_method(&path) {
return Ok(error_response(405, "Method not allowed"));
}
if method == "GET" || method == "HEAD" {
if let Some(response) =
serve_static(&state, &path, &query_text, &headers, peer, method == "HEAD").await
{
return Ok(response);
}
}
return Ok(error_response(404, "Resource not found"));
};
if method == "OPTIONS" {
let Some(cors) = assigned
.info
.cors
.as_ref()
.filter(|c| !c.options.is_empty())
else {
return Ok(error_response(405, "Method not allowed"));
};
let mut response = Response::builder().status(StatusCode::NO_CONTENT);
for (name, value) in &cors.options {
response = response.header(name, value);
}
return Ok(response.body(full(Bytes::new())).expect("static response"));
}
match process(
&state, assigned, method, path, query_text, headers, body_bytes, peer,
)
.await
{
Ok(response) => Ok(response),
Err(e) => Ok(error_response(e.status(), e.message())),
}
}
#[allow(clippy::too_many_arguments)]
async fn process(
state: &RouterState,
assigned: AssignedRoute<'_>,
method: String,
path: String,
query_text: String,
mut headers: HashMap<String, String>,
body_bytes: Bytes,
peer: SocketAddr,
) -> Result<Response<HttpBody>, AppError> {
let info = assigned.info;
if let Some(header_info) = &info.headers {
header_info.request.apply(&mut headers);
}
if let Some(flow) = &info.flow {
headers.insert("x-flow-id".to_string(), flow.clone());
}
let trace_header = info
.trace_id_header
.as_deref()
.unwrap_or(&state.trace_header)
.to_lowercase();
let cid_header = info
.correlation_id_header
.as_deref()
.unwrap_or(&state.cid_header)
.to_lowercase();
let traceparent = headers
.get(w3c_trace::TRACEPARENT)
.and_then(|value| w3c_trace::parse(value))
.or_else(|| {
let traceparent_header = info
.traceparent_header
.as_deref()
.unwrap_or(&state.traceparent_header)
.to_lowercase();
if traceparent_header == w3c_trace::TRACEPARENT {
None
} else {
headers
.get(&traceparent_header)
.and_then(|value| w3c_trace::parse(value))
}
});
let (trace_id, parent_span) = match &traceparent {
Some((trace_id, parent)) => (Some(trace_id.clone()), Some(parent.clone())),
None => (headers.get(&trace_header).cloned(), None),
};
let trace_id = if info.tracing {
Some(trace_id.unwrap_or_else(trace::new_trace_id))
} else {
None
};
let cid = headers.get(&cid_header).cloned().unwrap_or_else(|| {
if cid_header == trace_header {
trace_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string())
} else {
uuid::Uuid::new_v4().simple().to_string()
}
});
headers.insert(cid_header.clone(), cid.clone());
headers
.entry("x-ttl".to_string())
.or_insert_with(|| (info.timeout.as_secs().max(1) * 1000).to_string());
let mut query: HashMap<String, serde_json::Value> = HashMap::new();
for pair in query_text.split('&').filter(|p| !p.is_empty()) {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
let (name, value) = (url_decode(name), url_decode(value));
match query.get_mut(&name) {
None => {
query.insert(name, serde_json::Value::String(value));
}
Some(serde_json::Value::Array(values)) => {
values.push(serde_json::Value::String(value));
}
Some(existing) => {
let first = existing.clone();
*existing = serde_json::Value::Array(vec![first, serde_json::Value::String(value)]);
}
}
}
let path_params: HashMap<String, String> = assigned
.path_params
.iter()
.map(|(k, v)| (k.clone(), url_decode(v)))
.collect();
let cookies: HashMap<String, String> = headers
.remove("cookie")
.map(|header| {
header
.split(';')
.filter_map(|item| item.split_once('='))
.map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
.collect()
})
.unwrap_or_default();
let accept = headers.get("accept").cloned();
let parsed = parse_body(&headers, &body_bytes);
if let ParsedBody::Form(form) = &parsed {
for (name, value) in form {
query.insert(name.clone(), serde_json::Value::String(value.clone()));
}
}
let mut http_request = crate::automation::AsyncHttpRequest::new()
.set_method(&method)
.set_url(&path)
.set_remote_ip(&peer.ip().to_string())
.set_secure(headers.get("x-forwarded-proto").map(String::as_str) == Some("https"))
.set_target_host(&headers.get("host").cloned().unwrap_or_default())
.set_route_timeout_seconds(info.timeout.as_secs());
for (key, value) in &headers {
http_request = http_request.set_header(key, value);
}
for (key, value) in &path_params {
http_request = http_request.set_path_parameter(key, value);
}
for (key, value) in &query {
http_request = match value {
serde_json::Value::Array(values) => {
let values: Vec<&str> = values
.iter()
.map(|v| v.as_str().unwrap_or_default())
.collect();
http_request.set_query_parameter_values(key, &values)
}
serde_json::Value::String(value) => http_request.set_query_parameter(key, value),
other => http_request.set_query_parameter(key, &other.to_string()),
};
}
http_request = match &parsed {
ParsedBody::Value(value) => http_request
.set_body(rmpv::ext::to_value(value).map_err(|e| AppError::new(500, e.to_string()))?),
ParsedBody::Bytes(bytes) => http_request.set_body(rmpv::Value::Binary(bytes.clone())),
ParsedBody::Form(_) => http_request.set_body(rmpv::Value::Nil),
};
if !query_text.is_empty() {
http_request = http_request.set_query_string(&query_text);
}
for (key, value) in &cookies {
http_request = http_request.set_cookie(key, value);
}
let po = PostOffice::new(&state.platform);
let trace_path = if query_text.is_empty() {
format!("{method} {path}")
} else {
format!("{method} {path}?{query_text}")
};
if let Some(auth_route) = &info.authentication {
let auth_event = build_event(
auth_route,
&http_request,
&cid,
&trace_id,
&trace_path,
&parent_span,
)?;
let verdict = po.request(auth_event, info.timeout).await?;
if verdict.has_error() {
return Err(AppError::new(
verdict.status(),
verdict
.body_as::<String>()
.unwrap_or_else(|_| "Unauthorized".to_string()),
));
}
if !verdict.body_as::<bool>().unwrap_or(false) {
return Err(AppError::new(401, "Unauthorized"));
}
for (key, value) in verdict.headers() {
http_request = http_request.set_session_info(key, value);
}
}
let is_head = method == "HEAD";
let envelope_stream = !is_head && is_event_api_stream(info, &http_request);
let result = if (info.stream_response && !is_head) || envelope_stream {
match stream_dispatch(
state,
info,
&http_request,
&cid,
&cid_header,
&trace_id,
&trace_path,
&parent_span,
accept.clone(),
envelope_stream,
)
.await?
{
StreamOutcome::Streaming(response) => return Ok(response),
StreamOutcome::SingleShot(envelope) => envelope,
}
} else {
let context_id = uuid::Uuid::new_v4().simple().to_string();
let (tx, rx) = oneshot::channel();
pending_responses()
.lock()
.expect("pending http contexts poisoned")
.insert(context_id.clone(), tx);
let event = build_event(
&info.service,
&http_request,
&cid,
&trace_id,
&trace_path,
&parent_span,
)?
.set_correlation_id(&context_id)
.set_reply_to(ASYNC_HTTP_RESPONSE);
if let Err(e) = po.send(event).await {
pending_responses()
.lock()
.expect("pending http contexts poisoned")
.remove(&context_id);
return Err(e);
}
match tokio::time::timeout(info.timeout, rx).await {
Ok(Ok(envelope)) => envelope,
Ok(Err(_)) => {
return Err(AppError::new(500, "Response channel closed unexpectedly"));
}
Err(_) => {
pending_responses()
.lock()
.expect("pending http contexts poisoned")
.remove(&context_id);
return Err(AppError::new(
408,
format!("Timeout for {} ms", info.timeout.as_millis()),
));
}
}
};
let status = status_of(result.status());
let (result, standard_error) = match result.body() {
rmpv::Value::String(text) if result.status() >= 400 && result.headers().is_empty() => {
let message = text.as_str().unwrap_or_default().trim().to_string();
if message.starts_with('{') || message.starts_with('[') || message.starts_with('<') {
(result, false)
} else {
(
result.set_raw_body(rmpv::Value::Map(vec![
(
rmpv::Value::from("status"),
rmpv::Value::from(status.as_u16()),
),
(rmpv::Value::from("message"), rmpv::Value::from(message)),
(rmpv::Value::from("type"), rmpv::Value::from("error")),
])),
true,
)
}
}
_ => (result, false),
};
let mut content_type: Option<String> = None;
let mut set_cookies: Vec<String> = Vec::new();
let mut response_headers: HashMap<String, String> = HashMap::new();
for (name, value) in result.headers() {
let key = name.to_lowercase();
if PROTECTED_METADATA.contains(&key.as_str()) {
continue;
}
match key.as_str() {
"x-stream-id" if value.starts_with("stream.") && value.contains(".in") => {}
"x-ttl" => {}
"content-type" => {
if !is_head {
content_type = Some(value.to_lowercase());
}
}
"set-cookie" => {
set_cookies.extend(value.split('|').map(|c| c.trim().to_string()));
}
_ => {
response_headers.insert(key, value.clone());
}
}
}
if content_type.is_none() && !is_head {
content_type = accept_fallback_type(accept.as_deref(), result.body());
}
if standard_error && content_type.is_none() && !is_head {
content_type = Some("application/json".to_string());
}
let payload = render_payload(result.body(), content_type.as_deref());
if let Some(header_info) = &info.headers {
header_info.response.apply(&mut response_headers);
}
response_headers.entry(cid_header.clone()).or_insert(cid);
if let Some(content_type) = content_type {
response_headers.insert("content-type".to_string(), content_type);
}
if let Some(cors) = &info.cors {
for (name, value) in &cors.headers {
response_headers.insert(name.to_lowercase(), value.clone());
}
}
let mut response = Response::builder().status(status);
for (name, value) in response_headers {
response = response.header(name, value);
}
for cookie in set_cookies {
if !cookie.is_empty() {
response = response.header("set-cookie", cookie);
}
}
let payload = if is_head { Bytes::new() } else { payload };
response
.body(full(payload))
.map_err(|e| AppError::new(500, e.to_string()))
}
#[allow(clippy::large_enum_variant)]
enum StreamOutcome {
Streaming(Response<HttpBody>),
SingleShot(EventEnvelope),
}
fn stream_marker(event: &EventEnvelope) -> Result<Option<&'static str>, ()> {
for (name, value) in event.headers() {
if name.eq_ignore_ascii_case(event_stream::X_EVENT_STREAM) {
return match value.to_lowercase().as_str() {
event_stream::DATA => Ok(Some(event_stream::DATA)),
event_stream::EOF => Ok(Some(event_stream::EOF)),
event_stream::EXCEPTION => Ok(Some(event_stream::EXCEPTION)),
_ => Err(()),
};
}
}
Ok(None)
}
fn stream_error_message(event: &EventEnvelope) -> String {
match event.body() {
rmpv::Value::Map(entries) => entries
.iter()
.find(|(key, _)| key.as_str() == Some("message"))
.map(|(_, value)| stream_text(value))
.unwrap_or_else(|| "Stream failed".to_string()),
rmpv::Value::Nil => "Stream failed".to_string(),
other => stream_text(other),
}
}
fn negotiate_stream_type(accept: Option<&str>) -> String {
let Some(accept) = accept else {
return "application/json".to_string();
};
if accept.contains("*/*") || accept.contains("application/json") {
"application/json".to_string()
} else if accept.contains("text/event-stream") {
"text/event-stream".to_string()
} else if accept.contains("text/html") {
"text/html".to_string()
} else if accept.contains("application/xml") {
"application/xml".to_string()
} else {
"text/plain".to_string()
}
}
fn stream_text(body: &rmpv::Value) -> String {
match body {
rmpv::Value::Nil => String::new(),
rmpv::Value::String(text) => text.as_str().unwrap_or_default().to_string(),
rmpv::Value::Binary(bytes) => String::from_utf8_lossy(bytes).to_string(),
other => {
let stripped = crate::serializer::strip_nulls(other);
let json = serde_json::to_value(&stripped).unwrap_or_default();
serde_json::to_string(&json).unwrap_or_default()
}
}
}
fn sse_frame(event_name: Option<&str>, text: &str) -> Bytes {
let mut frame = String::new();
if let Some(name) = event_name.filter(|n| !n.is_empty()) {
frame.push_str("event: ");
frame.push_str(name);
frame.push('\n');
}
for line in text.split('\n') {
frame.push_str("data: ");
frame.push_str(line);
frame.push('\n');
}
frame.push('\n');
Bytes::from(frame)
}
fn chunk_bytes(body: &rmpv::Value) -> Bytes {
match body {
rmpv::Value::Nil => Bytes::new(),
rmpv::Value::String(text) => Bytes::from(text.as_str().unwrap_or_default().to_string()),
rmpv::Value::Binary(bytes) => Bytes::from(bytes.clone()),
other => {
let mut line = stream_text(other);
line.push('\n');
Bytes::from(line)
}
}
}
fn stream_event_name(event: &EventEnvelope) -> Option<&str> {
event
.headers()
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(event_stream::X_EVENT_NAME))
.map(|(_, value)| value.as_str())
}
fn is_event_api_stream(info: &RouteInfo, request: &crate::automation::AsyncHttpRequest) -> bool {
info.service == super::event_api::EVENT_API_SERVICE
&& request.header("x-async") != Some("true")
&& request
.header("accept")
.is_some_and(|accept| accept.contains("text/event-stream"))
}
fn event_api_idle(request: &crate::automation::AsyncHttpRequest) -> Duration {
let ttl_ms = request
.header("x-ttl")
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(0)
.max(1000);
Duration::from_millis(ttl_ms)
}
#[allow(clippy::too_many_arguments)]
async fn stream_dispatch(
state: &RouterState,
info: &RouteInfo,
http_request: &crate::automation::AsyncHttpRequest,
cid: &str,
cid_header: &str,
trace_id: &Option<String>,
trace_path: &str,
parent_span: &Option<String>,
accept: Option<String>,
envelope_mode: bool,
) -> Result<StreamOutcome, AppError> {
let Some(lane) = checkout_lane() else {
return Err(AppError::new(503, "Streaming response pool exhausted"));
};
let po = PostOffice::new(&state.platform);
let context_id = uuid::Uuid::new_v4().simple().to_string();
let (tx, mut rx) = mpsc::channel::<EventEnvelope>(STREAM_EVENT_BUFFER);
pending_streams()
.lock()
.expect("pending streams poisoned")
.insert(context_id.clone(), tx);
let event = build_event(
&info.service,
http_request,
cid,
trace_id,
trace_path,
parent_span,
)?
.set_correlation_id(&context_id)
.set_reply_to(&lane);
if let Err(e) = po.send(event).await {
cleanup_stream(&context_id, &lane);
return Err(e);
}
let base_idle = if envelope_mode {
event_api_idle(http_request)
} else {
info.timeout
};
let (first, marker) = loop {
match tokio::time::timeout(base_idle, rx.recv()).await {
Ok(Some(envelope)) => match stream_marker(&envelope) {
Ok(Some(marker)) => break (envelope, Some(marker)),
Ok(None) => break (envelope, None),
Err(()) => {
log::warn!(
"Dropping event for {context_id} - invalid {} signal",
event_stream::X_EVENT_STREAM
);
}
},
Ok(None) => {
cleanup_stream(&context_id, &lane);
return Err(AppError::new(500, "Response channel closed unexpectedly"));
}
Err(_) => {
cleanup_stream(&context_id, &lane);
return Err(AppError::new(
408,
format!("Timeout for {} ms", base_idle.as_millis()),
));
}
}
};
let Some(marker) = marker else {
cleanup_stream(&context_id, &lane);
let reply = if envelope_mode {
wire_single_shot(first)?
} else {
first
};
return Ok(StreamOutcome::SingleShot(reply));
};
if marker == event_stream::EXCEPTION && !envelope_mode {
cleanup_stream(&context_id, &lane);
let status = if first.status() >= 400 {
first.status()
} else {
500
};
return Err(AppError::new(status, stream_error_message(&first)));
}
if first
.headers()
.keys()
.any(|k| k.eq_ignore_ascii_case("x-stream-id"))
{
log::warn!("Ignoring x-stream-id on a streaming response for {context_id}");
}
let mut response_headers: HashMap<String, String> = HashMap::new();
let mut set_cookies: Vec<String> = Vec::new();
let mut content_type: Option<String> = None;
let mut idle_override: Option<Duration> = None;
for (name, value) in first.headers() {
let key = name.to_lowercase();
match key.as_str() {
event_stream::X_EVENT_STREAM | event_stream::X_EVENT_NAME | "x-stream-id" => {}
"x-ttl" => {
if let Ok(seconds) = value.trim().parse::<u64>() {
if seconds > 0 {
idle_override = Some(Duration::from_secs(seconds));
}
}
}
_ if envelope_mode => {}
"content-type" => content_type = Some(value.to_lowercase()),
"set-cookie" => {
set_cookies.extend(value.split('|').map(|c| c.trim().to_string()));
}
_ => {
response_headers.insert(key, value.clone());
}
}
}
if let Some(header_info) = &info.headers {
header_info.response.apply(&mut response_headers);
}
response_headers
.entry(cid_header.to_string())
.or_insert_with(|| cid.to_string());
if let Some(cors) = &info.cors {
for (name, value) in &cors.headers {
response_headers.insert(name.to_lowercase(), value.clone());
}
}
let content_type = if envelope_mode {
"text/event-stream".to_string()
} else {
content_type.unwrap_or_else(|| negotiate_stream_type(accept.as_deref()))
};
let sse = content_type.starts_with("text/event-stream");
if sse {
response_headers
.entry("cache-control".to_string())
.or_insert_with(|| "no-cache".to_string());
}
let idle = idle_override.unwrap_or(base_idle);
let mut builder = Response::builder().status(status_of(first.status()));
for (name, value) in &response_headers {
builder = builder.header(name, value);
}
for cookie in set_cookies {
if !cookie.is_empty() {
builder = builder.header("set-cookie", cookie);
}
}
builder = builder.header("content-type", &content_type);
let (body_tx, body_rx) = mpsc::channel::<Frame<Bytes>>(STREAM_FRAME_BUFFER);
let response = builder
.body(BoxBody::new(ChannelBody { rx: body_rx }))
.map_err(|e| AppError::new(500, e.to_string()))?;
tokio::spawn(render_stream(
rx,
body_tx,
sse,
idle,
context_id,
lane,
first,
marker,
envelope_mode,
));
Ok(StreamOutcome::Streaming(response))
}
#[allow(clippy::large_enum_variant)]
enum Waited {
Event(EventEnvelope),
Idle,
Closed,
}
async fn next_stream_event(
rx: &mut mpsc::Receiver<EventEnvelope>,
body_tx: &mpsc::Sender<Frame<Bytes>>,
sse: bool,
idle: Duration,
) -> Waited {
let ping_every = keep_alive_ms();
let idle_deadline = tokio::time::sleep(idle);
tokio::pin!(idle_deadline);
loop {
if sse && ping_every > 0 {
let ping = tokio::time::sleep(Duration::from_millis(ping_every));
tokio::pin!(ping);
tokio::select! {
received = rx.recv() => {
return match received {
Some(event) => Waited::Event(event),
None => Waited::Closed,
};
}
_ = &mut idle_deadline => return Waited::Idle,
_ = &mut ping => {
let _ = body_tx.try_send(Frame::data(Bytes::from_static(b": ping\n\n")));
}
}
} else {
tokio::select! {
received = rx.recv() => {
return match received {
Some(event) => Waited::Event(event),
None => Waited::Closed,
};
}
_ = &mut idle_deadline => return Waited::Idle,
}
}
}
}
async fn push_frame(
body_tx: &mpsc::Sender<Frame<Bytes>>,
idle: Duration,
context_id: &str,
bytes: Bytes,
) -> bool {
if bytes.is_empty() {
return true;
}
match tokio::time::timeout(idle, body_tx.send(Frame::data(bytes))).await {
Ok(Ok(())) => true,
Ok(Err(_)) => {
log::debug!("Client disconnected from event stream {context_id}");
false
}
Err(_) => {
log::error!("Closing event stream for {context_id} - client too slow");
false
}
}
}
#[allow(clippy::too_many_arguments)]
async fn render_stream(
mut rx: mpsc::Receiver<EventEnvelope>,
body_tx: mpsc::Sender<Frame<Bytes>>,
sse: bool,
idle: Duration,
context_id: String,
lane: String,
first: EventEnvelope,
first_marker: &'static str,
envelope_mode: bool,
) {
let mut pending = Some((first, first_marker));
let mut first_frame = true;
loop {
let (event, marker) = match pending.take() {
Some(next) => next,
None => match next_stream_event(&mut rx, &body_tx, sse, idle).await {
Waited::Event(event) => match stream_marker(&event) {
Ok(Some(marker)) => (event, marker),
Ok(None) | Err(()) => {
log::warn!(
"Dropping event for {context_id} - invalid {} signal",
event_stream::X_EVENT_STREAM
);
continue;
}
},
Waited::Idle => {
if envelope_mode {
let frame = idle_timeout_envelope_frame(idle);
let _ = push_frame(&body_tx, idle, &context_id, frame).await;
} else if sse {
let error = serde_json::json!({
"status": 408,
"message": format!("Timeout for {} seconds", idle.as_secs()),
"type": "error",
});
let frame = sse_frame(Some("error"), &error.to_string());
let _ = push_frame(&body_tx, idle, &context_id, frame).await;
}
break;
}
Waited::Closed => break,
},
};
match marker {
event_stream::DATA => {
let bytes = if envelope_mode {
envelope_mode_data_frame(&event, first_frame)
} else if sse {
if matches!(event.body(), rmpv::Value::Nil) {
Bytes::new()
} else {
sse_frame(stream_event_name(&event), &stream_text(event.body()))
}
} else {
chunk_bytes(event.body())
};
first_frame = false;
if !push_frame(&body_tx, idle, &context_id, bytes).await {
break;
}
}
event_stream::EOF => {
if envelope_mode {
let frame = envelope_wire_frame(&event);
let _ = push_frame(&body_tx, idle, &context_id, frame).await;
} else if sse {
let text = if matches!(event.body(), rmpv::Value::Nil) {
"{}".to_string()
} else {
stream_text(event.body())
};
let frame = sse_frame(Some("done"), &text);
let _ = push_frame(&body_tx, idle, &context_id, frame).await;
}
break;
}
_ => {
if envelope_mode {
let frame = envelope_wire_frame(&event);
let _ = push_frame(&body_tx, idle, &context_id, frame).await;
} else if sse {
let status = if event.status() >= 400 {
event.status()
} else {
500
};
let error = serde_json::json!({
"status": status,
"message": stream_error_message(&event),
"type": "error",
});
let frame = sse_frame(Some("error"), &error.to_string());
let _ = push_frame(&body_tx, idle, &context_id, frame).await;
}
break;
}
}
}
cleanup_stream(&context_id, &lane);
}
fn envelope_mode_data_frame(event: &EventEnvelope, first_frame: bool) -> Bytes {
if first_frame || !raw_streamable(event) {
envelope_wire_frame(event)
} else if matches!(event.body(), rmpv::Value::Nil) {
Bytes::new()
} else {
sse_frame(stream_event_name(event), &stream_text(event.body()))
}
}
fn raw_streamable(event: &EventEnvelope) -> bool {
if event.status() != 200 {
return false;
}
for (name, value) in event.headers() {
let key = name.to_lowercase();
let reserved = key == event_stream::X_EVENT_STREAM
|| key == event_stream::X_EVENT_NAME
|| key == "x-ttl";
if !reserved || (key == event_stream::X_EVENT_NAME && value == event_stream::ENVELOPE) {
return false;
}
}
match event.body() {
rmpv::Value::Nil => true,
rmpv::Value::String(text) => !text.as_str().unwrap_or_default().contains('\r'),
_ => false,
}
}
fn wire_single_shot(result: EventEnvelope) -> Result<EventEnvelope, AppError> {
let bytes = result.clear_to().clear_reply_to().to_bytes()?;
Ok(EventEnvelope::new()
.set_status(200)
.set_header("content-type", "application/octet-stream")
.set_raw_body(rmpv::Value::Binary(bytes)))
}
fn envelope_wire_frame(event: &EventEnvelope) -> Bytes {
use base64::Engine as _;
let wire = event.clone().clear_to().clear_reply_to();
match wire.to_bytes() {
Ok(bytes) => sse_frame(
Some(event_stream::ENVELOPE),
&base64::engine::general_purpose::STANDARD.encode(bytes),
),
Err(_) => Bytes::new(),
}
}
fn idle_timeout_envelope_frame(idle: Duration) -> Bytes {
let message = format!("Timeout for {} seconds", idle.as_secs());
let error = EventEnvelope::new()
.set_header(event_stream::X_EVENT_STREAM, event_stream::EXCEPTION)
.set_status(408)
.set_body(serde_json::json!({"type": "error", "status": 408, "message": message}));
match error {
Ok(envelope) => envelope_wire_frame(&envelope),
Err(_) => Bytes::new(),
}
}
fn build_event(
to: &str,
http_request: &crate::automation::AsyncHttpRequest,
cid: &str,
trace_id: &Option<String>,
trace_path: &str,
parent_span: &Option<String>,
) -> Result<EventEnvelope, AppError> {
let mut event = EventEnvelope::new()
.set_to(to)
.set_from("http.request")
.set_correlation_id(cid)
.add_tag(crate::post_office::BUSINESS_CID_TAG, cid)
.set_raw_body(http_request.to_value());
if let Some(trace_id) = trace_id {
event = event.set_trace(trace_id, trace_path);
if let Some(parent) = parent_span {
event = event.set_span_id(parent);
}
}
Ok(event)
}
enum ParsedBody {
Value(serde_json::Value),
Form(HashMap<String, String>),
Bytes(Vec<u8>),
}
fn base_content_type(headers: &HashMap<String, String>) -> Option<String> {
headers
.get("content-type")
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
}
fn parse_body(headers: &HashMap<String, String>, bytes: &Bytes) -> ParsedBody {
let content_type = base_content_type(headers);
let ct = content_type.as_deref().unwrap_or("?");
if ct.starts_with("application/json") {
let text = String::from_utf8_lossy(bytes).to_string();
let trimmed = text.trim();
let parsed = if trimmed.is_empty() {
Some(serde_json::Value::Object(serde_json::Map::new()))
} else if (trimmed.starts_with('{') && trimmed.ends_with('}'))
|| (trimmed.starts_with('[') && trimmed.ends_with(']'))
{
serde_json::from_str(&text).ok()
} else {
None
};
ParsedBody::Value(parsed.unwrap_or(serde_json::Value::String(text)))
} else if ct == "application/x-www-form-urlencoded" {
let text = String::from_utf8_lossy(bytes);
let mut form = HashMap::new();
for pair in text.split('&').filter(|p| !p.is_empty()) {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
form.insert(url_decode(name), url_decode(value));
}
ParsedBody::Form(form)
} else if ct.starts_with("application/xml")
|| ct.starts_with("text/html")
|| ct.starts_with("text/plain")
{
ParsedBody::Value(serde_json::Value::String(
String::from_utf8_lossy(bytes).to_string(),
))
} else if bytes.is_empty() {
ParsedBody::Value(serde_json::Value::Null)
} else {
ParsedBody::Bytes(bytes.to_vec())
}
}
fn url_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(byte) => {
out.push(byte);
i += 3;
}
None => {
out.push(bytes[i]);
i += 1;
}
}
}
other => {
out.push(other);
i += 1;
}
}
}
String::from_utf8_lossy(&out).to_string()
}
const DEFAULT_REST_YAML: &str = include_str!("../../resources/default-rest.yaml");
fn merge_default_endpoints(table: &mut RoutingTable) -> Result<(), AppError> {
let defaults = RoutingTable::from_yaml_text(DEFAULT_REST_YAML)?;
for route in defaults.routes() {
if !table.has_url(&route.url) {
table.add_route(route.clone());
}
}
Ok(())
}
async fn serve_static(
state: &RouterState,
path: &str,
query_text: &str,
headers: &HashMap<String, String>,
peer: SocketAddr,
head_only: bool,
) -> Option<Response<HttpBody>> {
let (bytes, filename) = resolve_static_file(path)?;
let static_content = state.table.static_content();
let no_cache = super::routing::matched_element(&static_content.no_cache_pages, path);
let mut filter_headers: Vec<(String, String)> = Vec::new();
if let Some(filter) = &static_content.filter {
let applies = super::routing::matched_element(&filter.path_list, path)
&& !super::routing::matched_element(&filter.exclusion_list, path);
if applies {
if state.platform.has_route(&filter.service) {
match run_static_filter(state, filter, path, query_text, headers, peer).await {
Ok(filtered) => {
for (name, value) in filtered.headers() {
filter_headers.push((name.clone(), value.clone()));
}
if filtered.status() != 200 {
let (content_type, payload) = envelope_payload(&filtered);
let mut response =
Response::builder().status(status_of(filtered.status()));
let mut has_content_type = false;
for (name, value) in &filter_headers {
has_content_type |= name.eq_ignore_ascii_case("content-type");
response = response.header(name, value);
}
if let (Some(content_type), false) = (content_type, has_content_type) {
response = response.header("content-type", content_type);
}
return response.body(full(payload)).ok();
}
}
Err(e) => {
log::error!(
"Unable to filter static content HTTP-GET {} - {}",
filter.service,
e.message()
);
}
}
} else {
log::warn!(
"Static content filter {} ignored because it does not exist",
filter.service
);
}
}
}
let mime = mime_for(
std::path::Path::new(&filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or(""),
);
let mut response = Response::builder().status(StatusCode::OK);
for (name, value) in &filter_headers {
response = response.header(name, value);
}
response = response.header("content-type", mime);
if no_cache {
response = response
.header("Cache-Control", "no-cache, no-store")
.header("Pragma", "no-cache")
.header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT");
} else {
use sha2::Digest;
let etag = format!("\"{:x}\"", sha2::Sha256::digest(&bytes));
let matched = headers
.get("if-none-match")
.is_some_and(|inm| inm.split(',').any(|tag| tag.trim() == etag));
if matched {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header("content-length", "0")
.body(full(Bytes::new()))
.ok();
}
response = response.header("ETag", etag);
}
let payload = if head_only {
Bytes::new()
} else {
Bytes::from(bytes)
};
response.body(full(payload)).ok()
}
fn resolve_static_file(path: &str) -> Option<(Vec<u8>, String)> {
if path.contains("..") {
return None; }
let rel = path.trim_start_matches('/');
let relative = if rel.is_empty() || path.ends_with('/') {
format!("{rel}/index.html")
.trim_start_matches('/')
.to_string()
} else {
let filename = rel.rsplit('/').next().unwrap_or(rel);
if filename.contains('.') {
rel.to_string()
} else {
format!("{rel}.html") }
};
let file = crate::util::resources::resolve_classpath(&format!("public/{relative}"))?;
let bytes = std::fs::read(&file).ok()?;
let filename = relative.rsplit('/').next().unwrap_or(&relative).to_string();
Some((bytes, filename))
}
async fn run_static_filter(
state: &RouterState,
filter: &super::routing::SimpleHttpFilter,
path: &str,
query_text: &str,
headers: &HashMap<String, String>,
peer: SocketAddr,
) -> Result<EventEnvelope, AppError> {
let mut request = crate::automation::AsyncHttpRequest::new()
.set_method("GET")
.set_url(path)
.set_remote_ip(&peer.ip().to_string())
.set_secure(false)
.set_target_host(&headers.get("host").cloned().unwrap_or_default())
.set_body(rmpv::Value::Nil);
for (key, value) in headers {
request = request.set_header(key, value);
}
for pair in query_text.split('&').filter(|p| !p.is_empty()) {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
request = request.set_query_parameter(&url_decode(name), &url_decode(value));
}
let event = EventEnvelope::new()
.set_to(&filter.service)
.set_raw_body(request.to_value());
let po = PostOffice::new(&state.platform);
po.request(event, std::time::Duration::from_secs(10)).await
}
fn accept_fallback_type(accept: Option<&str>, _body: &rmpv::Value) -> Option<String> {
let accept = accept?;
if accept.contains("text/html") {
Some("text/html".to_string())
} else if accept.contains("application/json")
|| accept.contains("*/*")
|| accept.contains("application/xml")
{
Some("application/json".to_string())
} else {
Some("text/plain".to_string())
}
}
fn render_payload(body: &rmpv::Value, content_type: Option<&str>) -> Bytes {
match body {
rmpv::Value::Nil => Bytes::new(),
rmpv::Value::String(text) => Bytes::from(text.as_str().unwrap_or_default().to_string()),
rmpv::Value::Binary(bytes) => Bytes::from(bytes.clone()),
_ => {
let stripped = crate::serializer::strip_nulls(body);
let json = serde_json::to_value(&stripped).unwrap_or_default();
let text = serde_json::to_string_pretty(&json).unwrap_or_default();
if content_type.is_some_and(|t| t.starts_with("text/html"))
&& matches!(body, rmpv::Value::Map(_) | rmpv::Value::Array(_))
{
Bytes::from(format!("<html><body><pre>\n{text}\n</pre></body></html>"))
} else {
Bytes::from(text)
}
}
}
}
fn envelope_payload(result: &EventEnvelope) -> (Option<&'static str>, Bytes) {
match result.body() {
rmpv::Value::Nil => (None, Bytes::new()),
rmpv::Value::String(text) => (
Some("text/plain"),
Bytes::from(text.as_str().unwrap_or_default().to_string()),
),
rmpv::Value::Binary(bytes) => {
(Some("application/octet-stream"), Bytes::from(bytes.clone()))
}
_ => {
let body = crate::serializer::strip_nulls(result.body());
let json = serde_json::to_value(&body).unwrap_or_default();
(
Some("application/json"),
Bytes::from(serde_json::to_string_pretty(&json).unwrap_or_default()),
)
}
}
}
fn status_of(code: i32) -> StatusCode {
StatusCode::from_u16(code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
fn mime_for(extension: &str) -> &'static str {
match extension.to_ascii_lowercase().as_str() {
"html" | "htm" => "text/html",
"css" => "text/css",
"js" | "mjs" => "text/javascript",
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"txt" => "text/plain",
"pdf" => "application/pdf",
"woff2" => "font/woff2",
"xml" => "application/xml",
_ => "application/octet-stream",
}
}
fn error_response(status: i32, message: &str) -> Response<HttpBody> {
let body = serde_json::json!({"status": status, "message": message, "type": "error"});
Response::builder()
.status(StatusCode::from_u16(status as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
.header("content-type", "application/json")
.body(full(Bytes::from(body.to_string())))
.expect("static response")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_decoding() {
assert_eq!(url_decode("hello%20world"), "hello world");
assert_eq!(url_decode("a+b"), "a b");
assert_eq!(url_decode("plain"), "plain");
assert_eq!(url_decode("bad%zz"), "bad%zz");
}
fn headers_of(content_type: &str) -> HashMap<String, String> {
HashMap::from([("content-type".to_string(), content_type.to_string())])
}
fn value_of(parsed: ParsedBody) -> serde_json::Value {
match parsed {
ParsedBody::Value(value) => value,
ParsedBody::Form(_) => panic!("expected a value, got form fields"),
ParsedBody::Bytes(_) => panic!("expected a value, got bytes"),
}
}
#[test]
fn body_parsing() {
let json = headers_of("application/json; charset=utf-8");
let value = value_of(parse_body(&json, &Bytes::from(r#"{"a":1}"#)));
assert_eq!(value["a"], 1);
let text = value_of(parse_body(&json, &Bytes::from("import graph from x")));
assert_eq!(
text,
serde_json::Value::String("import graph from x".into())
);
let bad = value_of(parse_body(&json, &Bytes::from("{broken")));
assert_eq!(bad, serde_json::Value::String("{broken".into()));
let empty = value_of(parse_body(&json, &Bytes::new()));
assert_eq!(empty, serde_json::json!({}));
let plain = headers_of("text/plain");
let unsniffed = value_of(parse_body(&plain, &Bytes::from(r#"{"a":1}"#)));
assert_eq!(unsniffed, serde_json::Value::String(r#"{"a":1}"#.into()));
let xml = value_of(parse_body(
&headers_of("application/xml"),
&Bytes::from("<a>1</a>"),
));
assert_eq!(xml, serde_json::Value::String("<a>1</a>".into()));
let form = parse_body(
&headers_of("application/x-www-form-urlencoded"),
&Bytes::from("a=1&b=hello+world"),
);
match form {
ParsedBody::Form(fields) => {
assert_eq!(fields["a"], "1");
assert_eq!(fields["b"], "hello world");
}
_ => panic!("expected form fields"),
}
match parse_body(&HashMap::new(), &Bytes::from("hello")) {
ParsedBody::Bytes(bytes) => assert_eq!(bytes, b"hello"),
_ => panic!("expected bytes for a missing content type"),
}
assert_eq!(
value_of(parse_body(&HashMap::new(), &Bytes::new())),
serde_json::Value::Null
);
}
}