use std::collections::HashMap;
use crate::{ffi, mem};
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct FetchOptions {
#[serde(skip_serializing_if = "String::is_empty")]
pub method: String,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
impl FetchOptions {
pub fn with_method(mut self, method: impl Into<String>) -> Self {
self.method = method.into();
self
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn with_body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
}
#[derive(Debug, serde::Deserialize)]
pub struct FetchResponse {
pub status: u16,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub body: String,
}
#[derive(Debug, thiserror::Error)]
pub enum BeamError {
#[error("beam: no response from host")]
NoResponse,
#[error("beam: serialise options: {0}")]
SerializeFailed(String),
#[error("beam: deserialise response: {0}")]
DeserializeFailed(String),
}
pub fn fetch(url: &str, opts: Option<&FetchOptions>) -> Result<FetchResponse, BeamError> {
let opts_json = match opts {
Some(o) => {
serde_json::to_string(o).map_err(|e| BeamError::SerializeFailed(e.to_string()))?
}
None => String::from("{}"),
};
let (url_ptr, url_len) = mem::host_arg_str(url);
let (opts_ptr, opts_len) = mem::host_arg_str(&opts_json);
let result = unsafe { ffi::beam_fetch(url_ptr, url_len, opts_ptr, opts_len) };
let bytes = unsafe { mem::read_packed_bytes(result) }.ok_or(BeamError::NoResponse)?;
serde_json::from_slice(&bytes).map_err(|e| BeamError::DeserializeFailed(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ffi::test_host;
#[test]
fn fetch_serialises_options_and_decodes_response() {
test_host::reset();
let canned = serde_json::json!({
"status": 200,
"headers": { "content-type": "text/plain" },
"body": "ok"
});
test_host::with_mock(|m| {
m.beam_response = Some(serde_json::to_vec(&canned).unwrap());
});
let opts = FetchOptions::default()
.with_method("POST")
.with_header("authorization", "Bearer xyz")
.with_body(r#"{"hello":"world"}"#);
let resp = fetch("https://api.example.com/echo", Some(&opts)).unwrap();
assert_eq!(resp.status, 200);
assert_eq!(
resp.headers.get("content-type").map(|s| s.as_str()),
Some("text/plain")
);
assert_eq!(resp.body, "ok");
let captured_url = test_host::read_mock(|m| m.last_beam_url.clone()).unwrap();
assert_eq!(captured_url, "https://api.example.com/echo");
let captured_opts = test_host::read_mock(|m| m.last_beam_opts.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&captured_opts).unwrap();
assert_eq!(parsed["method"], "POST");
assert_eq!(parsed["headers"]["authorization"], "Bearer xyz");
assert_eq!(parsed["body"], r#"{"hello":"world"}"#);
}
#[test]
fn fetch_no_options_sends_empty_object() {
test_host::reset();
test_host::with_mock(|m| {
m.beam_response = Some(b"{\"status\":204,\"headers\":{},\"body\":\"\"}".to_vec());
});
let resp = fetch("https://api.example.com/empty", None).unwrap();
assert_eq!(resp.status, 204);
assert_eq!(
test_host::read_mock(|m| m.last_beam_opts.clone()),
Some("{}".into())
);
}
#[test]
fn fetch_no_response_is_error() {
test_host::reset();
match fetch("https://api.example.com/down", None).unwrap_err() {
BeamError::NoResponse => {}
other => panic!("expected NoResponse, got {:?}", other),
}
}
#[test]
fn fetch_invalid_json_response_is_deserialise_error() {
test_host::reset();
test_host::with_mock(|m| {
m.beam_response = Some(b"not json".to_vec());
});
match fetch("https://api.example.com/bad", None).unwrap_err() {
BeamError::DeserializeFailed(_) => {}
other => panic!("expected DeserializeFailed, got {:?}", other),
}
}
#[test]
fn fetch_options_omit_empty_fields_from_json() {
test_host::reset();
test_host::with_mock(|m| {
m.beam_response = Some(b"{\"status\":200,\"headers\":{},\"body\":\"\"}".to_vec());
});
let opts = FetchOptions::default(); fetch("https://x", Some(&opts)).unwrap();
let captured = test_host::read_mock(|m| m.last_beam_opts.clone()).unwrap();
assert!(!captured.contains("\"method\""));
assert!(!captured.contains("\"headers\""));
assert!(!captured.contains("\"body\""));
}
#[test]
fn fetch_options_builder_chains() {
let opts = FetchOptions::default()
.with_method("PUT")
.with_header("a", "1")
.with_header("b", "2")
.with_body("payload");
assert_eq!(opts.method, "PUT");
assert_eq!(opts.headers.len(), 2);
assert_eq!(opts.body.as_deref(), Some("payload"));
}
}