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