1use anyhow::Context;
2use reqwest::Method;
3use reqwest::blocking::{Body, Client, multipart::Form};
4use reqwest::header::{CONTENT_TYPE, HeaderMap};
5
6use crate::Result;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct HttpResponse {
11 pub status: u16,
12 pub body: Vec<u8>,
13 pub content_type: Option<String>,
14}
15
16pub enum HttpBody {
24 None,
25 Bytes(Vec<u8>),
26 Multipart(Form),
27}
28
29pub fn execute_request(
37 method: Method,
38 url: &str,
39 headers: HeaderMap,
40 body: HttpBody,
41) -> Result<HttpResponse> {
42 let client = Client::new();
43 let mut builder = client.request(method.clone(), url).headers(headers);
44
45 builder = match body {
46 HttpBody::None => builder,
47 HttpBody::Bytes(bytes) => builder.body(Body::from(bytes)),
48 HttpBody::Multipart(form) => builder.multipart(form),
49 };
50
51 let response = builder
52 .send()
53 .with_context(|| format!("HTTP request failed: {method} {url}"))?;
54
55 let status = response.status().as_u16();
56 let content_type = response
57 .headers()
58 .get(CONTENT_TYPE)
59 .and_then(|v| v.to_str().ok())
60 .map(|s| s.to_string());
61 let body = response
62 .bytes()
63 .context("failed to read response body")?
64 .to_vec();
65
66 Ok(HttpResponse {
67 status,
68 body,
69 content_type,
70 })
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use nils_test_support::http::{HttpResponse as StubResponse, LoopbackServer};
77 use reqwest::header::{ACCEPT, HeaderValue};
78
79 #[test]
80 fn execute_request_returns_status_body_and_content_type_for_get() {
81 let server = LoopbackServer::new().expect("server");
82 server.add_route(
83 "GET",
84 "/echo",
85 StubResponse::new(200, r#"{"ok":true}"#)
86 .with_header("Content-Type", "application/json"),
87 );
88
89 let mut headers = HeaderMap::new();
90 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
91
92 let url = format!("{}/echo", server.url());
93 let response =
94 execute_request(Method::GET, &url, headers, HttpBody::None).expect("execute");
95
96 assert_eq!(response.status, 200);
97 assert_eq!(response.content_type.as_deref(), Some("application/json"));
98 assert_eq!(response.body, br#"{"ok":true}"#.to_vec());
99 }
100
101 #[test]
102 fn execute_request_sends_body_bytes_for_post() {
103 let server = LoopbackServer::new().expect("server");
104 server.add_route(
105 "POST",
106 "/widgets",
107 StubResponse::new(201, "").with_header("Content-Type", "text/plain"),
108 );
109
110 let mut headers = HeaderMap::new();
111 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
112
113 let url = format!("{}/widgets", server.url());
114 let body = br#"{"name":"alpha"}"#.to_vec();
115 let response =
116 execute_request(Method::POST, &url, headers, HttpBody::Bytes(body)).expect("execute");
117
118 assert_eq!(response.status, 201);
119 let received = server.take_requests();
120 assert_eq!(received.len(), 1);
121 assert_eq!(received[0].method, "POST");
122 assert!(received[0].body_text().contains("\"name\":\"alpha\""));
123 }
124
125 #[test]
126 fn execute_request_surfaces_transport_errors_with_method_and_url() {
127 let mut headers = HeaderMap::new();
128 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
129
130 let url = "http://127.0.0.1:1/unreachable";
133 let err = execute_request(Method::GET, url, headers, HttpBody::None).unwrap_err();
134 let msg = format!("{err:#}");
135 assert!(
136 msg.contains("HTTP request failed: GET") && msg.contains(url),
137 "expected error to mention method + URL; got: {msg}"
138 );
139 }
140}