Skip to main content

api_testing_core/rest/
schema.rs

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}