1use 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#[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
33pub struct HttpResponseData {
36 pub status: u16,
37 pub headers: Vec<(String, String)>,
38 pub body: Vec<u8>,
39}
40
41pub 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#[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 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
147pub 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
203fn 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}