Skip to main content

moonlit_pdk/
http.rs

1//! Tiny blocking HTTP client over the host `http_send` capability. Verbs, bearer
2//! auth, custom headers, JSON in/out, per-request timeout, and gzip response
3//! inflate. Sized for GitHub/GitLab/Slack/OpenAI/npm/NuGet-style REST calls.
4
5use std::io::Read;
6
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9
10use crate::context::Host;
11
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13pub enum HttpMethod {
14  Get,
15  Post,
16  Put,
17  Patch,
18  Delete,
19}
20
21/// Fully-formed request crossing the `Host` boundary (plain-Rust DTO).
22#[derive(Clone)]
23pub struct HttpRequestData {
24  pub method: HttpMethod,
25  pub scheme: String,
26  pub authority: String,
27  pub path_with_query: String,
28  pub headers: Vec<(String, String)>,
29  pub body: Option<Vec<u8>>,
30  pub timeout_ms: Option<u64>,
31}
32
33/// Raw response from the host: `body` is exactly the bytes received (still
34/// gzipped if the server gzipped it — the ergonomic layer inflates).
35pub struct HttpResponseData {
36  pub status: u16,
37  pub headers: Vec<(String, String)>,
38  pub body: Vec<u8>,
39}
40
41/// HTTP entry point, created via `ctx.http()`.
42pub struct Http<'a> {
43  host: &'a dyn Host,
44}
45
46impl<'a> Http<'a> {
47  #[must_use]
48  pub(crate) fn new(host: &'a dyn Host) -> Self {
49    Self { host }
50  }
51  pub fn get(&self, url: impl Into<String>) -> Request<'a> {
52    self.request(HttpMethod::Get, url)
53  }
54  pub fn post(&self, url: impl Into<String>) -> Request<'a> {
55    self.request(HttpMethod::Post, url)
56  }
57  pub fn put(&self, url: impl Into<String>) -> Request<'a> {
58    self.request(HttpMethod::Put, url)
59  }
60  pub fn patch(&self, url: impl Into<String>) -> Request<'a> {
61    self.request(HttpMethod::Patch, url)
62  }
63  pub fn delete(&self, url: impl Into<String>) -> Request<'a> {
64    self.request(HttpMethod::Delete, url)
65  }
66  fn request(&self, method: HttpMethod, url: impl Into<String>) -> Request<'a> {
67    Request {
68      host: self.host,
69      method,
70      url: url.into(),
71      headers: Vec::new(),
72      body: None,
73      timeout_ms: None,
74      error: None,
75    }
76  }
77}
78
79/// Fluent request builder. Terminal: `send()`.
80#[must_use = "a request does nothing until `.send()` is called"]
81pub struct Request<'a> {
82  host: &'a dyn Host,
83  method: HttpMethod,
84  url: String,
85  headers: Vec<(String, String)>,
86  body: Option<Vec<u8>>,
87  timeout_ms: Option<u64>,
88  /// Deferred error (e.g. JSON serialize) surfaced by `send()`.
89  error: Option<String>,
90}
91
92impl Request<'_> {
93  pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
94    self.headers.push((name.into(), value.into()));
95    self
96  }
97  pub fn bearer(mut self, token: impl AsRef<str>) -> Self {
98    self
99      .headers
100      .push(("authorization".to_string(), format!("Bearer {}", token.as_ref())));
101    self
102  }
103  pub fn json<T: Serialize>(mut self, value: &T) -> Self {
104    match serde_json::to_vec(value) {
105      Ok(bytes) => {
106        self
107          .headers
108          .push(("content-type".to_string(), "application/json".to_string()));
109        self.body = Some(bytes);
110      }
111      Err(e) => self.error = Some(format!("request json serialize: {e}")),
112    }
113    self
114  }
115  pub fn body_bytes(mut self, bytes: Vec<u8>) -> Self {
116    self.body = Some(bytes);
117    self
118  }
119  pub fn timeout_ms(mut self, ms: u64) -> Self {
120    self.timeout_ms = Some(ms);
121    self
122  }
123
124  pub fn send(self) -> Result<Response, String> {
125    if let Some(e) = self.error {
126      return Err(e);
127    }
128    let (scheme, authority, path_with_query) = parse_url(&self.url)?;
129    let mut headers = self.headers;
130    if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept-encoding")) {
131      headers.push(("accept-encoding".to_string(), "gzip".to_string()));
132    }
133    let req = HttpRequestData {
134      method: self.method,
135      scheme,
136      authority,
137      path_with_query,
138      headers,
139      body: self.body,
140      timeout_ms: self.timeout_ms,
141    };
142    let raw = self.host.http_send(&req)?;
143    Response::from_raw(raw)
144  }
145}
146
147/// A received HTTP response (body already gzip-inflated when applicable).
148pub struct Response {
149  status: u16,
150  headers: Vec<(String, String)>,
151  body: Vec<u8>,
152}
153
154impl Response {
155  fn from_raw(raw: HttpResponseData) -> Result<Self, String> {
156    let gzipped = raw
157      .headers
158      .iter()
159      .any(|(k, v)| k.eq_ignore_ascii_case("content-encoding") && v.to_ascii_lowercase().contains("gzip"));
160    let body = if gzipped {
161      let mut out = Vec::new();
162      flate2::read::GzDecoder::new(&raw.body[..])
163        .read_to_end(&mut out)
164        .map_err(|e| format!("gzip inflate: {e}"))?;
165      out
166    } else {
167      raw.body
168    };
169    Ok(Self {
170      status: raw.status,
171      headers: raw.headers,
172      body,
173    })
174  }
175  #[must_use]
176  pub fn status(&self) -> u16 {
177    self.status
178  }
179  #[must_use]
180  pub fn is_success(&self) -> bool {
181    (200..=299).contains(&self.status)
182  }
183  #[must_use]
184  pub fn header(&self, name: &str) -> Option<&str> {
185    self
186      .headers
187      .iter()
188      .find(|(k, _)| k.eq_ignore_ascii_case(name))
189      .map(|(_, v)| v.as_str())
190  }
191  #[must_use]
192  pub fn bytes(&self) -> &[u8] {
193    &self.body
194  }
195  pub fn text(&self) -> Result<String, String> {
196    String::from_utf8(self.body.clone()).map_err(|e| format!("response utf-8: {e}"))
197  }
198  pub fn json<T: DeserializeOwned>(&self) -> Result<T, String> {
199    serde_json::from_slice(&self.body).map_err(|e| format!("response json: {e}"))
200  }
201}
202
203/// Split `scheme://authority/path?query` into parts. Minimal by design: callers
204/// pass absolute URLs that include a path.
205fn parse_url(url: &str) -> Result<(String, String, String), String> {
206  let (scheme, rest) = url
207    .split_once("://")
208    .ok_or_else(|| format!("invalid url (no scheme): {url}"))?;
209  if scheme.is_empty() {
210    return Err(format!("invalid url (empty scheme): {url}"));
211  }
212  let (authority, path_and_query) = match rest.find('/') {
213    Some(i) => (&rest[..i], &rest[i..]),
214    None => (rest, "/"),
215  };
216  if authority.is_empty() {
217    return Err(format!("invalid url (empty authority): {url}"));
218  }
219  Ok((scheme.to_string(), authority.to_string(), path_and_query.to_string()))
220}
221
222#[cfg(test)]
223mod tests {
224  use super::*;
225  use crate::Context;
226  use crate::testing::MockHost;
227
228  #[test]
229  fn get_builds_request_and_reads_json() {
230    #[derive(serde::Deserialize)]
231    struct R {
232      tag: String,
233    }
234    let host = MockHost::new().with_http_response(200, br#"{"tag":"v1"}"#);
235    let ctx = Context::new(&host, "/w".into(), "s".into());
236    let resp = ctx
237      .http()
238      .get("https://api.example.com/repos?page=1")
239      .bearer("tok")
240      .send()
241      .unwrap();
242    assert!(resp.is_success());
243    let r: R = resp.json().unwrap();
244    assert_eq!(r.tag, "v1");
245
246    let reqs = host.recorded_requests();
247    assert_eq!(reqs[0].method, HttpMethod::Get);
248    assert_eq!(reqs[0].scheme, "https");
249    assert_eq!(reqs[0].authority, "api.example.com");
250    assert_eq!(reqs[0].path_with_query, "/repos?page=1");
251    assert!(reqs[0].headers.iter().any(|(k, v)| k == "authorization" && v == "Bearer tok"));
252    assert!(
253      reqs[0]
254        .headers
255        .iter()
256        .any(|(k, v)| k.eq_ignore_ascii_case("accept-encoding") && v == "gzip")
257    );
258  }
259
260  #[test]
261  fn post_json_sets_body_and_content_type() {
262    let host = MockHost::new().with_http_response(201, b"{}");
263    let ctx = Context::new(&host, "/w".into(), "s".into());
264    let body = serde_json::json!({ "name": "x" });
265    let _ = ctx.http().post("https://h/api").json(&body).send().unwrap();
266    let reqs = host.recorded_requests();
267    assert_eq!(reqs[0].method, HttpMethod::Post);
268    assert_eq!(reqs[0].body.as_deref().unwrap(), br#"{"name":"x"}"#);
269    assert!(
270      reqs[0]
271        .headers
272        .iter()
273        .any(|(k, v)| k == "content-type" && v == "application/json")
274    );
275  }
276
277  #[test]
278  fn verbs_map_correctly() {
279    let host = MockHost::new()
280      .with_http_response(200, b"")
281      .with_http_response(200, b"")
282      .with_http_response(200, b"");
283    let ctx = Context::new(&host, "/w".into(), "s".into());
284    ctx.http().put("https://h/a").send().unwrap();
285    ctx.http().patch("https://h/b").send().unwrap();
286    ctx.http().delete("https://h/c").send().unwrap();
287    let methods: Vec<_> = host.recorded_requests().iter().map(|r| r.method).collect();
288    assert_eq!(methods, vec![HttpMethod::Put, HttpMethod::Patch, HttpMethod::Delete]);
289  }
290
291  #[test]
292  fn gzip_response_is_inflated() {
293    use std::io::Write;
294    let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
295    enc.write_all(b"hello gzip").unwrap();
296    let gz = enc.finish().unwrap();
297    let host = MockHost::new().with_http_response_headers(200, vec![("content-encoding".to_string(), "gzip".to_string())], &gz);
298    let ctx = Context::new(&host, "/w".into(), "s".into());
299    let resp = ctx.http().get("https://h/x").send().unwrap();
300    assert_eq!(resp.text().unwrap(), "hello gzip");
301  }
302
303  #[test]
304  fn http_error_surfaces_from_send() {
305    let host = MockHost::new().with_http_error("network: host not permitted");
306    let ctx = Context::new(&host, "/w".into(), "s".into());
307    let e = ctx.http().get("https://h/x").send().err().expect("expected error");
308    assert!(e.contains("not permitted"), "got: {e}");
309  }
310
311  fn send_err(url: &str) -> String {
312    let host = MockHost::new().with_http_response(200, b"");
313    let ctx = Context::new(&host, "/w".into(), "s".into());
314    ctx.http().get(url).send().err().expect("expected url error")
315  }
316
317  #[test]
318  fn bad_url_is_err() {
319    assert!(send_err("no-scheme").contains("invalid url (no scheme)"));
320    assert!(send_err("://h/x").contains("invalid url (empty scheme)"));
321    assert!(send_err("https:///x").contains("invalid url (empty authority)"));
322  }
323
324  #[test]
325  fn url_without_a_path_requests_the_root() {
326    let host = MockHost::new().with_http_response(200, b"");
327    let ctx = Context::new(&host, "/w".into(), "s".into());
328    ctx.http().get("https://h").send().unwrap();
329    assert_eq!(host.recorded_requests()[0].path_with_query, "/");
330  }
331
332  #[test]
333  fn builder_sets_headers_body_and_timeout() {
334    let host = MockHost::new().with_http_response(204, b"");
335    let ctx = Context::new(&host, "/w".into(), "s".into());
336    ctx
337      .http()
338      .put("https://h/up")
339      .header("x-trace", "1")
340      .header("Accept-Encoding", "identity")
341      .body_bytes(b"raw".to_vec())
342      .timeout_ms(250)
343      .send()
344      .unwrap();
345    let req = &host.recorded_requests()[0];
346    assert_eq!(req.body.as_deref(), Some(&b"raw"[..]));
347    assert_eq!(req.timeout_ms, Some(250));
348    assert!(req.headers.iter().any(|(k, v)| k == "x-trace" && v == "1"));
349    assert!(!req.headers.iter().any(|(_, v)| v == "gzip"));
350  }
351
352  #[test]
353  fn json_body_that_cannot_serialize_fails_at_send() {
354    let host = MockHost::new().with_http_response(200, b"");
355    let ctx = Context::new(&host, "/w".into(), "s".into());
356    let body: std::collections::HashMap<(i32, i32), i32> = std::collections::HashMap::from([((1, 2), 3)]);
357    let e = ctx
358      .http()
359      .post("https://h/api")
360      .json(&body)
361      .send()
362      .err()
363      .expect("expected serialize error");
364    assert!(e.starts_with("request json serialize:"), "got: {e}");
365    assert!(host.recorded_requests().is_empty());
366  }
367
368  #[test]
369  fn response_exposes_status_headers_and_bytes() {
370    let host = MockHost::new().with_http_response_headers(404, vec![("X-Id".to_string(), "abc".to_string())], b"gone");
371    let ctx = Context::new(&host, "/w".into(), "s".into());
372    let resp = ctx.http().get("https://h/x").send().unwrap();
373    assert_eq!(resp.status(), 404);
374    assert!(!resp.is_success());
375    assert_eq!(resp.header("x-id"), Some("abc"));
376    assert_eq!(resp.header("missing"), None);
377    assert_eq!(resp.bytes(), b"gone");
378  }
379
380  #[test]
381  fn response_text_and_json_report_decode_errors() {
382    let host = MockHost::new().with_http_response(200, &[0xff, 0xfe]);
383    let ctx = Context::new(&host, "/w".into(), "s".into());
384    let resp = ctx.http().get("https://h/x").send().unwrap();
385    assert!(resp.text().expect_err("utf-8 error").starts_with("response utf-8:"));
386    assert!(
387      resp
388        .json::<serde_json::Value>()
389        .expect_err("json error")
390        .starts_with("response json:")
391    );
392  }
393}