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