Skip to main content

flaron_sdk/
beam.rs

1//! Outbound HTTP from inside a flare via the host's `beam_fetch`.
2//!
3//! Beam is the only network egress allowed from a flare - direct sockets are
4//! not exposed. The host enforces a per-invocation request count
5//! (`max_fetch_requests` on the flare config, default 16), routes the call
6//! through the edge node's HTTP client, and returns the full response body.
7//!
8//! ```ignore
9//! use flaron_sdk::beam::{self, FetchOptions};
10//!
11//! let opts = FetchOptions::default()
12//!     .with_method("POST")
13//!     .with_header("content-type", "application/json")
14//!     .with_body(r#"{"hello":"world"}"#);
15//!
16//! match beam::fetch("https://api.example.com/echo", Some(&opts)) {
17//!     Ok(resp) => flaron_sdk::logging::info(&format!("origin {}", resp.status)),
18//!     Err(err) => flaron_sdk::logging::error(&err.to_string()),
19//! }
20//! ```
21
22use std::collections::HashMap;
23
24use crate::{ffi, mem};
25
26/// Optional knobs for [`fetch`]. All fields have sensible defaults - leave
27/// them empty for a plain `GET`.
28#[derive(Debug, Default, Clone, serde::Serialize)]
29pub struct FetchOptions {
30    /// HTTP method. Empty string is treated as `"GET"` by the host.
31    #[serde(skip_serializing_if = "String::is_empty")]
32    pub method: String,
33
34    /// Request headers. Header names are case-insensitive at the wire level.
35    #[serde(skip_serializing_if = "HashMap::is_empty")]
36    pub headers: HashMap<String, String>,
37
38    /// Request body as a UTF-8 string. For binary bodies, base64-encode the
39    /// bytes and set the appropriate `content-encoding` header.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub body: Option<String>,
42}
43
44impl FetchOptions {
45    /// Set the HTTP method.
46    pub fn with_method(mut self, method: impl Into<String>) -> Self {
47        self.method = method.into();
48        self
49    }
50
51    /// Add or replace a single header.
52    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    /// Set the request body.
58    pub fn with_body(mut self, body: impl Into<String>) -> Self {
59        self.body = Some(body.into());
60        self
61    }
62}
63
64/// Successful response from [`fetch`].
65#[derive(Debug, serde::Deserialize)]
66pub struct FetchResponse {
67    /// HTTP status code returned by the upstream.
68    pub status: u16,
69
70    /// Response headers (lowercased keys, as the host normalises them).
71    #[serde(default)]
72    pub headers: HashMap<String, String>,
73
74    /// Response body as a UTF-8 string. Binary upstream responses are
75    /// returned as the lossy UTF-8 conversion of their bytes - fetch a JSON
76    /// or text endpoint, or have the upstream base64-encode binary data.
77    #[serde(default)]
78    pub body: String,
79}
80
81/// Errors returned by [`fetch`].
82#[derive(Debug, thiserror::Error)]
83pub enum BeamError {
84    /// The host returned no response - typically means the per-invocation
85    /// fetch limit was hit, the upstream timed out, or beam is not
86    /// configured on this edge.
87    #[error("beam: no response from host")]
88    NoResponse,
89
90    /// Failed to JSON-encode the [`FetchOptions`] argument.
91    #[error("beam: serialise options: {0}")]
92    SerializeFailed(String),
93
94    /// Failed to JSON-decode the host's response payload.
95    #[error("beam: deserialise response: {0}")]
96    DeserializeFailed(String),
97}
98
99/// Make an outbound HTTP request from this edge node.
100///
101/// Pass `None` for `opts` to do a plain `GET` with no extra headers.
102pub 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    // SAFETY: host writes the JSON response bytes into the bump arena.
115    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(); // Empty
202        fetch("https://x", Some(&opts)).unwrap();
203
204        let captured = test_host::read_mock(|m| m.last_beam_opts.clone()).unwrap();
205        // Empty defaults should be skipped: not present in the JSON
206        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}