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 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}