Skip to main content

api_testing_core/rest/
runner.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use base64::Engine;
5
6use crate::Result;
7use crate::rest::schema::{RestMultipartPart, RestRequestFile};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RestHttpResponse {
11    pub status: u16,
12    pub body: Vec<u8>,
13    pub content_type: Option<String>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RestExecutedRequest {
18    pub method: String,
19    pub url: String,
20    pub response: RestHttpResponse,
21}
22
23fn resolve_part_file_path(request_file: &Path, raw: &str) -> Result<PathBuf> {
24    let path = Path::new(raw);
25    if path.is_absolute() {
26        return Ok(path.to_path_buf());
27    }
28
29    let base_dir = request_file.parent().unwrap_or_else(|| Path::new("."));
30    Ok(base_dir.join(path))
31}
32
33fn build_multipart_form(
34    request_file: &Path,
35    parts: &[RestMultipartPart],
36) -> Result<Option<reqwest::blocking::multipart::Form>> {
37    if parts.is_empty() {
38        return Ok(None);
39    }
40
41    let mut form = reqwest::blocking::multipart::Form::new();
42    let mut added_parts = 0usize;
43
44    for part in parts {
45        let name = part.name.trim();
46        if name.is_empty() {
47            anyhow::bail!("Multipart part is missing required field: name");
48        }
49
50        if let Some(value) = &part.value {
51            let value = value.trim();
52            if !value.is_empty() {
53                form = form.text(name.to_string(), value.to_string());
54                added_parts += 1;
55                continue;
56            }
57        }
58
59        if let Some(payload) = &part.base64 {
60            let payload = payload.trim();
61            if !payload.is_empty() {
62                let bytes = base64::engine::general_purpose::STANDARD
63                    .decode(payload)
64                    .context("failed to decode multipart base64 payload")?;
65                let mut p = reqwest::blocking::multipart::Part::bytes(bytes);
66                let filename = part
67                    .filename
68                    .clone()
69                    .unwrap_or_else(|| "rest.multipart.bin".to_string());
70                p = p.file_name(filename);
71                if let Some(ct) = &part.content_type {
72                    p = p
73                        .mime_str(ct)
74                        .with_context(|| format!("invalid multipart contentType: {ct}"))?;
75                }
76                form = form.part(name.to_string(), p);
77                added_parts += 1;
78                continue;
79            }
80        }
81
82        let Some(file_path_raw) = part.file_path.as_deref() else {
83            anyhow::bail!("Multipart part '{name}' must include value, filePath, or base64.");
84        };
85
86        let file_path = resolve_part_file_path(request_file, file_path_raw)?;
87        if !file_path.is_file() {
88            anyhow::bail!(
89                "Multipart part '{name}' file not found: {}",
90                file_path.display()
91            );
92        }
93
94        let mut p = reqwest::blocking::multipart::Part::file(&file_path)
95            .with_context(|| format!("failed to open multipart file: {}", file_path.display()))?;
96
97        let filename = part.filename.clone().unwrap_or_else(|| {
98            file_path
99                .file_name()
100                .and_then(|s| s.to_str())
101                .unwrap_or("file")
102                .to_string()
103        });
104        p = p.file_name(filename);
105
106        if let Some(ct) = &part.content_type {
107            p = p
108                .mime_str(ct)
109                .with_context(|| format!("invalid multipart contentType: {ct}"))?;
110        }
111
112        form = form.part(name.to_string(), p);
113        added_parts += 1;
114    }
115
116    if added_parts == 0 {
117        Ok(None)
118    } else {
119        Ok(Some(form))
120    }
121}
122
123pub fn execute_rest_request(
124    request_file: &RestRequestFile,
125    base_url: &str,
126    bearer_token: Option<&str>,
127) -> Result<RestExecutedRequest> {
128    let req = &request_file.request;
129
130    let base = base_url.trim_end_matches('/');
131    let mut url = format!("{base}{}", req.path);
132    let query_string = req.query_string();
133    if !query_string.is_empty() {
134        url.push('?');
135        url.push_str(&query_string);
136    }
137
138    let method = reqwest::Method::from_bytes(req.method.as_bytes())
139        .with_context(|| format!("invalid HTTP method: {}", req.method))?;
140
141    let mut headers = reqwest::header::HeaderMap::new();
142    if !req.headers.accept_key_present {
143        headers.insert(
144            reqwest::header::ACCEPT,
145            reqwest::header::HeaderValue::from_static("application/json"),
146        );
147    }
148    if req.body.is_some() {
149        headers.insert(
150            reqwest::header::CONTENT_TYPE,
151            reqwest::header::HeaderValue::from_static("application/json"),
152        );
153    }
154    if let Some(token) = bearer_token {
155        let value = format!("Bearer {token}");
156        headers.insert(
157            reqwest::header::AUTHORIZATION,
158            reqwest::header::HeaderValue::from_str(&value)
159                .context("invalid Authorization header value")?,
160        );
161    }
162
163    for (k, v) in &req.headers.user_headers {
164        let name = reqwest::header::HeaderName::from_bytes(k.as_bytes())
165            .with_context(|| format!("invalid header name: {k}"))?;
166        let value = reqwest::header::HeaderValue::from_str(v)
167            .with_context(|| format!("invalid header value: {k}"))?;
168        headers.append(name, value);
169    }
170
171    let client = reqwest::blocking::Client::new();
172    let mut builder = client.request(method, &url).headers(headers);
173
174    if let Some(body) = &req.body {
175        let bytes = serde_json::to_vec(body).context("failed to serialize request body as JSON")?;
176        builder = builder.body(bytes);
177    } else if let Some(parts) = &req.multipart {
178        let form = build_multipart_form(&request_file.path, parts)?;
179        if let Some(form) = form {
180            builder = builder.multipart(form);
181        }
182    }
183
184    let response = builder
185        .send()
186        .with_context(|| format!("HTTP request failed: {} {}", req.method, url))?;
187
188    let status = response.status().as_u16();
189    let content_type = response
190        .headers()
191        .get(reqwest::header::CONTENT_TYPE)
192        .and_then(|v| v.to_str().ok())
193        .map(|s| s.to_string());
194    let body = response
195        .bytes()
196        .context("failed to read response body")?
197        .to_vec();
198
199    Ok(RestExecutedRequest {
200        method: req.method.clone(),
201        url,
202        response: RestHttpResponse {
203            status,
204            body,
205            content_type,
206        },
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use pretty_assertions::assert_eq;
214
215    use nils_test_support::http::{HttpResponse, LoopbackServer};
216    use tempfile::TempDir;
217
218    #[test]
219    fn rest_runner_url_construction_includes_sorted_query() {
220        let request_file = RestRequestFile {
221            path: PathBuf::from("/tmp/req.request.json"),
222            request: crate::rest::schema::parse_rest_request_json(serde_json::json!({
223                "method": "GET",
224                "path": "/health",
225                "query": { "b": 1, "a": true }
226            }))
227            .unwrap(),
228        };
229
230        // Not actually sending a request here; just validate the derived URL logic via build_multipart_form
231        // by calling execute_rest_request up to the point that constructs the URL would be awkward. Keep this
232        // as a lightweight unit check by asserting the computed URL through the public helper path.
233        let base = "http://localhost:6700/";
234        let req = &request_file.request;
235        let base = base.trim_end_matches('/');
236        let mut url = format!("{base}{}", req.path);
237        let qs = req.query_string();
238        if !qs.is_empty() {
239            url.push('?');
240            url.push_str(&qs);
241        }
242        assert_eq!(url, "http://localhost:6700/health?a=true&b=1");
243    }
244
245    #[test]
246    fn rest_runner_resolve_part_file_path_respects_absolute_and_relative() {
247        let request_file = Path::new("/tmp/req/request.json");
248        let absolute = resolve_part_file_path(request_file, "/var/data.bin").expect("abs");
249        assert_eq!(absolute, PathBuf::from("/var/data.bin"));
250
251        let relative = resolve_part_file_path(request_file, "data.bin").expect("rel");
252        assert_eq!(relative, PathBuf::from("/tmp/req/data.bin"));
253    }
254
255    #[test]
256    fn rest_runner_build_multipart_form_empty_returns_none() {
257        let request_file = Path::new("/tmp/req/request.json");
258        let form = build_multipart_form(request_file, &[]).expect("form");
259        assert!(form.is_none());
260    }
261
262    #[test]
263    fn rest_runner_build_multipart_form_errors_without_name() {
264        let request_file = Path::new("/tmp/req/request.json");
265        let parts = vec![RestMultipartPart {
266            name: " ".to_string(),
267            value: Some("hi".to_string()),
268            file_path: None,
269            base64: None,
270            filename: None,
271            content_type: None,
272        }];
273        let err = build_multipart_form(request_file, &parts).unwrap_err();
274        let msg = format!("{err:#}");
275        assert!(msg.contains("Multipart part is missing required field"));
276    }
277
278    #[test]
279    fn rest_runner_build_multipart_form_accepts_value_base64_and_file() {
280        let tmp = TempDir::new().expect("tmp");
281        let request_file = tmp.path().join("req.request.json");
282        let file_path = tmp.path().join("data.bin");
283        std::fs::write(&file_path, b"abc").expect("write");
284
285        let parts = vec![
286            RestMultipartPart {
287                name: "note".to_string(),
288                value: Some("hello".to_string()),
289                file_path: None,
290                base64: None,
291                filename: None,
292                content_type: None,
293            },
294            RestMultipartPart {
295                name: "raw".to_string(),
296                value: None,
297                file_path: None,
298                base64: Some("AQID".to_string()),
299                filename: Some("payload.bin".to_string()),
300                content_type: Some("application/octet-stream".to_string()),
301            },
302            RestMultipartPart {
303                name: "file".to_string(),
304                value: None,
305                file_path: Some("data.bin".to_string()),
306                base64: None,
307                filename: None,
308                content_type: None,
309            },
310        ];
311
312        let form = build_multipart_form(&request_file, &parts).expect("form");
313        assert!(form.is_some());
314    }
315
316    #[test]
317    fn rest_runner_execute_request_sends_headers_and_body() {
318        let server = LoopbackServer::new().expect("server");
319        server.add_route(
320            "POST",
321            "/widgets",
322            HttpResponse::new(200, r#"{"ok":true}"#)
323                .with_header("Content-Type", "application/json"),
324        );
325
326        let request_file = RestRequestFile {
327            path: PathBuf::from("/tmp/req.request.json"),
328            request: crate::rest::schema::parse_rest_request_json(serde_json::json!({
329                "method": "POST",
330                "path": "/widgets",
331                "headers": { "X-Trace": "1" },
332                "body": { "name": "alpha" }
333            }))
334            .unwrap(),
335        };
336
337        let executed =
338            execute_rest_request(&request_file, &server.url(), Some("token")).expect("execute");
339        assert_eq!(executed.response.status, 200);
340        assert_eq!(
341            executed.response.content_type.as_deref(),
342            Some("application/json")
343        );
344
345        let requests = server.take_requests();
346        assert_eq!(requests.len(), 1);
347        let req = &requests[0];
348        assert_eq!(req.method, "POST");
349        assert_eq!(req.path, "/widgets");
350        assert_eq!(
351            req.header_value("authorization").as_deref(),
352            Some("Bearer token")
353        );
354        assert_eq!(
355            req.header_value("accept").as_deref(),
356            Some("application/json")
357        );
358        assert!(req.body_text().contains("\"name\":\"alpha\""));
359    }
360}