standard-plugin-sdk 0.1.0

Write Standard Code plugins in Rust: wasm components against standard:plugin@2.0.0
Documentation
//! An HTTP client over `wasi:http` (`wasi` feature): any method, request
//! headers and body, a timeout, the response's status, headers and body,
//! and a cap on how much body is read. Grant: `fetch:<host>` (or
//! `fetch:<scheme>://<host>[:<port>]`, `network.full`, `machine.full`);
//! the host refuses any other host before a connection is made, and the
//! plugin sees `Error::GrantDenied { grant: "fetch:<host>" }`.
//!
//! Natively (`cargo test`) requests go to the test host
//! ([`crate::testing::MockHost::http`]), which checks the same grants,
//! records every request and answers from the responses the test queued.
//!
//! ```rust,ignore
#![doc = include_str!("../../examples/daemon_http.rs")]
//! ```

use alloc::string::String;
use alloc::vec::Vec;

use crate::error::{Error, Result};

/// How long a request may take when it names no timeout: headers and the
/// whole body.
pub const DEFAULT_TIMEOUT_MS: u32 = 30_000;
/// The most body a response reads when the request names no cap.
pub const DEFAULT_MAX_BODY: usize = 4 * 1024 * 1024;

/// A request. Build it with [`Request::get`], [`Request::post`] or
/// [`Request::new`], then [`Request::send`] it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
    pub method: String,
    pub url: String,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
    /// The whole exchange: connecting, the response's headers and its body.
    pub timeout_ms: u32,
    /// A body longer than this is refused with [`Error::Invalid`] (its
    /// `Content-Length` before anything is read, or as it streams in).
    pub max_body: usize,
}

/// A response: status, headers (names lowercase, in order) and body.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Response {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
}

impl Response {
    /// A response with `status` and nothing else: what a test queues
    /// (`MockHost::http`), with [`Response::header`] and [`Response::body`]
    /// set by the builders below.
    pub fn new(status: u16) -> Self {
        Self {
            status,
            headers: Vec::new(),
            body: Vec::new(),
        }
    }

    /// Adds a header (the name lowercased, as a host reports it).
    pub fn with_header(mut self, name: &str, value: &str) -> Self {
        self.headers
            .push((name.to_ascii_lowercase(), String::from(value)));
        self
    }

    pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
        self.body = body.into();
        self
    }

    /// A JSON body and its `content-type`.
    pub fn with_json<T: serde::Serialize + ?Sized>(self, value: &T) -> Result<Self> {
        let body = serde_json::to_vec(value).map_err(Error::json)?;
        Ok(self
            .with_header("content-type", "application/json")
            .with_body(body))
    }

    /// The first value of header `name` (any case).
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(key, _)| key.eq_ignore_ascii_case(name))
            .map(|(_, value)| value.as_str())
    }

    /// Whether the status is 2xx.
    pub fn ok(&self) -> bool {
        (200..300).contains(&self.status)
    }

    pub fn text(&self) -> Result<&str> {
        core::str::from_utf8(&self.body).map_err(|error| Error::Invalid(alloc::format!("{error}")))
    }

    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
        serde_json::from_slice(&self.body).map_err(Error::json)
    }
}

impl Request {
    /// `method` (`GET`, `POST`, `PATCH`, ...) to `url` (`https://host/path?query`).
    pub fn new(method: &str, url: &str) -> Self {
        Self {
            method: String::from(method),
            url: String::from(url),
            headers: Vec::new(),
            body: Vec::new(),
            timeout_ms: DEFAULT_TIMEOUT_MS,
            max_body: DEFAULT_MAX_BODY,
        }
    }

    pub fn get(url: &str) -> Self {
        Self::new("GET", url)
    }

    pub fn post(url: &str) -> Self {
        Self::new("POST", url)
    }

    /// Adds a request header (`Authorization`, `Accept`, ...).
    pub fn header(mut self, name: &str, value: &str) -> Self {
        self.headers.push((String::from(name), String::from(value)));
        self
    }

    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
        self.body = body.into();
        self
    }

    /// A JSON body and its `Content-Type`.
    pub fn json<T: serde::Serialize + ?Sized>(self, value: &T) -> Result<Self> {
        let body = serde_json::to_vec(value).map_err(Error::json)?;
        Ok(self.header("content-type", "application/json").body(body))
    }

    /// The whole exchange may take `timeout_ms`; past it the request fails
    /// with `Error::Unavailable("timeout")`.
    pub fn timeout_ms(mut self, timeout_ms: u32) -> Self {
        self.timeout_ms = timeout_ms.max(1);
        self
    }

    /// Reads at most `bytes` of body; a longer one fails with
    /// [`Error::Invalid`].
    pub fn max_body(mut self, bytes: usize) -> Self {
        self.max_body = bytes;
        self
    }

    /// Sends the request and reads the response on the daemon executor.
    pub async fn send(self) -> Result<Response> {
        let target = split_url(&self.url)?;
        #[cfg(target_arch = "wasm32")]
        let answer = super::http_wasi::send(&self, &target).await;
        #[cfg(not(target_arch = "wasm32"))]
        let answer = crate::testing::http_send(&self, &target);
        answer
    }
}

/// `GET url` with the default timeout and cap.
pub async fn get(url: &str) -> Result<Response> {
    Request::get(url).send().await
}

#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn timeout() -> Error {
    Error::Unavailable("timeout".into())
}

pub(crate) fn too_long(max: usize) -> Error {
    Error::Invalid(alloc::format!("the response body is over {max} bytes"))
}

/// A URL split as a request needs it.
pub(crate) struct Target<'a> {
    /// `https` or `http`.
    pub scheme: &'static str,
    /// `host[:port]`.
    pub authority: &'a str,
    /// The path and query, `/` at least.
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
    pub path: &'a str,
}

impl Target<'_> {
    /// The host, lowercase, without the port.
    pub fn host(&self) -> String {
        let host = match self.authority.rsplit_once(':') {
            Some((host, port)) if port.bytes().all(|byte| byte.is_ascii_digit()) => host,
            _ => self.authority,
        };
        host.to_ascii_lowercase()
    }

    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
    pub fn port(&self) -> Option<u16> {
        self.authority
            .rsplit_once(':')
            .and_then(|(_, port)| port.parse().ok())
    }

    /// What a refusal of this request names: `fetch:<host>`.
    pub fn denied(&self) -> Error {
        Error::GrantDenied {
            grant: alloc::format!("fetch:{}", self.host()),
        }
    }
}

pub(crate) fn split_url(url: &str) -> Result<Target<'_>> {
    let (scheme, rest) = if let Some(rest) = url.strip_prefix("https://") {
        ("https", rest)
    } else if let Some(rest) = url.strip_prefix("http://") {
        ("http", rest)
    } else {
        return Err(Error::Invalid(alloc::format!("not an http(s) url: {url}")));
    };
    let (authority, path) = match rest.find(['/', '?']) {
        Some(index) if rest.as_bytes()[index] == b'/' => (&rest[..index], &rest[index..]),
        Some(index) => (&rest[..index], &rest[index..]),
        None => (rest, "/"),
    };
    if authority.is_empty() {
        return Err(Error::Invalid(alloc::format!("no host in {url}")));
    }
    Ok(Target {
        scheme,
        authority,
        path,
    })
}