Skip to main content

api_testing_core/
http.rs

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/// Minimal protocol-agnostic HTTP response, shared by every backend runner.
9#[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
16/// Body shape for [`execute_request`].
17///
18/// `Multipart` intentionally exposes [`reqwest::blocking::multipart::Form`]
19/// because the form is composed by the caller (REST runners assemble fields,
20/// files, and base64 payloads outside the generic HTTP layer). Inline reqwest
21/// types are an acceptable leak inside this workspace: every consumer already
22/// depends on reqwest, and abstracting `Form` would only add boilerplate.
23pub enum HttpBody {
24    None,
25    Bytes(Vec<u8>),
26    Multipart(Form),
27}
28
29/// Send a blocking HTTP request and read the response into [`HttpResponse`].
30///
31/// This helper is deliberately protocol-agnostic: the caller composes the URL,
32/// headers, and body, then hands them in. REST-aware decisions
33/// (Accept defaults, bearer-token shaping, JSON serialisation, multipart
34/// assembly) live in `rest::runner::execute_rest_request`; future
35/// non-REST backends layer their own conventions on top of this primitive.
36pub 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        // Port 1 is reserved (tcpmux) and effectively guaranteed to refuse a
131        // local TCP connection on test infrastructure.
132        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}