use crate::error::{Error, Result};
use serde_json::Value;
use std::path::Path;
use tracing::debug;
#[derive(Debug)]
pub struct Schema {
name: String,
schema: Value,
compiled: jsonschema::JSONSchema,
}
impl Clone for Schema {
fn clone(&self) -> Self {
Self::new(self.name.clone(), self.schema.clone())
.expect("Schema was valid before, should be valid now")
}
}
impl Schema {
pub fn new(name: impl Into<String>, schema: Value) -> Result<Self> {
let name = name.into();
debug!(%name, "Creating schema");
let compiled =
jsonschema::JSONSchema::compile(&schema).map_err(|e| Error::InvalidSchema {
message: e.to_string(),
})?;
Ok(Self {
name,
schema,
compiled,
})
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)?;
let schema: Value = serde_json::from_str(&content)?;
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
Self::new(name, schema)
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn validate(&self, value: &Value) -> ValidationResult {
let mut errors = Vec::new();
if let Err(validation_errors) = self.compiled.validate(value) {
for error in validation_errors {
errors.push(ValidationError {
message: error.to_string(),
path: error.instance_path.to_string(),
keyword: format!("{:?}", error.kind),
});
}
}
ValidationResult { errors }
}
#[must_use]
pub fn is_valid(&self, value: &Value) -> bool {
self.compiled.is_valid(value)
}
#[must_use]
pub fn definition(&self) -> &Value {
&self.schema
}
}
#[derive(Debug, Default)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
}
impl ValidationResult {
#[must_use]
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
#[must_use]
pub fn error_count(&self) -> usize {
self.errors.len()
}
pub fn into_result(self) -> Result<()> {
if self.is_valid() {
Ok(())
} else {
let message = self
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
Err(Error::ValidationFailed {
message,
path: self.errors.first().map(|e| e.path.clone()),
})
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub message: String,
pub path: String,
pub keyword: String,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.path.is_empty() {
write!(f, "{}", self.message)
} else {
write!(f, "{}: {}", self.path, self.message)
}
}
}
#[must_use]
pub fn article_schema() -> Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1
},
"authors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"affiliation": { "type": "string" },
"orcid": { "type": "string", "pattern": "^\\d{4}-\\d{4}-\\d{4}-\\d{3}[\\dX]$" }
},
"required": ["name"]
}
},
"date": { "type": "string" },
"abstract": { "type": "string" },
"keywords": {
"type": "array",
"items": { "type": "string" }
},
"template": { "type": "string" },
"citation_style": { "type": "string" },
"bibliography": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["title"]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_validate_valid_document() {
let schema = Schema::new("test", article_schema()).unwrap();
let valid_doc = serde_json::json!({
"title": "My Document",
"authors": [{ "name": "John Doe" }]
});
let result = schema.validate(&valid_doc);
assert!(result.is_valid());
}
#[test]
fn schema_validate_missing_required() {
let schema = Schema::new("test", article_schema()).unwrap();
let invalid_doc = serde_json::json!({
"authors": [{ "name": "John Doe" }]
});
let result = schema.validate(&invalid_doc);
assert!(!result.is_valid());
assert_eq!(result.error_count(), 1);
}
#[test]
fn schema_validate_invalid_type() {
let schema = Schema::new("test", article_schema()).unwrap();
let invalid_doc = serde_json::json!({
"title": 123 });
let result = schema.validate(&invalid_doc);
assert!(!result.is_valid());
}
#[test]
fn schema_is_valid_shortcut() {
let schema = Schema::new("test", article_schema()).unwrap();
let valid = serde_json::json!({ "title": "Test" });
let invalid = serde_json::json!({ "title": "" });
assert!(schema.is_valid(&valid));
assert!(!schema.is_valid(&invalid));
}
#[test]
fn validation_result_into_result() {
let valid_result = ValidationResult::default();
assert!(valid_result.into_result().is_ok());
let invalid_result = ValidationResult {
errors: vec![ValidationError {
message: "test error".to_string(),
path: "/title".to_string(),
keyword: "required".to_string(),
}],
};
assert!(invalid_result.into_result().is_err());
}
#[test]
fn validation_error_display() {
let error = ValidationError {
message: "is required".to_string(),
path: "/title".to_string(),
keyword: "required".to_string(),
};
assert_eq!(error.to_string(), "/title: is required");
let error_no_path = ValidationError {
message: "invalid type".to_string(),
path: String::new(),
keyword: "type".to_string(),
};
assert_eq!(error_no_path.to_string(), "invalid type");
}
}