#![cfg(feature = "extism-runtime")]
use std::time::Duration;
use serde::{Deserialize, Serialize};
use uni_plugin::{Capability, FnError};
use super::{HostSvcCtx, from_hex, to_hex};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
#[derive(Debug, Deserialize)]
struct HttpReq {
url: String,
#[serde(default)]
body_hex: Option<String>,
}
#[derive(Debug, Serialize)]
struct HttpResp {
status: u16,
body_hex: String,
}
fn do_http(ctx: &HostSvcCtx, req: HttpReq, traceparent: Option<&str>) -> Result<HttpResp, FnError> {
if !ctx.effective.iter().any(|c| c.network_allows(&req.url)) {
return Err(FnError::new(
0xC20,
format!(
"uni.http: url `{}` not in granted Network allow-list",
req.url
),
));
}
let egress = ctx
.http
.as_ref()
.ok_or_else(|| FnError::new(0xC21, "uni.http: no HTTP egress configured"))?;
let timeout = ctx
.effective
.iter()
.find_map(|c| match c {
Capability::WallClockMillisPerCall(ms) => Some(Duration::from_millis(*ms)),
_ => None,
})
.unwrap_or(DEFAULT_TIMEOUT);
let response = match &req.body_hex {
Some(h) => {
let body = from_hex(h)
.map_err(|e| FnError::new(0xC22, format!("uni.http.post: body hex: {e}")))?;
egress.post(&req.url, &body, timeout, MAX_RESPONSE_BYTES, traceparent)?
}
None => egress.get(&req.url, timeout, MAX_RESPONSE_BYTES, traceparent)?,
};
if response.status >= 400 {
return Err(FnError::new(
0xC23,
format!("uni.http(`{}`): HTTP status {}", req.url, response.status),
));
}
Ok(HttpResp {
status: response.status,
body_hex: to_hex(&response.body),
})
}
fn http_dispatch_json(ctx: &HostSvcCtx, req_json: &str) -> Result<String, FnError> {
let traceparent = uni_plugin::observability::current_trace_context().to_traceparent();
super::dispatch_json(ctx, req_json, "uni.http", |ctx, req: HttpReq| {
do_http(ctx, req, traceparent.as_deref())
})
}
extism::host_fn!(pub(crate) uni_http_get(ctx: HostSvcCtx; req_json: String) -> String {
let bundle = ctx.get()?;
let bundle = bundle
.lock()
.map_err(|_| extism::Error::msg("uni.http.get: host service ctx poisoned"))?;
http_dispatch_json(&bundle, &req_json).map_err(|e| extism::Error::msg(e.to_string()))
});
extism::host_fn!(pub(crate) uni_http_post(ctx: HostSvcCtx; req_json: String) -> String {
let bundle = ctx.get()?;
let bundle = bundle
.lock()
.map_err(|_| extism::Error::msg("uni.http.post: host service ctx poisoned"))?;
http_dispatch_json(&bundle, &req_json).map_err(|e| extism::Error::msg(e.to_string()))
});
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::Mutex;
use uni_plugin::{CapabilitySet, HttpEgress, HttpResponse};
struct RecordingHttp {
last_traceparent: Mutex<Option<String>>,
status: u16,
}
impl RecordingHttp {
fn new(status: u16) -> Self {
Self {
last_traceparent: Mutex::new(None),
status,
}
}
}
impl HttpEgress for RecordingHttp {
fn get(
&self,
url: &str,
_t: Duration,
_m: usize,
tp: Option<&str>,
) -> Result<HttpResponse, FnError> {
*self.last_traceparent.lock().unwrap() = tp.map(str::to_owned);
Ok(HttpResponse {
status: self.status,
body: format!("GET {url}").into_bytes(),
})
}
fn post(
&self,
url: &str,
body: &[u8],
_t: Duration,
_m: usize,
tp: Option<&str>,
) -> Result<HttpResponse, FnError> {
*self.last_traceparent.lock().unwrap() = tp.map(str::to_owned);
Ok(HttpResponse {
status: self.status,
body: format!("POST {url} {}", body.len()).into_bytes(),
})
}
}
fn net_caps(pattern: &str) -> CapabilitySet {
CapabilitySet::from_iter_of([Capability::Network {
allow: vec![pattern.into()],
}])
}
fn ctx_with(caps: CapabilitySet, http: Option<Arc<dyn HttpEgress>>) -> HostSvcCtx {
HostSvcCtx {
effective: caps,
kms: None,
secrets: None,
http,
}
}
#[test]
fn get_succeeds_and_injects_traceparent() {
let egress = Arc::new(RecordingHttp::new(200));
let ctx = ctx_with(net_caps("https://api.example.com/**"), Some(egress.clone()));
let resp = do_http(
&ctx,
HttpReq {
url: "https://api.example.com/v1/x".into(),
body_hex: None,
},
Some("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"),
)
.expect("get");
assert_eq!(resp.status, 200);
assert_eq!(
*egress.last_traceparent.lock().unwrap(),
Some("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01".to_owned()),
"host-active traceparent must reach the egress"
);
}
#[test]
fn denied_out_of_allowlist() {
let egress = Arc::new(RecordingHttp::new(200));
let ctx = ctx_with(net_caps("https://api.example.com/**"), Some(egress));
let err = do_http(
&ctx,
HttpReq {
url: "https://evil.test/".into(),
body_hex: None,
},
None,
)
.expect_err("must deny");
assert!(err.message.contains("not in granted Network allow-list"));
}
#[test]
fn fails_loudly_without_egress() {
let ctx = ctx_with(net_caps("**"), None);
let err = do_http(
&ctx,
HttpReq {
url: "https://x/".into(),
body_hex: None,
},
None,
)
.expect_err("no egress");
assert!(err.message.contains("no HTTP egress configured"));
}
#[test]
fn status_4xx_is_loud_error() {
let egress = Arc::new(RecordingHttp::new(404));
let ctx = ctx_with(net_caps("**"), Some(egress));
let err = do_http(
&ctx,
HttpReq {
url: "https://x/missing".into(),
body_hex: None,
},
None,
)
.expect_err("4xx");
assert!(err.message.contains("HTTP status 404"));
}
#[cfg(feature = "otel")]
#[test]
fn dispatch_injects_active_traceparent() {
use opentelemetry::trace::TracerProvider as _;
use tracing_subscriber::prelude::*;
let egress = Arc::new(RecordingHttp::new(200));
let ctx = ctx_with(net_caps("https://api.example.com/**"), Some(egress.clone()));
let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
let tracer = provider.tracer("uni-plugin-extism-test");
let subscriber =
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
tracing::subscriber::with_default(subscriber, || {
let span = tracing::info_span!("extism-http-e2e");
let _enter = span.enter();
let out = http_dispatch_json(&ctx, r#"{"url":"https://api.example.com/v1/x"}"#)
.expect("dispatch");
assert!(out.contains("\"status\":200"), "out: {out}");
});
let tp = egress
.last_traceparent
.lock()
.unwrap()
.clone()
.expect("an active OTel context must inject a traceparent");
assert!(tp.starts_with("00-"), "traceparent: {tp}");
assert_eq!(tp.len(), 55, "traceparent: {tp}");
}
#[cfg(feature = "otel")]
#[test]
fn dispatch_injects_no_traceparent_without_active_context() {
let egress = Arc::new(RecordingHttp::new(200));
let ctx = ctx_with(net_caps("**"), Some(egress.clone()));
http_dispatch_json(&ctx, r#"{"url":"https://x/"}"#).expect("dispatch");
assert_eq!(*egress.last_traceparent.lock().unwrap(), None);
}
#[test]
fn post_carries_body() {
let egress = Arc::new(RecordingHttp::new(200));
let ctx = ctx_with(net_caps("**"), Some(egress));
let resp = do_http(
&ctx,
HttpReq {
url: "https://x/".into(),
body_hex: Some("414243".into()),
},
None,
)
.expect("post");
let body = String::from_utf8(from_hex(&resp.body_hex).unwrap()).unwrap();
assert_eq!(body, "POST https://x/ 3");
}
}