1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5
6use crate::Result;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct RestHeaders {
10 pub accept_key_present: bool,
11 pub user_headers: Vec<(String, String)>,
12}
13
14#[derive(Debug, Clone, PartialEq)]
15pub struct RestMultipartPart {
16 pub name: String,
17 pub value: Option<String>,
18 pub file_path: Option<String>,
19 pub base64: Option<String>,
20 pub filename: Option<String>,
21 pub content_type: Option<String>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct RestExpect {
26 pub status: u16,
27 pub jq: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct RestCleanup {
32 pub method: String,
33 pub path_template: String,
34 pub vars: BTreeMap<String, String>,
35 pub expect_status: u16,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub struct RestRequest {
40 pub method: String,
41 pub path: String,
42 pub query: BTreeMap<String, Vec<String>>,
43 pub headers: RestHeaders,
44 pub body: Option<serde_json::Value>,
45 pub multipart: Option<Vec<RestMultipartPart>>,
46 pub expect: Option<RestExpect>,
47 pub cleanup: Option<RestCleanup>,
48 pub raw: serde_json::Value,
49}
50
51#[derive(Debug, Clone, PartialEq)]
52pub struct RestRequestFile {
53 pub path: PathBuf,
54 pub request: RestRequest,
55}
56
57fn json_scalar_to_string(value: &serde_json::Value) -> Result<String> {
58 match value {
59 serde_json::Value::String(s) => Ok(s.clone()),
60 serde_json::Value::Number(n) => Ok(n.to_string()),
61 serde_json::Value::Bool(b) => Ok(b.to_string()),
62 serde_json::Value::Null => anyhow::bail!("query values must be scalar or array of scalars"),
63 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
64 anyhow::bail!("query values must be scalar or array of scalars")
65 }
66 }
67}
68
69fn json_value_to_string_loose(value: &serde_json::Value) -> String {
70 match value {
71 serde_json::Value::String(s) => s.clone(),
72 _ => value.to_string(),
73 }
74}
75
76fn is_valid_header_key(key: &str) -> bool {
77 !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
78}
79
80fn uri_encode_component(raw: &str) -> String {
81 let mut out = String::with_capacity(raw.len());
82 for &b in raw.as_bytes() {
83 let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
84 if unreserved {
85 out.push(b as char);
86 } else {
87 out.push('%');
88 out.push_str(&format!("{:02X}", b));
89 }
90 }
91 out
92}
93
94impl RestRequest {
95 pub fn query_string(&self) -> String {
96 let mut pairs: Vec<(String, String)> = Vec::new();
97 for (k, values) in &self.query {
98 for v in values {
99 pairs.push((uri_encode_component(k), uri_encode_component(v)));
100 }
101 }
102 pairs
103 .into_iter()
104 .map(|(k, v)| format!("{k}={v}"))
105 .collect::<Vec<_>>()
106 .join("&")
107 }
108}
109
110impl RestRequestFile {
111 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
112 let path = path.as_ref();
113 let bytes = std::fs::read(path)
114 .with_context(|| format!("read request file: {}", path.display()))?;
115 let raw: serde_json::Value = serde_json::from_slice(&bytes)
116 .map_err(|_| anyhow::anyhow!("Request file is not valid JSON: {}", path.display()))?;
117 let request = parse_rest_request_json(raw)?;
118 Ok(Self {
119 path: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
120 request,
121 })
122 }
123}
124
125pub fn parse_rest_request_json(raw: serde_json::Value) -> Result<RestRequest> {
126 let obj = raw
127 .as_object()
128 .context("Request file must be a JSON object")?;
129
130 let method_raw = obj
131 .get("method")
132 .and_then(|v| v.as_str())
133 .unwrap_or_default()
134 .trim()
135 .to_string();
136 if method_raw.is_empty() {
137 anyhow::bail!("Request is missing required field: method");
138 }
139 let method = method_raw.to_ascii_uppercase();
140 if !method.chars().all(|c| c.is_ascii_alphabetic()) {
141 anyhow::bail!("Invalid HTTP method: {method_raw}");
142 }
143
144 let path = obj
145 .get("path")
146 .and_then(|v| v.as_str())
147 .unwrap_or_default()
148 .trim()
149 .to_string();
150 if path.is_empty() {
151 anyhow::bail!("Request is missing required field: path");
152 }
153 if !path.starts_with('/') {
154 anyhow::bail!("Invalid path (must start with '/'): {path}");
155 }
156 if path.contains("://") {
157 anyhow::bail!("Invalid path (must be relative, no scheme/host): {path}");
158 }
159 if path.contains('?') {
160 anyhow::bail!("Invalid path (do not include query string; use .query): {path}");
161 }
162
163 let mut query: BTreeMap<String, Vec<String>> = BTreeMap::new();
164 if let Some(query_value) = obj.get("query")
165 && !query_value.is_null()
166 {
167 let q = query_value.as_object().context("query must be an object")?;
168 for (k, v) in q {
169 let values = match v {
170 serde_json::Value::Null => continue,
171 serde_json::Value::Array(arr) => {
172 let mut out: Vec<String> = Vec::new();
173 for el in arr {
174 if el.is_null() {
175 continue;
176 }
177 match el {
178 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
179 anyhow::bail!("query array elements must be scalars");
180 }
181 _ => out.push(json_scalar_to_string(el)?),
182 }
183 }
184 out
185 }
186 serde_json::Value::Object(_) => {
187 anyhow::bail!(
188 "query values must be scalars or arrays (objects are not allowed)"
189 );
190 }
191 _ => vec![json_scalar_to_string(v)?],
192 };
193
194 if !values.is_empty() {
195 query.insert(k.to_string(), values);
196 }
197 }
198 }
199
200 let mut accept_key_present = false;
201 let mut user_headers: Vec<(String, String)> = Vec::new();
202 if let Some(headers_value) = obj.get("headers")
203 && !headers_value.is_null()
204 {
205 let h = headers_value
206 .as_object()
207 .context("headers must be an object")?;
208 accept_key_present = h.keys().any(|k| k.eq_ignore_ascii_case("accept"));
209
210 for (k, v) in h {
211 if v.is_null() {
212 continue;
213 }
214
215 let key_lower = k.to_ascii_lowercase();
216 if key_lower == "authorization" || key_lower == "content-type" {
217 continue;
218 }
219
220 if !is_valid_header_key(k) {
221 anyhow::bail!("invalid header key: {k}");
222 }
223
224 if matches!(
225 v,
226 serde_json::Value::Object(_) | serde_json::Value::Array(_)
227 ) {
228 anyhow::bail!("header values must be scalars: {k}");
229 }
230
231 user_headers.push((k.to_string(), json_value_to_string_loose(v)));
232 }
233 }
234
235 let body = if obj.contains_key("body") {
236 Some(obj.get("body").cloned().unwrap_or(serde_json::Value::Null))
237 } else {
238 None
239 };
240
241 let multipart = if obj.contains_key("multipart") {
242 let mut parts: Vec<RestMultipartPart> = Vec::new();
243 match obj.get("multipart") {
244 Some(serde_json::Value::Null) | None => {}
245 Some(serde_json::Value::Array(arr)) => {
246 for part in arr {
247 let part_obj = part
248 .as_object()
249 .context("multipart parts must be objects")?;
250
251 let name = part_obj
252 .get("name")
253 .and_then(|v| v.as_str())
254 .unwrap_or_default()
255 .trim()
256 .to_string();
257 if name.is_empty() {
258 anyhow::bail!("Multipart part is missing required field: name");
259 }
260
261 let value = part_obj.get("value").and_then(|v| {
262 if v.is_null() {
263 None
264 } else {
265 let s = json_value_to_string_loose(v);
266 let s = s.trim().to_string();
267 (!s.is_empty()).then_some(s)
268 }
269 });
270 let file_path = part_obj
271 .get("filePath")
272 .and_then(|v| v.as_str())
273 .map(|s| s.trim().to_string())
274 .filter(|s| !s.is_empty());
275 let base64 = part_obj
276 .get("base64")
277 .and_then(|v| v.as_str())
278 .map(|s| s.trim().to_string())
279 .filter(|s| !s.is_empty());
280 let filename = part_obj
281 .get("filename")
282 .and_then(|v| v.as_str())
283 .map(|s| s.trim().to_string())
284 .filter(|s| !s.is_empty());
285 let content_type = part_obj
286 .get("contentType")
287 .and_then(|v| v.as_str())
288 .map(|s| s.trim().to_string())
289 .filter(|s| !s.is_empty());
290
291 parts.push(RestMultipartPart {
292 name,
293 value,
294 file_path,
295 base64,
296 filename,
297 content_type,
298 });
299 }
300 }
301 Some(_) => anyhow::bail!("multipart must be an array"),
302 }
303 Some(parts)
304 } else {
305 None
306 };
307
308 if body.is_some() && multipart.is_some() {
309 anyhow::bail!("Request cannot include both body and multipart.");
310 }
311
312 let expect = if obj.contains_key("expect") {
313 let expect_value = obj.get("expect").unwrap_or(&serde_json::Value::Null);
314 let expect_obj = expect_value.as_object();
315 let status_value = expect_obj
316 .and_then(|o| o.get("status"))
317 .unwrap_or(&serde_json::Value::Null);
318 let status_raw = if status_value.is_null() {
319 String::new()
320 } else {
321 json_value_to_string_loose(status_value)
322 };
323 let status_raw = status_raw.trim().to_string();
324 if status_raw.is_empty() {
325 anyhow::bail!("Request includes expect but is missing expect.status");
326 }
327 if !status_raw.chars().all(|c| c.is_ascii_digit()) {
328 anyhow::bail!("Invalid expect.status (must be an integer): {status_raw}");
329 }
330 let status: u16 = status_raw
331 .parse()
332 .with_context(|| format!("Invalid expect.status (must be an integer): {status_raw}"))?;
333
334 let jq = match expect_obj.and_then(|o| o.get("jq")) {
335 None | Some(serde_json::Value::Null) => None,
336 Some(serde_json::Value::String(s)) => {
337 let trimmed = s.trim().to_string();
338 (!trimmed.is_empty()).then_some(trimmed)
339 }
340 Some(_) => anyhow::bail!("expect.jq must be a string"),
341 };
342
343 Some(RestExpect { status, jq })
344 } else {
345 None
346 };
347
348 let cleanup = if obj.contains_key("cleanup") {
349 let cleanup_value = obj.get("cleanup").unwrap_or(&serde_json::Value::Null);
350 let cleanup_obj = cleanup_value
351 .as_object()
352 .context("cleanup must be an object")?;
353
354 let method_raw = cleanup_obj
355 .get("method")
356 .and_then(|v| v.as_str())
357 .unwrap_or("DELETE")
358 .trim()
359 .to_string();
360 if method_raw.is_empty() {
361 anyhow::bail!("cleanup.method is empty");
362 }
363 let method = method_raw.to_ascii_uppercase();
364
365 let path_template = cleanup_obj
366 .get("pathTemplate")
367 .and_then(|v| v.as_str())
368 .unwrap_or_default()
369 .trim()
370 .to_string();
371 if path_template.is_empty() {
372 anyhow::bail!("cleanup.pathTemplate is required");
373 }
374
375 let vars_value = cleanup_obj.get("vars").unwrap_or(&serde_json::Value::Null);
376 let mut vars: BTreeMap<String, String> = BTreeMap::new();
377 if !vars_value.is_null() {
378 let vars_obj = vars_value
379 .as_object()
380 .context("cleanup.vars must be an object")?;
381 for (k, v) in vars_obj {
382 let expr = v
383 .as_str()
384 .with_context(|| format!("cleanup var '{k}' must be a string"))?;
385 vars.insert(k.to_string(), expr.to_string());
386 }
387 }
388
389 let expect_status_raw = cleanup_obj
390 .get("expectStatus")
391 .and_then(|v| (!v.is_null()).then(|| json_value_to_string_loose(v)))
392 .unwrap_or_default();
393 let expect_status_raw = expect_status_raw.trim().to_string();
394 let expect_status_raw = if expect_status_raw.is_empty() {
395 if method == "DELETE" {
396 "204".to_string()
397 } else {
398 "200".to_string()
399 }
400 } else {
401 expect_status_raw
402 };
403 if !expect_status_raw.chars().all(|c| c.is_ascii_digit()) {
404 anyhow::bail!("Invalid cleanup.expectStatus (must be an integer): {expect_status_raw}");
405 }
406 let expect_status: u16 = expect_status_raw.parse().with_context(|| {
407 format!("Invalid cleanup.expectStatus (must be an integer): {expect_status_raw}")
408 })?;
409
410 Some(RestCleanup {
411 method,
412 path_template,
413 vars,
414 expect_status,
415 })
416 } else {
417 None
418 };
419
420 Ok(RestRequest {
421 method,
422 path,
423 query,
424 headers: RestHeaders {
425 accept_key_present,
426 user_headers,
427 },
428 body,
429 multipart,
430 expect,
431 cleanup,
432 raw,
433 })
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use pretty_assertions::assert_eq;
440
441 #[test]
442 fn rest_schema_parses_minimal_request() {
443 let raw = serde_json::json!({"method":"get","path":"/health"});
444 let req = parse_rest_request_json(raw).unwrap();
445 assert_eq!(req.method, "GET");
446 assert_eq!(req.path, "/health");
447 }
448
449 #[test]
450 fn rest_schema_query_string_sorts_keys_and_omits_nulls() {
451 let raw = serde_json::json!({
452 "method":"GET",
453 "path":"/q",
454 "query": { "b": [2, null, 3], "a": true, "z": null }
455 });
456 let req = parse_rest_request_json(raw).unwrap();
457 assert_eq!(req.query_string(), "a=true&b=2&b=3");
458 }
459
460 #[test]
461 fn rest_schema_rejects_body_and_multipart() {
462 let raw = serde_json::json!({
463 "method":"POST",
464 "path":"/x",
465 "body": {"a": 1},
466 "multipart": []
467 });
468 let err = parse_rest_request_json(raw).unwrap_err();
469 assert!(err.to_string().contains("both body and multipart"));
470 }
471
472 #[test]
473 fn rest_schema_headers_ignore_reserved_and_validate_keys() {
474 let raw = serde_json::json!({
475 "method":"GET",
476 "path":"/h",
477 "headers": {
478 "Authorization": "bad",
479 "Content-Type": "bad2",
480 "X-OK": 1
481 }
482 });
483 let req = parse_rest_request_json(raw).unwrap();
484 assert_eq!(
485 req.headers.user_headers,
486 vec![("X-OK".to_string(), "1".to_string())]
487 );
488 }
489
490 #[test]
491 fn rest_schema_expect_status_accepts_string() {
492 let raw = serde_json::json!({
493 "method":"GET",
494 "path":"/h",
495 "expect": {"status": "204"}
496 });
497 let req = parse_rest_request_json(raw).unwrap();
498 assert_eq!(req.expect.unwrap().status, 204);
499 }
500
501 #[test]
502 fn rest_schema_rejects_bad_expect_status() {
503 let raw = serde_json::json!({
504 "method":"GET",
505 "path":"/h",
506 "expect": {"status": "oops"}
507 });
508 let err = parse_rest_request_json(raw).unwrap_err();
509 assert!(err.to_string().contains("Invalid expect.status"));
510 }
511}