use facet::Facet;
use serde::{Deserialize, Serialize};
use crate::{Cx, PluginResponse};
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[repr(C)]
pub struct HttpHeader {
pub name: String,
pub value: String,
}
#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[repr(C)]
pub enum HttpOutcome {
Response { status: u16, headers: Vec<HttpHeader>, body: Vec<u8> },
TransportError { message: String },
}
impl HttpOutcome {
pub fn status(&self) -> Option<u16> {
match self {
Self::Response { status, .. } => Some(*status),
Self::TransportError { .. } => None,
}
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Response { status, .. } if (200..300).contains(status))
}
pub fn body(&self) -> &[u8] {
match self {
Self::Response { body, .. } => body,
Self::TransportError { .. } => &[],
}
}
pub fn text(&self) -> Option<&str> {
std::str::from_utf8(self.body()).ok().filter(|_| matches!(self, Self::Response { .. }))
}
pub fn header(&self, name: &str) -> Option<&str> {
match self {
Self::Response { headers, .. } => headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case(name))
.map(|h| h.value.as_str()),
Self::TransportError { .. } => None,
}
}
pub fn encode(&self) -> Vec<u8> {
use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
let mut buffer = Vec::new();
BincodeFfiFormat::serialize(&mut buffer, self).expect("encode HttpOutcome");
buffer
}
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
BincodeFfiFormat::deserialize(bytes).map_err(|e| e.to_string())
}
}
#[derive(Serialize)]
struct HttpReq {
url: String,
headers: Vec<HttpHeader>,
body: Option<String>,
}
#[must_use = "a RequestBuilder does nothing until you call .send()"]
pub struct RequestBuilder<'a, E> {
cx: &'a mut Cx<E>,
method: String,
url: String,
headers: Vec<HttpHeader>,
body: Option<String>,
}
impl<'a, E> RequestBuilder<'a, E> {
pub(crate) fn new(cx: &'a mut Cx<E>, method: String, url: String) -> Self {
Self { cx, method, url, headers: Vec::new(), body: None }
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push(HttpHeader { name: name.into(), value: value.into() });
self
}
pub fn bearer(self, token: impl AsRef<str>) -> Self {
self.header("Authorization", format!("Bearer {}", token.as_ref()))
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn send(self, then: impl FnOnce(HttpOutcome) -> E + Send + 'static) {
let input = serde_json::to_string(&HttpReq {
url: self.url,
headers: self.headers,
body: self.body,
})
.expect("serialize http request");
self.cx.plugin("http", self.method, input, move |r: PluginResponse| {
then(decode_outcome(&r))
});
}
}
fn decode_outcome(r: &PluginResponse) -> HttpOutcome {
HttpOutcome::decode(&r.output).unwrap_or_else(|e| {
let message = r
.as_text()
.filter(|t| !t.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("malformed http response: {e}"));
HttpOutcome::TransportError { message }
})
}
#[cfg(test)]
mod tests {
use super::*;
fn resp(status: u16, body: &str) -> HttpOutcome {
HttpOutcome::Response {
status,
headers: vec![HttpHeader { name: "Content-Type".into(), value: "application/json".into() }],
body: body.as_bytes().to_vec(),
}
}
#[test]
fn status_is_some_for_response_and_none_for_transport_error() {
assert_eq!(resp(200, "").status(), Some(200));
assert_eq!(HttpOutcome::TransportError { message: "offline".into() }.status(), None);
}
#[test]
fn is_success_covers_exactly_2xx() {
assert!(!resp(199, "").is_success());
assert!(resp(200, "").is_success());
assert!(resp(299, "").is_success());
assert!(!resp(300, "").is_success());
assert!(!resp(409, "").is_success());
assert!(!HttpOutcome::TransportError { message: "x".into() }.is_success());
}
#[test]
fn body_and_text_behave_on_valid_and_invalid_utf8() {
assert_eq!(resp(200, "hi").body(), b"hi");
assert_eq!(resp(200, "hi").text(), Some("hi"));
let binary = HttpOutcome::Response { status: 200, headers: vec![], body: vec![0xff, 0xfe] };
assert_eq!(binary.body(), &[0xff, 0xfe]);
assert_eq!(binary.text(), None, "invalid UTF-8 must not panic or lossily convert");
let err = HttpOutcome::TransportError { message: "x".into() };
assert_eq!(err.body(), b"");
assert_eq!(err.text(), None);
}
#[test]
fn header_lookup_is_case_insensitive() {
assert_eq!(resp(200, "").header("content-type"), Some("application/json"));
assert_eq!(resp(200, "").header("CONTENT-TYPE"), Some("application/json"));
assert_eq!(resp(200, "").header("missing"), None);
}
#[test]
fn bincode_round_trips_both_variants() {
for original in [
resp(409, "conflict"),
HttpOutcome::TransportError { message: "connection refused".into() },
] {
let bytes = original.encode();
assert_eq!(HttpOutcome::decode(&bytes).unwrap(), original);
}
}
#[test]
fn decode_rejects_garbage_without_panicking() {
assert!(HttpOutcome::decode(&[0xff, 0xff, 0xff]).is_err());
}
#[test]
fn decode_outcome_falls_back_to_plain_text_for_undecodable_utf8_payloads() {
let r = PluginResponse::text(false, "plugin 'http' not available");
match decode_outcome(&r) {
HttpOutcome::TransportError { message } => {
assert_eq!(message, "plugin 'http' not available");
}
other => panic!("expected TransportError, got {other:?}"),
}
let r = PluginResponse { ok: false, output: vec![0xff, 0xfe, 0xfd] };
match decode_outcome(&r) {
HttpOutcome::TransportError { message } => {
assert!(message.starts_with("malformed http response:"), "got: {message}");
}
other => panic!("expected TransportError, got {other:?}"),
}
}
#[test]
fn decode_outcome_keeps_the_bincode_error_for_an_empty_payload() {
let r = PluginResponse { ok: false, output: Vec::new() };
match decode_outcome(&r) {
HttpOutcome::TransportError { message } => {
assert!(!message.is_empty(), "expected a diagnostic message, got empty string");
assert!(message.starts_with("malformed http response:"), "got: {message}");
}
other => panic!("expected TransportError, got {other:?}"),
}
}
}