1use std::collections::HashMap;
23
24use crate::{ffi, mem};
25
26#[derive(Debug, Default, Clone, serde::Serialize)]
29pub struct FetchOptions {
30 #[serde(skip_serializing_if = "String::is_empty")]
32 pub method: String,
33
34 #[serde(skip_serializing_if = "HashMap::is_empty")]
36 pub headers: HashMap<String, String>,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
41 pub body: Option<String>,
42}
43
44impl FetchOptions {
45 pub fn with_method(mut self, method: impl Into<String>) -> Self {
47 self.method = method.into();
48 self
49 }
50
51 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
53 self.headers.insert(name.into(), value.into());
54 self
55 }
56
57 pub fn with_body(mut self, body: impl Into<String>) -> Self {
59 self.body = Some(body.into());
60 self
61 }
62}
63
64#[derive(Debug, serde::Deserialize)]
66pub struct FetchResponse {
67 pub status: u16,
69
70 #[serde(default)]
72 pub headers: HashMap<String, String>,
73
74 #[serde(default)]
78 pub body: String,
79}
80
81#[derive(Debug, thiserror::Error)]
83pub enum BeamError {
84 #[error("beam: no response from host")]
88 NoResponse,
89
90 #[error("beam: serialise options: {0}")]
92 SerializeFailed(String),
93
94 #[error("beam: deserialise response: {0}")]
96 DeserializeFailed(String),
97}
98
99pub fn fetch(url: &str, opts: Option<&FetchOptions>) -> Result<FetchResponse, BeamError> {
103 let opts_json = match opts {
104 Some(o) => {
105 serde_json::to_string(o).map_err(|e| BeamError::SerializeFailed(e.to_string()))?
106 }
107 None => String::from("{}"),
108 };
109
110 let (url_ptr, url_len) = mem::host_arg_str(url);
111 let (opts_ptr, opts_len) = mem::host_arg_str(&opts_json);
112 let result = unsafe { ffi::beam_fetch(url_ptr, url_len, opts_ptr, opts_len) };
113
114 let bytes = unsafe { mem::read_packed_bytes(result) }.ok_or(BeamError::NoResponse)?;
116
117 serde_json::from_slice(&bytes).map_err(|e| BeamError::DeserializeFailed(e.to_string()))
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::ffi::test_host;
124
125 #[test]
126 fn fetch_serialises_options_and_decodes_response() {
127 test_host::reset();
128 let canned = serde_json::json!({
129 "status": 200,
130 "headers": { "content-type": "text/plain" },
131 "body": "ok"
132 });
133 test_host::with_mock(|m| {
134 m.beam_response = Some(serde_json::to_vec(&canned).unwrap());
135 });
136
137 let opts = FetchOptions::default()
138 .with_method("POST")
139 .with_header("authorization", "Bearer xyz")
140 .with_body(r#"{"hello":"world"}"#);
141 let resp = fetch("https://api.example.com/echo", Some(&opts)).unwrap();
142
143 assert_eq!(resp.status, 200);
144 assert_eq!(
145 resp.headers.get("content-type").map(|s| s.as_str()),
146 Some("text/plain")
147 );
148 assert_eq!(resp.body, "ok");
149
150 let captured_url = test_host::read_mock(|m| m.last_beam_url.clone()).unwrap();
151 assert_eq!(captured_url, "https://api.example.com/echo");
152
153 let captured_opts = test_host::read_mock(|m| m.last_beam_opts.clone()).unwrap();
154 let parsed: serde_json::Value = serde_json::from_str(&captured_opts).unwrap();
155 assert_eq!(parsed["method"], "POST");
156 assert_eq!(parsed["headers"]["authorization"], "Bearer xyz");
157 assert_eq!(parsed["body"], r#"{"hello":"world"}"#);
158 }
159
160 #[test]
161 fn fetch_no_options_sends_empty_object() {
162 test_host::reset();
163 test_host::with_mock(|m| {
164 m.beam_response = Some(b"{\"status\":204,\"headers\":{},\"body\":\"\"}".to_vec());
165 });
166 let resp = fetch("https://api.example.com/empty", None).unwrap();
167 assert_eq!(resp.status, 204);
168 assert_eq!(
169 test_host::read_mock(|m| m.last_beam_opts.clone()),
170 Some("{}".into())
171 );
172 }
173
174 #[test]
175 fn fetch_no_response_is_error() {
176 test_host::reset();
177 match fetch("https://api.example.com/down", None).unwrap_err() {
178 BeamError::NoResponse => {}
179 other => panic!("expected NoResponse, got {:?}", other),
180 }
181 }
182
183 #[test]
184 fn fetch_invalid_json_response_is_deserialise_error() {
185 test_host::reset();
186 test_host::with_mock(|m| {
187 m.beam_response = Some(b"not json".to_vec());
188 });
189 match fetch("https://api.example.com/bad", None).unwrap_err() {
190 BeamError::DeserializeFailed(_) => {}
191 other => panic!("expected DeserializeFailed, got {:?}", other),
192 }
193 }
194
195 #[test]
196 fn fetch_options_omit_empty_fields_from_json() {
197 test_host::reset();
198 test_host::with_mock(|m| {
199 m.beam_response = Some(b"{\"status\":200,\"headers\":{},\"body\":\"\"}".to_vec());
200 });
201 let opts = FetchOptions::default(); fetch("https://x", Some(&opts)).unwrap();
203
204 let captured = test_host::read_mock(|m| m.last_beam_opts.clone()).unwrap();
205 assert!(!captured.contains("\"method\""));
207 assert!(!captured.contains("\"headers\""));
208 assert!(!captured.contains("\"body\""));
209 }
210
211 #[test]
212 fn fetch_options_builder_chains() {
213 let opts = FetchOptions::default()
214 .with_method("PUT")
215 .with_header("a", "1")
216 .with_header("b", "2")
217 .with_body("payload");
218 assert_eq!(opts.method, "PUT");
219 assert_eq!(opts.headers.len(), 2);
220 assert_eq!(opts.body.as_deref(), Some("payload"));
221 }
222}