use std::sync::Arc;
use parking_lot::RwLock;
use crate::base64_encode;
use crate::otel::{HostOtelRuntime, HostSpanScope, TraceDirection};
use crate::trace::TraceCarrier;
use helix_core::auth::{apply_auth_intent, AuthKind};
use helix_core::effect::{DomainEventBytes, HttpRequest, HttpResponse};
use helix_core::ports::{EventSink, HttpRequester};
use helix_core::PortError;
pub type HttpOutcomeObserver =
fn(url: &str, outcome: &Result<HttpResponse, PortError>) -> Option<DomainEventBytes>;
#[derive(Clone, Default)]
pub struct AuthTokenRegistry {
inner: Arc<RwLock<AuthSlots>>,
}
#[derive(Default)]
struct AuthSlots {
session: Option<(String, String)>,
bot: Option<(String, String)>,
}
impl AuthTokenRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn set_session(&self, header_name: impl Into<String>, header_value: impl Into<String>) {
self.inner.write().session = Some((header_name.into(), header_value.into()));
}
pub fn set_bot(&self, header_name: impl Into<String>, header_value: impl Into<String>) {
self.inner.write().bot = Some((header_name.into(), header_value.into()));
}
pub fn clear(&self, kind: AuthKind) {
let mut slots = self.inner.write();
match kind {
AuthKind::Session => slots.session = None,
AuthKind::Bot => slots.bot = None,
}
}
fn resolve(&self, kind: AuthKind) -> Option<(String, String)> {
let slots = self.inner.read();
match kind {
AuthKind::Session => slots.session.clone(),
AuthKind::Bot => slots.bot.clone(),
}
}
}
#[derive(Clone)]
pub struct CrossCuttingHttp<H, E> {
inner: H,
event_sink: Arc<E>,
auth: AuthTokenRegistry,
outcome_observer: HttpOutcomeObserver,
otel: Option<HostOtelRuntime>,
}
impl<H, E> CrossCuttingHttp<H, E>
where
H: HttpRequester,
E: EventSink,
{
pub fn new(
inner: H,
event_sink: Arc<E>,
auth: AuthTokenRegistry,
outcome_observer: HttpOutcomeObserver,
) -> Self {
Self {
inner,
event_sink,
auth,
outcome_observer,
otel: None,
}
}
pub fn with_otel(mut self, otel: HostOtelRuntime) -> Self {
self.otel = Some(otel).filter(HostOtelRuntime::is_enabled);
self
}
pub fn is_otel_enabled(&self) -> bool {
self.otel.is_some()
}
fn apply_auth(&self, req: HttpRequest) -> (HttpRequest, Option<AuthKind>) {
apply_auth_intent(req, |kind| self.auth.resolve(kind))
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl<H, E> HttpRequester for CrossCuttingHttp<H, E>
where
H: HttpRequester,
E: EventSink,
{
async fn request(&self, req: HttpRequest) -> Result<HttpResponse, PortError> {
let (prepared, _kind) = self.apply_auth(req);
let url = prepared.url.clone();
let carrier = TraceCarrier::from_headers(&prepared.headers);
let request_scope = self.otel.as_ref().map(|runtime| {
runtime.span_with_attributes(
"helix.http.request",
TraceDirection::Outbound,
carrier.as_ref(),
full_debug_http_attributes(runtime, &prepared),
)
});
let outcome = self.inner.request(prepared).await;
let response_parent = match &outcome {
Ok(response) => TraceCarrier::from_response_headers(&response.headers),
Err(_) => None,
}
.or_else(|| {
request_scope
.as_ref()
.and_then(HostSpanScope::child_carrier)
})
.or_else(|| carrier.clone());
let _response_scope = match &outcome {
Ok(response) => self.otel.as_ref().map(|runtime| {
let mut attributes =
vec![("http.response.status_code", response.status.to_string())];
if runtime.is_full_debug() {
attributes.push((
"helix.debug.http.response_headers",
bounded_debug_string(
&serde_json::to_string(&response.headers)
.unwrap_or_else(|_| "[]".to_string()),
),
));
attributes.push((
"helix.debug.http.response_body_base64",
bounded_debug_bytes(&response.body),
));
}
runtime.span_with_attributes(
"helix.http.response",
TraceDirection::Inbound,
response_parent.as_ref(),
attributes,
)
}),
Err(error) => self.otel.as_ref().map(|runtime| {
runtime.span_with_attributes(
"helix.http.response",
TraceDirection::Inbound,
response_parent.as_ref(),
vec![("helix.http.error_kind", error_kind(error).to_string())],
)
}),
};
if let Some(event) = (self.outcome_observer)(&url, &outcome) {
self.event_sink.emit(event);
}
outcome
}
}
fn error_kind(error: &PortError) -> &'static str {
match error {
PortError::Storage(_) => "storage",
PortError::Transport(_) => "transport",
PortError::Http(_) => "http",
PortError::Clock(_) => "clock",
PortError::IdSource(_) => "id_source",
PortError::Other(_) => "other",
}
}
const FULL_DEBUG_PAYLOAD_LIMIT: usize = 64 * 1024;
fn full_debug_http_attributes(
runtime: &HostOtelRuntime,
request: &HttpRequest,
) -> Vec<(&'static str, String)> {
if !runtime.is_full_debug() {
return Vec::new();
}
let mut attributes = vec![
(
"helix.debug.http.request_headers",
bounded_debug_string(
&serde_json::to_string(&request.headers).unwrap_or_else(|_| "[]".to_string()),
),
),
(
"helix.debug.http.request_url",
bounded_debug_string(&request.url),
),
(
"helix.debug.http.request_method",
bounded_debug_string(&request.method),
),
];
if let Some(body) = &request.body {
attributes.push((
"helix.debug.http.request_body_base64",
bounded_debug_bytes(body),
));
}
attributes
}
fn bounded_debug_string(value: &str) -> String {
if value.len() <= FULL_DEBUG_PAYLOAD_LIMIT {
return value.to_string();
}
let boundary = value
.char_indices()
.map(|(index, _)| index)
.take_while(|index| *index <= FULL_DEBUG_PAYLOAD_LIMIT)
.last()
.unwrap_or(0);
format!(
"{}...[truncated {} bytes]",
&value[..boundary],
value.len() - boundary
)
}
fn bounded_debug_bytes(bytes: &[u8]) -> String {
let shown = bytes.len().min(FULL_DEBUG_PAYLOAD_LIMIT);
let mut value = base64_encode(&bytes[..shown]);
if shown < bytes.len() {
value.push_str(&format!("...[truncated {} bytes]", bytes.len() - shown));
}
value
}