Skip to main content

nova_schema/
schema.rs

1//! JSON Schema validation for document metadata.
2//!
3//! Provides validation of document metadata against predefined schemas.
4
5use crate::error::{Error, Result};
6use serde_json::Value;
7use std::path::Path;
8use tracing::debug;
9
10/// A JSON Schema for validating document metadata.
11#[derive(Debug)]
12pub struct Schema {
13    /// Schema name/identifier.
14    name: String,
15
16    /// The JSON Schema definition.
17    schema: Value,
18
19    /// Compiled validator.
20    compiled: jsonschema::JSONSchema,
21}
22
23impl Clone for Schema {
24    fn clone(&self) -> Self {
25        // Re-compile for clone since JSONSchema doesn't implement Clone
26        Self::new(self.name.clone(), self.schema.clone())
27            .expect("Schema was valid before, should be valid now")
28    }
29}
30
31impl Schema {
32    /// Create a new schema from a JSON value.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if the schema is invalid.
37    pub fn new(name: impl Into<String>, schema: Value) -> Result<Self> {
38        let name = name.into();
39        debug!(%name, "Creating schema");
40
41        let compiled =
42            jsonschema::JSONSchema::compile(&schema).map_err(|e| Error::InvalidSchema {
43                message: e.to_string(),
44            })?;
45
46        Ok(Self {
47            name,
48            schema,
49            compiled,
50        })
51    }
52
53    /// Load a schema from a file.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the file cannot be read or parsed.
58    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
59        let path = path.as_ref();
60        let content = std::fs::read_to_string(path)?;
61        let schema: Value = serde_json::from_str(&content)?;
62
63        let name = path
64            .file_stem()
65            .and_then(|s| s.to_str())
66            .unwrap_or("unknown")
67            .to_string();
68
69        Self::new(name, schema)
70    }
71
72    /// Get the schema name.
73    #[must_use]
74    pub fn name(&self) -> &str {
75        &self.name
76    }
77
78    /// Validate a JSON value against this schema.
79    ///
80    /// # Returns
81    ///
82    /// A [`ValidationResult`] containing any errors found.
83    pub fn validate(&self, value: &Value) -> ValidationResult {
84        let mut errors = Vec::new();
85
86        if let Err(validation_errors) = self.compiled.validate(value) {
87            for error in validation_errors {
88                errors.push(ValidationError {
89                    message: error.to_string(),
90                    path: error.instance_path.to_string(),
91                    keyword: format!("{:?}", error.kind),
92                });
93            }
94        }
95
96        ValidationResult { errors }
97    }
98
99    /// Check if a value is valid against this schema.
100    #[must_use]
101    pub fn is_valid(&self, value: &Value) -> bool {
102        self.compiled.is_valid(value)
103    }
104
105    /// Get the raw schema definition.
106    #[must_use]
107    pub fn definition(&self) -> &Value {
108        &self.schema
109    }
110}
111
112/// Result of schema validation.
113#[derive(Debug, Default)]
114pub struct ValidationResult {
115    /// List of validation errors.
116    pub errors: Vec<ValidationError>,
117}
118
119impl ValidationResult {
120    /// Check if validation passed (no errors).
121    #[must_use]
122    pub fn is_valid(&self) -> bool {
123        self.errors.is_empty()
124    }
125
126    /// Get the number of errors.
127    #[must_use]
128    pub fn error_count(&self) -> usize {
129        self.errors.len()
130    }
131
132    /// Convert to a Result, failing if there are errors.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if validation failed.
137    pub fn into_result(self) -> Result<()> {
138        if self.is_valid() {
139            Ok(())
140        } else {
141            let message = self
142                .errors
143                .iter()
144                .map(|e| e.to_string())
145                .collect::<Vec<_>>()
146                .join("; ");
147            Err(Error::ValidationFailed {
148                message,
149                path: self.errors.first().map(|e| e.path.clone()),
150            })
151        }
152    }
153}
154
155/// A single validation error.
156#[derive(Debug, Clone)]
157pub struct ValidationError {
158    /// Human-readable error message.
159    pub message: String,
160
161    /// JSON Pointer path to the invalid field.
162    pub path: String,
163
164    /// JSON Schema keyword that failed.
165    pub keyword: String,
166}
167
168impl std::fmt::Display for ValidationError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        if self.path.is_empty() {
171            write!(f, "{}", self.message)
172        } else {
173            write!(f, "{}: {}", self.path, self.message)
174        }
175    }
176}
177
178/// Create a basic article metadata schema.
179#[must_use]
180pub fn article_schema() -> Value {
181    serde_json::json!({
182        "$schema": "https://json-schema.org/draft/2020-12/schema",
183        "type": "object",
184        "properties": {
185            "title": {
186                "type": "string",
187                "minLength": 1
188            },
189            "authors": {
190                "type": "array",
191                "items": {
192                    "type": "object",
193                    "properties": {
194                        "name": { "type": "string" },
195                        "email": { "type": "string", "format": "email" },
196                        "affiliation": { "type": "string" },
197                        "orcid": { "type": "string", "pattern": "^\\d{4}-\\d{4}-\\d{4}-\\d{3}[\\dX]$" }
198                    },
199                    "required": ["name"]
200                }
201            },
202            "date": { "type": "string" },
203            "abstract": { "type": "string" },
204            "keywords": {
205                "type": "array",
206                "items": { "type": "string" }
207            },
208            "template": { "type": "string" },
209            "citation_style": { "type": "string" },
210            "bibliography": {
211                "type": "array",
212                "items": { "type": "string" }
213            }
214        },
215        "required": ["title"]
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn schema_validate_valid_document() {
225        let schema = Schema::new("test", article_schema()).unwrap();
226
227        let valid_doc = serde_json::json!({
228            "title": "My Document",
229            "authors": [{ "name": "John Doe" }]
230        });
231
232        let result = schema.validate(&valid_doc);
233        assert!(result.is_valid());
234    }
235
236    #[test]
237    fn schema_validate_missing_required() {
238        let schema = Schema::new("test", article_schema()).unwrap();
239
240        let invalid_doc = serde_json::json!({
241            "authors": [{ "name": "John Doe" }]
242        });
243
244        let result = schema.validate(&invalid_doc);
245        assert!(!result.is_valid());
246        assert_eq!(result.error_count(), 1);
247    }
248
249    #[test]
250    fn schema_validate_invalid_type() {
251        let schema = Schema::new("test", article_schema()).unwrap();
252
253        let invalid_doc = serde_json::json!({
254            "title": 123  // Should be string
255        });
256
257        let result = schema.validate(&invalid_doc);
258        assert!(!result.is_valid());
259    }
260
261    #[test]
262    fn schema_is_valid_shortcut() {
263        let schema = Schema::new("test", article_schema()).unwrap();
264
265        let valid = serde_json::json!({ "title": "Test" });
266        let invalid = serde_json::json!({ "title": "" });
267
268        assert!(schema.is_valid(&valid));
269        assert!(!schema.is_valid(&invalid));
270    }
271
272    #[test]
273    fn validation_result_into_result() {
274        let valid_result = ValidationResult::default();
275        assert!(valid_result.into_result().is_ok());
276
277        let invalid_result = ValidationResult {
278            errors: vec![ValidationError {
279                message: "test error".to_string(),
280                path: "/title".to_string(),
281                keyword: "required".to_string(),
282            }],
283        };
284        assert!(invalid_result.into_result().is_err());
285    }
286
287    #[test]
288    fn validation_error_display() {
289        let error = ValidationError {
290            message: "is required".to_string(),
291            path: "/title".to_string(),
292            keyword: "required".to_string(),
293        };
294        assert_eq!(error.to_string(), "/title: is required");
295
296        let error_no_path = ValidationError {
297            message: "invalid type".to_string(),
298            path: String::new(),
299            keyword: "type".to_string(),
300        };
301        assert_eq!(error_no_path.to_string(), "invalid type");
302    }
303}