use hyper::StatusCode;
use serde::Deserialize;
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
#[derive(Clone, Default, Deserialize, Debug)]
#[non_exhaustive]
pub struct Respond {
pub file_path: Option<String>,
pub csv_records_key: Option<String>,
pub text: Option<String>,
pub json: Option<String>,
pub status: Option<u16>,
#[serde(skip)]
pub status_code: Option<StatusCode>,
pub headers: Option<HashMap<String, Option<String>>>,
pub delay_response_milliseconds: Option<u32>,
}
impl Respond {
pub fn validate(
&self,
dir_prefix: &str,
rule_idx: usize,
rule_set_idx: usize,
) -> Result<(), String> {
let all_missing = self.file_path.is_none()
&& self.text.is_none()
&& self.json.is_none()
&& self.status.is_none();
if all_missing {
return Err(format!(
"at least one of file_path, text, json or status is required (rule #{} in rule set #{})",
rule_idx + 1,
rule_set_idx + 1
));
}
let body_sources_set = [
self.file_path.is_some(),
self.text.is_some(),
self.json.is_some(),
]
.into_iter()
.filter(|&set| set)
.count();
if body_sources_set > 1 {
return Err(format!(
"file_path, text and json are mutually exclusive; exactly one may be set (rule #{} in rule set #{})",
rule_idx + 1,
rule_set_idx + 1
));
}
if self.file_path.is_some() && self.status.is_some() {
return Err(format!(
"cannot use status with file_path; only with text or json (rule #{} in rule set #{})",
rule_idx + 1,
rule_set_idx + 1
));
}
if let Some(json_str) = self.json.as_ref() {
json5::from_str::<serde_json::Value>(json_str).map_err(|e| {
format!(
"invalid `json` (rule #{} in rule set #{}): {}",
rule_idx + 1,
rule_set_idx + 1,
e
)
})?;
}
if let Some(file_path) = self.file_path.as_ref() {
file_path_validate(file_path.as_str(), dir_prefix, rule_idx, rule_set_idx)?;
}
Ok(())
}
}
impl std::fmt::Display for Respond {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(status_code) = self.status_code {
let _ = writeln!(f, "status_code = {} ", status_code);
}
if let Some(text) = self.text.as_ref() {
let _ = writeln!(f, "text = `{}` ", text);
}
if let Some(json) = self.json.as_ref() {
let _ = writeln!(f, "json = `{}` ", json);
}
if let Some(file_path) = self.file_path.as_ref() {
let _ = writeln!(f, "file_path = `{}` ", file_path);
}
Ok(())
}
}
fn display_path(p: &Path) -> String {
use std::path::Component;
let cleaned: PathBuf = p
.components()
.filter(|c| !matches!(c, Component::CurDir))
.collect();
if cleaned.as_os_str().is_empty() {
".".to_owned()
} else {
cleaned.to_string_lossy().into_owned()
}
}
fn file_path_validate(
file_path: &str,
dir_prefix: &str,
rule_idx: usize,
rule_set_idx: usize,
) -> Result<(), String> {
let p = Path::new(dir_prefix).join(file_path);
if !p.exists() {
return Err(format!(
"file not found (rule #{} in rule set #{}): `{}`",
rule_idx + 1,
rule_set_idx + 1,
display_path(&p),
));
}
let is_json_like = p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| e == "json" || e == "json5");
if is_json_like {
let content = std::fs::read_to_string(&p).map_err(|e| {
format!(
"failed to read `{}` (rule #{} in rule set #{}): {}",
display_path(&p),
rule_idx + 1,
rule_set_idx + 1,
e
)
})?;
json5::from_str::<serde_json::Value>(content.as_str()).map_err(|e| {
format!(
"`{}` is not valid JSON (rule #{} in rule set #{}): {}",
display_path(&p),
rule_idx + 1,
rule_set_idx + 1,
e
)
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn respond_with(f: impl FnOnce(&mut Respond)) -> Respond {
let mut r = Respond::default();
f(&mut r);
r
}
#[test]
fn empty_respond_is_rejected() {
let r = Respond::default();
let err = r.validate(".", 0, 0).unwrap_err();
assert!(err.contains("at least one"), "message was: {err}");
}
#[test]
fn status_alone_is_accepted() {
let r = respond_with(|r| r.status = Some(204));
assert!(r.validate(".", 0, 0).is_ok());
}
#[test]
fn text_alone_is_accepted() {
let r = respond_with(|r| r.text = Some("hi".to_owned()));
assert!(r.validate(".", 0, 0).is_ok());
}
#[test]
fn json_alone_is_accepted() {
let r = respond_with(|r| r.json = Some(r#"{"a":1}"#.to_owned()));
assert!(r.validate(".", 0, 0).is_ok());
}
#[test]
fn json_and_text_together_are_rejected() {
let r = respond_with(|r| {
r.json = Some(r#"{"a":1}"#.to_owned());
r.text = Some("hi".to_owned());
});
let err = r.validate(".", 0, 0).unwrap_err();
assert!(err.contains("mutually exclusive"), "message was: {err}");
}
#[test]
fn json_and_file_path_together_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.json");
std::fs::write(&path, "{}").unwrap();
let r = respond_with(|r| {
r.json = Some(r#"{"a":1}"#.to_owned());
r.file_path = Some("data.json".to_owned());
});
let err = r.validate(dir.path().to_str().unwrap(), 0, 0).unwrap_err();
assert!(err.contains("mutually exclusive"), "message was: {err}");
}
#[test]
fn text_and_file_path_together_are_still_rejected() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f.txt"), "hi").unwrap();
let r = respond_with(|r| {
r.text = Some("hi".to_owned());
r.file_path = Some("f.txt".to_owned());
});
let err = r.validate(dir.path().to_str().unwrap(), 0, 0).unwrap_err();
assert!(err.contains("mutually exclusive"), "message was: {err}");
}
#[test]
fn file_path_with_status_is_still_rejected() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f.txt"), "hi").unwrap();
let r = respond_with(|r| {
r.file_path = Some("f.txt".to_owned());
r.status = Some(200);
});
let err = r.validate(dir.path().to_str().unwrap(), 0, 0).unwrap_err();
assert!(err.contains("status"), "message was: {err}");
}
#[test]
fn json_with_status_is_accepted() {
let r = respond_with(|r| {
r.json = Some(r#"{"error":"nope"}"#.to_owned());
r.status = Some(404);
});
assert!(r.validate(".", 0, 0).is_ok());
}
#[test]
fn malformed_inline_json_is_rejected_naming_the_rule() {
let r = respond_with(|r| r.json = Some("{not json".to_owned()));
let err = r.validate(".", 2, 1).unwrap_err();
assert!(err.contains("rule #3"), "message was: {err}");
assert!(err.contains("rule set #2"), "message was: {err}");
}
#[test]
fn malformed_referenced_json_file_is_rejected_naming_the_file_and_position() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "{\"a\": ,,,BROKEN").unwrap();
let r = respond_with(|r| r.file_path = Some("bad.json".to_owned()));
let err = r.validate(dir.path().to_str().unwrap(), 0, 0).unwrap_err();
assert!(err.contains("bad.json"), "message was: {err}");
assert!(err.contains("line"), "message was: {err}");
assert!(err.contains("column"), "message was: {err}");
}
#[test]
fn a_dot_dir_prefix_does_not_double_up_in_the_displayed_path() {
let r = respond_with(|r| r.file_path = Some("bad.json".to_owned()));
let err = r.validate("./.", 0, 0).unwrap_err();
assert!(
!err.contains("././bad.json") && !err.contains(".//./bad.json"),
"message should not show a doubled './': {err}"
);
assert!(err.contains("bad.json"), "message was: {err}");
}
#[test]
fn display_path_strips_current_dir_components() {
assert_eq!(
display_path(Path::new(".").join(".").join("bad.json").as_path()),
"bad.json"
);
let with_dir = Path::new("data").join("bad.json");
assert_eq!(display_path(&with_dir), with_dir.to_string_lossy());
assert_eq!(display_path(Path::new(".")), ".");
}
#[test]
fn malformed_referenced_json5_file_is_also_rejected() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("bad.json5"), "not json at all").unwrap();
let r = respond_with(|r| r.file_path = Some("bad.json5".to_owned()));
assert!(
r.validate(dir.path().to_str().unwrap(), 0, 0).is_err(),
"a malformed .json5 file must be rejected the same way as .json"
);
}
#[test]
fn valid_referenced_json_file_loads() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("good.json"), r#"{"a":1}"#).unwrap();
let r = respond_with(|r| r.file_path = Some("good.json".to_owned()));
assert!(r.validate(dir.path().to_str().unwrap(), 0, 0).is_ok());
}
#[test]
fn a_non_json_file_path_is_never_content_checked() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("data.csv"), "not,json,at,all\n1,2,3").unwrap();
let r = respond_with(|r| r.file_path = Some("data.csv".to_owned()));
assert!(r.validate(dir.path().to_str().unwrap(), 0, 0).is_ok());
}
#[test]
fn a_missing_file_path_is_still_rejected() {
let dir = tempfile::tempdir().unwrap();
let r = respond_with(|r| r.file_path = Some("does-not-exist.json".to_owned()));
let err = r.validate(dir.path().to_str().unwrap(), 0, 0).unwrap_err();
assert!(err.contains("does-not-exist.json"), "message was: {err}");
}
#[test]
fn load_time_and_the_json5_crate_agree_on_a_value_with_a_trailing_comma() {
let r = respond_with(|r| r.json = Some(r#"{"a":1,}"#.to_owned()));
assert!(
r.validate(".", 0, 0).is_ok(),
"a JSON5-legal trailing comma must be accepted, proving the JSON5 parser is in use"
);
}
}