use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SchemaStatus {
Valid,
Invalid,
NoSidecar,
ReadError(String),
SchemaParseError(String),
NotJson,
}
#[derive(Clone, Debug)]
pub struct SchemaResult {
pub status: SchemaStatus,
pub errors: Vec<String>,
pub schema_path: Option<PathBuf>,
}
impl SchemaResult {
pub fn no_sidecar() -> Self {
Self {
status: SchemaStatus::NoSidecar,
errors: Vec::new(),
schema_path: None,
}
}
}
pub fn resolve_sidecar(source_path: &Path) -> Option<PathBuf> {
let parent = source_path.parent()?;
let file_name = source_path.file_name()?.to_str()?;
if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) {
let cand = parent.join(format!("{stem}.schema.json"));
if cand.is_file() {
return Some(cand);
}
}
let cand = parent.join(format!("{file_name}.schema.json"));
if cand.is_file() {
return Some(cand);
}
None
}
pub fn validate_body(body: &str, schema_path: &Path) -> SchemaResult {
let schema_text = match std::fs::read_to_string(schema_path) {
Ok(s) => s,
Err(e) => {
return SchemaResult {
status: SchemaStatus::ReadError(e.to_string()),
errors: Vec::new(),
schema_path: Some(schema_path.to_path_buf()),
};
}
};
let schema_val: serde_json::Value = match serde_json::from_str(&schema_text) {
Ok(v) => v,
Err(e) => {
return SchemaResult {
status: SchemaStatus::SchemaParseError(e.to_string()),
errors: Vec::new(),
schema_path: Some(schema_path.to_path_buf()),
};
}
};
let body_val: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => {
return SchemaResult {
status: SchemaStatus::NotJson,
errors: Vec::new(),
schema_path: Some(schema_path.to_path_buf()),
};
}
};
let validator = match jsonschema::validator_for(&schema_val) {
Ok(v) => v,
Err(e) => {
return SchemaResult {
status: SchemaStatus::SchemaParseError(e.to_string()),
errors: Vec::new(),
schema_path: Some(schema_path.to_path_buf()),
};
}
};
let errors: Vec<String> = validator
.iter_errors(&body_val)
.map(|e| {
let path = e.instance_path.to_string();
if path.is_empty() {
e.to_string()
} else {
format!("{path}: {e}")
}
})
.collect();
let status = if errors.is_empty() {
SchemaStatus::Valid
} else {
SchemaStatus::Invalid
};
SchemaResult {
status,
errors,
schema_path: Some(schema_path.to_path_buf()),
}
}
pub fn validate_for(source_path: Option<&Path>, body: &str) -> SchemaResult {
let Some(src) = source_path else {
return SchemaResult::no_sidecar();
};
let Some(schema) = resolve_sidecar(src) else {
return SchemaResult::no_sidecar();
};
validate_body(body, &schema)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, content).unwrap();
p
}
#[test]
fn no_sidecar_returns_nosidecar() {
let r = validate_for(None, "{}");
assert_eq!(r.status, SchemaStatus::NoSidecar);
}
#[test]
fn resolves_stem_dot_schema_json() {
let d = tempdir().unwrap();
let src = write(d.path(), "users.curl", "GET /users");
write(d.path(), "users.schema.json", r#"{"type":"object"}"#);
let found = resolve_sidecar(&src);
assert_eq!(found.unwrap().file_name().unwrap(), "users.schema.json");
}
#[test]
fn valid_body() {
let d = tempdir().unwrap();
let src = write(d.path(), "users.curl", "GET /users");
write(
d.path(),
"users.schema.json",
r#"{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}"#,
);
let r = validate_for(Some(&src), r#"{"name":"alice"}"#);
assert_eq!(r.status, SchemaStatus::Valid);
assert!(r.errors.is_empty());
}
#[test]
fn invalid_body_reports_errors() {
let d = tempdir().unwrap();
let src = write(d.path(), "users.curl", "GET /users");
write(
d.path(),
"users.schema.json",
r#"{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}"#,
);
let r = validate_for(Some(&src), r#"{"name":42}"#);
assert_eq!(r.status, SchemaStatus::Invalid);
assert!(!r.errors.is_empty());
}
#[test]
fn non_json_body_reported_as_not_json() {
let d = tempdir().unwrap();
let src = write(d.path(), "users.curl", "GET /users");
write(d.path(), "users.schema.json", r#"{"type":"object"}"#);
let r = validate_for(Some(&src), "<html>not json</html>");
assert_eq!(r.status, SchemaStatus::NotJson);
}
#[test]
fn malformed_schema_surfaces_parse_error() {
let d = tempdir().unwrap();
let src = write(d.path(), "users.curl", "GET /users");
write(d.path(), "users.schema.json", "{ not json");
let r = validate_for(Some(&src), r#"{"name":"x"}"#);
assert!(matches!(r.status, SchemaStatus::SchemaParseError(_)));
}
}