Skip to main content

standard_plugin/daemon/
http.rs

1//! An HTTP client over `wasi:http` (`wasi` feature): any method, request
2//! headers and body, a timeout, the response's status, headers and body,
3//! and a cap on how much body is read. Grant: `fetch:<host>` (or
4//! `fetch:<scheme>://<host>[:<port>]`, `network.full`, `machine.full`);
5//! the host refuses any other host before a connection is made, and the
6//! plugin sees `Error::GrantDenied { grant: "fetch:<host>" }`.
7//!
8//! Natively (`cargo test`) requests go to the test host
9//! ([`crate::testing::MockHost::http`]), which checks the same grants,
10//! records every request and answers from the responses the test queued.
11//!
12//! ```rust,ignore
13#![doc = include_str!("../../examples/daemon_http.rs")]
14//! ```
15
16use alloc::string::String;
17use alloc::vec::Vec;
18
19use crate::error::{Error, Result};
20
21/// How long a request may take when it names no timeout: headers and the
22/// whole body.
23pub const DEFAULT_TIMEOUT_MS: u32 = 30_000;
24/// The most body a response reads when the request names no cap.
25pub const DEFAULT_MAX_BODY: usize = 4 * 1024 * 1024;
26
27/// A request. Build it with [`Request::get`], [`Request::post`] or
28/// [`Request::new`], then [`Request::send`] it.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Request {
31    pub method: String,
32    pub url: String,
33    pub headers: Vec<(String, String)>,
34    pub body: Vec<u8>,
35    /// The whole exchange: connecting, the response's headers and its body.
36    pub timeout_ms: u32,
37    /// A body longer than this is refused with [`Error::Invalid`] (its
38    /// `Content-Length` before anything is read, or as it streams in).
39    pub max_body: usize,
40}
41
42/// A response: status, headers (names lowercase, in order) and body.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct Response {
45    pub status: u16,
46    pub headers: Vec<(String, String)>,
47    pub body: Vec<u8>,
48}
49
50impl Response {
51    /// A response with `status` and nothing else: what a test queues
52    /// (`MockHost::http`), with [`Response::header`] and [`Response::body`]
53    /// set by the builders below.
54    pub fn new(status: u16) -> Self {
55        Self {
56            status,
57            headers: Vec::new(),
58            body: Vec::new(),
59        }
60    }
61
62    /// Adds a header (the name lowercased, as a host reports it).
63    pub fn with_header(mut self, name: &str, value: &str) -> Self {
64        self.headers
65            .push((name.to_ascii_lowercase(), String::from(value)));
66        self
67    }
68
69    pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
70        self.body = body.into();
71        self
72    }
73
74    /// A JSON body and its `content-type`.
75    pub fn with_json<T: serde::Serialize + ?Sized>(self, value: &T) -> Result<Self> {
76        let body = serde_json::to_vec(value).map_err(Error::json)?;
77        Ok(self
78            .with_header("content-type", "application/json")
79            .with_body(body))
80    }
81
82    /// The first value of header `name` (any case).
83    pub fn header(&self, name: &str) -> Option<&str> {
84        self.headers
85            .iter()
86            .find(|(key, _)| key.eq_ignore_ascii_case(name))
87            .map(|(_, value)| value.as_str())
88    }
89
90    /// Whether the status is 2xx.
91    pub fn ok(&self) -> bool {
92        (200..300).contains(&self.status)
93    }
94
95    pub fn text(&self) -> Result<&str> {
96        core::str::from_utf8(&self.body).map_err(|error| Error::Invalid(alloc::format!("{error}")))
97    }
98
99    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
100        serde_json::from_slice(&self.body).map_err(Error::json)
101    }
102}
103
104impl Request {
105    /// `method` (`GET`, `POST`, `PATCH`, ...) to `url` (`https://host/path?query`).
106    pub fn new(method: &str, url: &str) -> Self {
107        Self {
108            method: String::from(method),
109            url: String::from(url),
110            headers: Vec::new(),
111            body: Vec::new(),
112            timeout_ms: DEFAULT_TIMEOUT_MS,
113            max_body: DEFAULT_MAX_BODY,
114        }
115    }
116
117    pub fn get(url: &str) -> Self {
118        Self::new("GET", url)
119    }
120
121    pub fn post(url: &str) -> Self {
122        Self::new("POST", url)
123    }
124
125    /// Adds a request header (`Authorization`, `Accept`, ...).
126    pub fn header(mut self, name: &str, value: &str) -> Self {
127        self.headers.push((String::from(name), String::from(value)));
128        self
129    }
130
131    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
132        self.body = body.into();
133        self
134    }
135
136    /// A JSON body and its `Content-Type`.
137    pub fn json<T: serde::Serialize + ?Sized>(self, value: &T) -> Result<Self> {
138        let body = serde_json::to_vec(value).map_err(Error::json)?;
139        Ok(self.header("content-type", "application/json").body(body))
140    }
141
142    /// The whole exchange may take `timeout_ms`; past it the request fails
143    /// with `Error::Unavailable("timeout")`.
144    pub fn timeout_ms(mut self, timeout_ms: u32) -> Self {
145        self.timeout_ms = timeout_ms.max(1);
146        self
147    }
148
149    /// Reads at most `bytes` of body; a longer one fails with
150    /// [`Error::Invalid`].
151    pub fn max_body(mut self, bytes: usize) -> Self {
152        self.max_body = bytes;
153        self
154    }
155
156    /// Sends the request and reads the response on the daemon executor.
157    pub async fn send(self) -> Result<Response> {
158        let target = split_url(&self.url)?;
159        #[cfg(target_arch = "wasm32")]
160        let answer = super::http_wasi::send(&self, &target).await;
161        #[cfg(not(target_arch = "wasm32"))]
162        let answer = crate::testing::http_send(&self, &target);
163        answer
164    }
165}
166
167/// `GET url` with the default timeout and cap.
168pub async fn get(url: &str) -> Result<Response> {
169    Request::get(url).send().await
170}
171
172#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
173pub(crate) fn timeout() -> Error {
174    Error::Unavailable("timeout".into())
175}
176
177pub(crate) fn too_long(max: usize) -> Error {
178    Error::Invalid(alloc::format!("the response body is over {max} bytes"))
179}
180
181/// A URL split as a request needs it.
182pub(crate) struct Target<'a> {
183    /// `https` or `http`.
184    pub scheme: &'static str,
185    /// `host[:port]`.
186    pub authority: &'a str,
187    /// The path and query, `/` at least.
188    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
189    pub path: &'a str,
190}
191
192impl Target<'_> {
193    /// The host, lowercase, without the port.
194    pub fn host(&self) -> String {
195        let host = match self.authority.rsplit_once(':') {
196            Some((host, port)) if port.bytes().all(|byte| byte.is_ascii_digit()) => host,
197            _ => self.authority,
198        };
199        host.to_ascii_lowercase()
200    }
201
202    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
203    pub fn port(&self) -> Option<u16> {
204        self.authority
205            .rsplit_once(':')
206            .and_then(|(_, port)| port.parse().ok())
207    }
208
209    /// What a refusal of this request names: `fetch:<host>`.
210    pub fn denied(&self) -> Error {
211        Error::GrantDenied {
212            grant: alloc::format!("fetch:{}", self.host()),
213        }
214    }
215}
216
217pub(crate) fn split_url(url: &str) -> Result<Target<'_>> {
218    let (scheme, rest) = if let Some(rest) = url.strip_prefix("https://") {
219        ("https", rest)
220    } else if let Some(rest) = url.strip_prefix("http://") {
221        ("http", rest)
222    } else {
223        return Err(Error::Invalid(alloc::format!("not an http(s) url: {url}")));
224    };
225    let (authority, path) = match rest.find(['/', '?']) {
226        Some(index) if rest.as_bytes()[index] == b'/' => (&rest[..index], &rest[index..]),
227        Some(index) => (&rest[..index], &rest[index..]),
228        None => (rest, "/"),
229    };
230    if authority.is_empty() {
231        return Err(Error::Invalid(alloc::format!("no host in {url}")));
232    }
233    Ok(Target {
234        scheme,
235        authority,
236        path,
237    })
238}