#![cfg(feature = "extism-runtime")]
use serde::{Deserialize, Serialize};
use uni_plugin::FnError;
use uni_plugin::host_services::{self, HttpPolicyError};
use super::{HostSvcCtx, from_hex, to_hex};
#[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> {
let body = match &req.body_hex {
Some(h) => Some(
from_hex(h)
.map_err(|e| FnError::new(0xC22, format!("uni.http.post: body hex: {e}")))?,
),
None => None,
};
let response = host_services::http_request(
ctx.http.as_ref(),
&ctx.effective,
&req.url,
body.as_deref(),
traceparent,
)
.map_err(|e| match e {
HttpPolicyError::NotAllowed => FnError::new(
0xC20,
format!(
"uni.http: url `{}` not in granted Network allow-list",
req.url
),
),
HttpPolicyError::NoEgress => FnError::new(0xC21, "uni.http: no HTTP egress configured"),
HttpPolicyError::Transport(err) => err,
HttpPolicyError::Status(status) => FnError::new(
0xC23,
format!("uni.http(`{}`): HTTP status {}", req.url, 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 std::time::Duration;
use uni_plugin::Capability;
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,
graph: None,
}
}
#[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");
}
}