ts_json/
validate.rs

1//! Validate a JSON file against a schema.
2
3use std::path::Path;
4
5use jsonschema::ValidationOptions;
6use serde_json::Value;
7use ts_error::{
8    diagnostic::{Context, Diagnostic, Diagnostics},
9    normalize_message,
10};
11
12use crate::{
13    location::LocationExtensions,
14    parser::{Node, Value as SpannedValue},
15    problem_message::ProblemMessage,
16};
17
18/// Error variants for validating JSON.
19#[derive(Debug)]
20#[non_exhaustive]
21#[allow(missing_docs)]
22pub enum ValidationError {
23    #[non_exhaustive]
24    ParseSource { source: serde_json::Error },
25
26    #[non_exhaustive]
27    ParseSchema { source: serde_json::Error },
28
29    #[non_exhaustive]
30    CreateValidator {
31        source: Box<jsonschema::ValidationError<'static>>,
32    },
33}
34impl core::fmt::Display for ValidationError {
35    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
36        match &self {
37            Self::ParseSource { .. } => write!(f, "source file is not valid JSON"),
38            Self::ParseSchema { .. } => write!(f, "schema is not valid JSON"),
39            Self::CreateValidator { .. } => write!(f, "could not create validator from schema"),
40        }
41    }
42}
43impl core::error::Error for ValidationError {
44    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
45        match &self {
46            Self::ParseSource { source, .. } | Self::ParseSchema { source, .. } => Some(source),
47            Self::CreateValidator { source, .. } => Some(source),
48        }
49    }
50}
51
52/// Validate some JSON against a JSON schema, returning all problems.
53pub fn validate_json(
54    source: &str,
55    schema: &str,
56    source_path: Option<&Path>,
57) -> Result<Diagnostics, ValidationError> {
58    let source_node: Value =
59        serde_json::from_str(source).map_err(|source| ValidationError::ParseSource { source })?;
60    let schema_node: Value =
61        serde_json::from_str(schema).map_err(|source| ValidationError::ParseSchema { source })?;
62
63    let validator = ValidationOptions::default()
64        .build(&schema_node)
65        .map_err(|source| ValidationError::CreateValidator {
66            source: Box::new(source),
67        })?;
68
69    let mut diagnostics = Diagnostics::new("validating JSON");
70
71    if !validator.is_valid(&source_node) {
72        let document = Node::parse_document(source);
73        for error in validator.iter_errors(&source_node) {
74            let context = document.as_ref().and_then(|document| {
75                let span = document
76                    .evaluate(&error.instance_path)
77                    .map(|node| match node.value {
78                        SpannedValue::Array(_) | SpannedValue::Object(_) => {
79                            if let Some(tag) = &node.tag {
80                                tag.span
81                            } else {
82                                node.value.span()
83                            }
84                        }
85                        _ => node.value.span(),
86                    });
87
88                span.map(|span| {
89                    let mut context = Context::new(source, span);
90                    context.label = error.kind.message();
91                    context
92                })
93            });
94
95            let mut diagnostic = Diagnostic::error(format!(
96                "`{}` {}",
97                error.instance_path,
98                error.kind.headline()
99            ));
100
101            diagnostic.context = context;
102            diagnostic.file_path = source_path.map(|path| path.display().to_string());
103
104            if let Some(parent) = error.schema_path.parent()
105                && let Some(node) = schema_node.pointer(parent.join("description").as_str())
106                && let Some(contents) = node.as_str()
107            {
108                for line in contents.lines() {
109                    diagnostic.notes.push(normalize_message(line));
110                }
111            }
112
113            diagnostics.push(diagnostic);
114        }
115    }
116
117    Ok(diagnostics)
118}
119
120#[cfg(test)]
121mod test {
122    use std::path::Path;
123
124    const SOURCE: &str = include_str!("../tests/sample.json");
125    const SCHEMA: &str = include_str!("../tests/sample.schema.json");
126
127    #[test]
128    fn validates_sample_correctly() {
129        let diagnostics = crate::validate_json(
130            SOURCE,
131            SCHEMA,
132            Some(Path::new("crates/ts-json/tests/sample.json")),
133        )
134        .expect("validation to succeed");
135        assert!(!diagnostics.is_empty());
136        assert_eq!(5, diagnostics.errors().count());
137        eprintln!("{diagnostics}");
138    }
139}