Skip to main content

mif_schema/
lib.rs

1//! JSON Schema validation for the MIF (Modeled Information Format) ecosystem.
2//!
3//! Validates MIF documents and citation objects against the canonical MIF
4//! JSON Schema (draft 2020-12; see <https://mif-spec.dev/schema/>). Schemas
5//! are vendored at compile time (`src/schemas/`, synced from the canonical
6//! `MIF` repo) and resolved entirely offline — no network access happens at
7//! validation time.
8
9use std::sync::OnceLock;
10
11use jsonschema::{Registry, Validator};
12use mif_problem::{
13    Applicability, CodeAction, ProblemDetails, ProblemMeta, SuggestedFix, ToProblem,
14};
15use serde_json::Value;
16
17const MIF_SCHEMA: &str = include_str!("schemas/mif.schema.json");
18const CITATION_SCHEMA: &str = include_str!("schemas/citation.schema.json");
19const ONTOLOGY_SCHEMA: &str = include_str!("schemas/ontology.schema.json");
20const ENTITY_REFERENCE_SCHEMA: &str =
21    include_str!("schemas/definitions/entity-reference.schema.json");
22const ENTITY_REFERENCE_SCHEMA_ID: &str =
23    "https://mif-spec.dev/schema/definitions/entity-reference.schema.json";
24
25/// The original `serde_json`/`jsonschema` error message behind a
26/// [`MifSchemaError::SchemaCompilation`] failure.
27///
28/// `serde_json::Error` and `jsonschema`'s build/registry errors are
29/// stringified before being cached (see `build_registry`/`build_validator`),
30/// so this wrapper is what `#[source]` actually points at: it preserves the
31/// original error's message so `std::error::Error::source()` on
32/// `SchemaCompilation` yields a real hop in the chain instead of `None`.
33/// This wrapper's own `source()` returns `None` — the original typed error
34/// itself could not be preserved through the cache.
35#[derive(Debug)]
36pub struct SchemaCompilationSource(String);
37
38impl std::fmt::Display for SchemaCompilationSource {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(&self.0)
41    }
42}
43
44impl std::error::Error for SchemaCompilationSource {}
45
46/// Error validating a MIF document or citation against the canonical schema.
47#[derive(Debug, thiserror::Error)]
48pub enum MifSchemaError {
49    /// The vendored schema itself failed to compile. Indicates a bug in
50    /// this crate's vendored schema files, not a problem with the instance
51    /// being validated.
52    #[error("internal error: vendored MIF schema failed to compile: {0}")]
53    SchemaCompilation(#[source] SchemaCompilationSource),
54    /// The instance failed schema validation.
55    #[error("MIF document failed schema validation: {}", .0.join("; "))]
56    Invalid(Vec<String>),
57}
58
59impl MifSchemaError {
60    /// The individual validation error messages, if this is an
61    /// [`MifSchemaError::Invalid`]. Empty for [`MifSchemaError::SchemaCompilation`].
62    #[must_use]
63    pub fn messages(&self) -> &[String] {
64        match self {
65            Self::Invalid(errors) => errors,
66            Self::SchemaCompilation(_) => &[],
67        }
68    }
69
70    const fn meta(&self) -> ProblemMeta {
71        match self {
72            Self::SchemaCompilation(_) => ProblemMeta {
73                slug: "schema-compilation",
74                version: "v1",
75                title: "Internal schema compilation error",
76                status: 500,
77                exit_code: 1,
78            },
79            Self::Invalid(_) => ProblemMeta {
80                slug: "invalid-document",
81                version: "v1",
82                title: "Document failed schema validation",
83                status: 422,
84                exit_code: 2,
85            },
86        }
87    }
88}
89
90impl ToProblem for MifSchemaError {
91    fn to_problem(&self) -> ProblemDetails {
92        let (fix, action) = match self {
93            Self::SchemaCompilation(_) => (
94                SuggestedFix::new(
95                    "This indicates a bug in mif-schema's vendored schema files, not the \
96                     instance being validated. Report it upstream.",
97                    Applicability::Unspecified,
98                ),
99                CodeAction::new(
100                    "File a bug against mif-schema's vendored schemas",
101                    "quickfix",
102                    Applicability::Unspecified,
103                ),
104            ),
105            Self::Invalid(_) => (
106                SuggestedFix::new(
107                    "Correct the document so it conforms to the canonical MIF JSON Schema, \
108                     then retry.",
109                    Applicability::MaybeIncorrect,
110                ),
111                CodeAction::new(
112                    "Fix the reported schema violations",
113                    "quickfix",
114                    Applicability::MaybeIncorrect,
115                ),
116            ),
117        };
118        self.meta()
119            .into_details(env!("CARGO_PKG_NAME"), self.to_string())
120            .with_suggested_fix(fix)
121            .with_code_action(action)
122    }
123}
124
125fn build_registry() -> Result<Registry<'static>, String> {
126    let entity_reference: Value =
127        serde_json::from_str(ENTITY_REFERENCE_SCHEMA).map_err(|e| e.to_string())?;
128    Registry::new()
129        .add(ENTITY_REFERENCE_SCHEMA_ID, entity_reference)
130        .map_err(|e| e.to_string())?
131        .prepare()
132        .map_err(|e| e.to_string())
133}
134
135fn build_validator(schema_json: &str) -> Result<Validator, String> {
136    let schema: Value = serde_json::from_str(schema_json).map_err(|e| e.to_string())?;
137    let registry = build_registry()?;
138    jsonschema::options()
139        .with_registry(&registry)
140        .build(&schema)
141        .map_err(|e| e.to_string())
142}
143
144fn document_validator() -> Result<&'static Validator, MifSchemaError> {
145    static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
146    VALIDATOR
147        .get_or_init(|| build_validator(MIF_SCHEMA))
148        .as_ref()
149        .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
150}
151
152fn citation_validator() -> Result<&'static Validator, MifSchemaError> {
153    static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
154    VALIDATOR
155        .get_or_init(|| build_validator(CITATION_SCHEMA))
156        .as_ref()
157        .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
158}
159
160fn ontology_validator() -> Result<&'static Validator, MifSchemaError> {
161    static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
162    VALIDATOR
163        .get_or_init(|| build_validator(ONTOLOGY_SCHEMA))
164        .as_ref()
165        .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
166}
167
168fn validate(validator: &Validator, instance: &Value) -> Result<(), MifSchemaError> {
169    let errors: Vec<String> = validator
170        .iter_errors(instance)
171        .map(|error| error.to_string())
172        .collect();
173    if errors.is_empty() {
174        Ok(())
175    } else {
176        Err(MifSchemaError::Invalid(errors))
177    }
178}
179
180/// Validates a MIF document (a JSON-LD-projected memory) against the
181/// canonical `mif.schema.json`.
182///
183/// # Errors
184///
185/// Returns [`MifSchemaError::Invalid`] with every validation error message
186/// if `instance` does not conform to the schema, or
187/// [`MifSchemaError::SchemaCompilation`] if the vendored schema itself
188/// fails to compile (indicates a bug in this crate).
189pub fn validate_document(instance: &Value) -> Result<(), MifSchemaError> {
190    validate(document_validator()?, instance)
191}
192
193/// Validates a standalone MIF citation object against `citation.schema.json`.
194///
195/// # Errors
196///
197/// See [`validate_document`].
198pub fn validate_citation(instance: &Value) -> Result<(), MifSchemaError> {
199    validate(citation_validator()?, instance)
200}
201
202/// Validates an ontology definition object against `ontology.schema.json`.
203///
204/// # Errors
205///
206/// See [`validate_document`].
207pub fn validate_ontology_definition(instance: &Value) -> Result<(), MifSchemaError> {
208    validate(ontology_validator()?, instance)
209}
210
211#[cfg(test)]
212mod tests {
213    use mif_problem::ToProblem;
214    use serde_json::json;
215
216    use super::{
217        MifSchemaError, SchemaCompilationSource, validate_citation, validate_document,
218        validate_ontology_definition,
219    };
220
221    fn minimal_valid_document() -> serde_json::Value {
222        json!({
223            "@context": "https://mif-spec.dev/schema/context.jsonld",
224            "@type": "Concept",
225            "@id": "urn:mif:memory:test-001",
226            "conceptType": "semantic",
227            "content": "Test content.",
228            "created": "2026-07-02T00:00:00Z",
229        })
230    }
231
232    #[test]
233    fn valid_document_passes() {
234        assert!(validate_document(&minimal_valid_document()).is_ok());
235    }
236
237    #[test]
238    fn document_missing_required_field_fails() {
239        let mut instance = minimal_valid_document();
240        instance.as_object_mut().unwrap().remove("conceptType");
241        let result = validate_document(&instance);
242        assert!(result.is_err());
243    }
244
245    #[test]
246    fn messages_reports_the_invalid_variant_and_is_empty_for_schema_compilation() {
247        let mut instance = minimal_valid_document();
248        instance.as_object_mut().unwrap().remove("conceptType");
249        let error = validate_document(&instance).unwrap_err();
250        assert!(matches!(error, MifSchemaError::Invalid(_)));
251        assert!(!error.messages().is_empty());
252
253        let compilation_error = MifSchemaError::SchemaCompilation(SchemaCompilationSource(
254            "synthetic failure for coverage".to_string(),
255        ));
256        assert!(compilation_error.messages().is_empty());
257    }
258
259    #[test]
260    fn document_with_bad_id_pattern_fails() {
261        let mut instance = minimal_valid_document();
262        instance["@id"] = json!("not-a-urn");
263        assert!(validate_document(&instance).is_err());
264    }
265
266    #[test]
267    fn document_with_entity_reference_resolves_ref_chain() {
268        let mut instance = minimal_valid_document();
269        instance["entities"] = json!([{
270            "@type": "EntityReference",
271            "entity": { "@id": "urn:mif:entity:person:jane-smith" },
272            "entityType": "Person",
273        }]);
274        assert!(validate_document(&instance).is_ok());
275    }
276
277    #[test]
278    fn document_with_invalid_entity_reference_fails() {
279        let mut instance = minimal_valid_document();
280        instance["entities"] = json!([{
281            "@type": "EntityReference",
282            "entity": { "@id": "not-a-urn" },
283        }]);
284        assert!(validate_document(&instance).is_err());
285    }
286
287    #[test]
288    fn valid_citation_passes() {
289        let citation = json!({
290            "@type": "Citation",
291            "citationType": "documentation",
292            "citationRole": "source",
293            "title": "MIF Specification",
294            "url": "https://mif-spec.dev",
295        });
296        assert!(validate_citation(&citation).is_ok());
297    }
298
299    #[test]
300    fn valid_ontology_definition_passes() {
301        let ontology = json!({
302            "ontology": {
303                "id": "mif-base",
304                "version": "1.0.0",
305            }
306        });
307        assert!(validate_ontology_definition(&ontology).is_ok());
308    }
309
310    #[test]
311    fn ontology_definition_with_bad_id_pattern_fails() {
312        let ontology = json!({
313            "ontology": {
314                "id": "Not_Valid",
315                "version": "1.0.0",
316            }
317        });
318        assert!(validate_ontology_definition(&ontology).is_err());
319    }
320
321    #[test]
322    fn entity_type_with_classification_fields_passes() {
323        // The v1.1 additive fields: aliases, exemplars, negative_examples.
324        let ontology = json!({
325            "ontology": {
326                "id": "sec-fixture",
327                "version": "1.1.0",
328            },
329            "entity_types": [{
330                "name": "control",
331                "base": "semantic",
332                "aliases": ["safeguard", "countermeasure"],
333                "exemplars": ["Enforce MFA for all administrative access"],
334                "negative_examples": ["An incident report describing a control failure"],
335            }]
336        });
337        assert!(validate_ontology_definition(&ontology).is_ok());
338    }
339
340    #[test]
341    fn entity_type_classification_fields_reject_non_string_and_empty_items() {
342        let non_string_alias = json!({
343            "ontology": { "id": "sec-fixture", "version": "1.1.0" },
344            "entity_types": [{
345                "name": "control",
346                "base": "semantic",
347                "aliases": [123],
348            }]
349        });
350        assert!(validate_ontology_definition(&non_string_alias).is_err());
351
352        let empty_exemplar = json!({
353            "ontology": { "id": "sec-fixture", "version": "1.1.0" },
354            "entity_types": [{
355                "name": "control",
356                "base": "semantic",
357                "exemplars": [""],
358            }]
359        });
360        assert!(validate_ontology_definition(&empty_exemplar).is_err());
361    }
362
363    #[test]
364    fn citation_missing_required_field_fails() {
365        let citation = json!({
366            "@type": "Citation",
367            "citationType": "documentation",
368            "citationRole": "source",
369            "title": "MIF Specification",
370        });
371        assert!(validate_citation(&citation).is_err());
372    }
373
374    #[test]
375    fn invalid_document_maps_to_versioned_problem_details() {
376        let mut instance = minimal_valid_document();
377        instance.as_object_mut().unwrap().remove("conceptType");
378        let error = validate_document(&instance).unwrap_err();
379        let problem = error.to_problem();
380
381        assert_eq!(
382            problem.problem_type,
383            "https://modeled-information-format.github.io/mif-rs/references/errors/invalid-document/v1"
384        );
385        assert_eq!(problem.status, 422);
386        assert_eq!(problem.exit_code, Some(2));
387        assert!(problem.suggested_fix.is_some());
388        assert_eq!(problem.code_actions.len(), 1);
389        assert!(problem.detail.contains("schema validation"));
390    }
391
392    #[test]
393    fn schema_compilation_error_maps_to_distinct_problem_type() {
394        let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
395        let problem = error.to_problem();
396
397        assert_eq!(
398            problem.problem_type,
399            "https://modeled-information-format.github.io/mif-rs/references/errors/schema-compilation/v1"
400        );
401        assert_eq!(problem.status, 500);
402        assert_eq!(problem.exit_code, Some(1));
403    }
404
405    #[test]
406    fn schema_compilation_error_source_preserves_the_underlying_cause() {
407        let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
408
409        let source = std::error::Error::source(&error).expect("source should not be None");
410        assert_eq!(source.to_string(), "boom");
411    }
412}