flaron-sdk 0.99.0

Official Rust SDK for writing Flaron edge flares - WebAssembly modules that run on the Flaron CDN edge runtime.
Documentation
//! Outbound HTTP from inside a flare via the host's `beam_fetch`.
//!
//! Beam is the only network egress allowed from a flare - direct sockets are
//! not exposed. The host enforces a per-invocation request count
//! (`max_fetch_requests` on the flare config, default 16), routes the call
//! through the edge node's HTTP client, and returns the full response body.
//!
//! ```ignore
//! use flaron_sdk::beam::{self, FetchOptions};
//!
//! let opts = FetchOptions::default()
//!     .with_method("POST")
//!     .with_header("content-type", "application/json")
//!     .with_body(r#"{"hello":"world"}"#);
//!
//! match beam::fetch("https://api.example.com/echo", Some(&opts)) {
//!     Ok(resp) => flaron_sdk::logging::info(&format!("origin {}", resp.status)),
//!     Err(err) => flaron_sdk::logging::error(&err.to_string()),
//! }
//! ```

use std::collections::HashMap;

use crate::{ffi, mem};

/// Optional knobs for [`fetch`]. All fields have sensible defaults - leave
/// them empty for a plain `GET`.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct FetchOptions {
    /// HTTP method. Empty string is treated as `"GET"` by the host.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub method: String,

    /// Request headers. Header names are case-insensitive at the wire level.
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    pub headers: HashMap<String, String>,

    /// Request body as a UTF-8 string. For binary bodies, base64-encode the
    /// bytes and set the appropriate `content-encoding` header.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub body: Option<String>,
}

impl FetchOptions {
    /// Set the HTTP method.
    pub fn with_method(mut self, method: impl Into<String>) -> Self {
        self.method = method.into();
        self
    }

    /// Add or replace a single header.
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.insert(name.into(), value.into());
        self
    }

    /// Set the request body.
    pub fn with_body(mut self, body: impl Into<String>) -> Self {
        self.body = Some(body.into());
        self
    }
}

/// Successful response from [`fetch`].
#[derive(Debug, serde::Deserialize)]
pub struct FetchResponse {
    /// HTTP status code returned by the upstream.
    pub status: u16,

    /// Response headers (lowercased keys, as the host normalises them).
    #[serde(default)]
    pub headers: HashMap<String, String>,

    /// Response body as a UTF-8 string. Binary upstream responses are
    /// returned as the lossy UTF-8 conversion of their bytes - fetch a JSON
    /// or text endpoint, or have the upstream base64-encode binary data.
    #[serde(default)]
    pub body: String,
}

/// Errors returned by [`fetch`].
#[derive(Debug, thiserror::Error)]
pub enum BeamError {
    /// The host returned no response - typically means the per-invocation
    /// fetch limit was hit, the upstream timed out, or beam is not
    /// configured on this edge.
    #[error("beam: no response from host")]
    NoResponse,

    /// Failed to JSON-encode the [`FetchOptions`] argument.
    #[error("beam: serialise options: {0}")]
    SerializeFailed(String),

    /// Failed to JSON-decode the host's response payload.
    #[error("beam: deserialise response: {0}")]
    DeserializeFailed(String),
}

/// Make an outbound HTTP request from this edge node.
///
/// Pass `None` for `opts` to do a plain `GET` with no extra headers.
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) };

    // SAFETY: host writes the JSON response bytes into the bump arena.
    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(); // Empty
        fetch("https://x", Some(&opts)).unwrap();

        let captured = test_host::read_mock(|m| m.last_beam_opts.clone()).unwrap();
        // Empty defaults should be skipped: not present in the JSON
        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"));
    }
}