Skip to main content

oxirs_samm/codegen/
json_schema.rs

1//! SAMM to JSON Schema Generator
2//!
3//! Generates [JSON Schema](https://json-schema.org/) (draft-07 / 2020-12 compatible)
4//! documents from SAMM Aspect Model definitions.  The generated schemas can be
5//! used for API payload validation, documentation, and tooling integration.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use oxirs_samm::parser::parse_aspect_model;
11//! use oxirs_samm::codegen::JsonSchemaGenerator;
12//!
13//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
14//! let aspect = parse_aspect_model("Movement.ttl").await?;
15//! let generator = JsonSchemaGenerator::new()
16//!     .with_descriptions()
17//!     .with_examples();
18//! let schema = generator.generate(&aspect)?;
19//! println!("{}", serde_json::to_string_pretty(&schema)?);
20//! # Ok(())
21//! # }
22//! ```
23
24use serde_json::{json, Map, Value};
25
26use crate::error::{Result, SammError};
27use crate::metamodel::{Aspect, Characteristic, CharacteristicKind, ModelElement, Property};
28
29// ------------------------------------------------------------------ //
30//  Configuration                                                       //
31// ------------------------------------------------------------------ //
32
33/// Configuration for [`JsonSchemaGenerator`].
34#[derive(Debug, Clone)]
35pub struct JsonSchemaOptions {
36    /// Emit `description` fields when available.
37    pub include_descriptions: bool,
38    /// Emit `examples` arrays when example values are present.
39    pub include_examples: bool,
40    /// Prefer the `$defs` keyword (2020-12) over `definitions` (draft-07).
41    pub use_defs_keyword: bool,
42    /// Language tag used for selecting descriptions and titles.
43    pub language: String,
44}
45
46impl Default for JsonSchemaOptions {
47    fn default() -> Self {
48        Self {
49            include_descriptions: true,
50            include_examples: true,
51            use_defs_keyword: true,
52            language: "en".to_string(),
53        }
54    }
55}
56
57// ------------------------------------------------------------------ //
58//  Generator                                                           //
59// ------------------------------------------------------------------ //
60
61/// Generates JSON Schema documents from SAMM Aspect Models.
62///
63/// Use the builder methods to customise the output, then call
64/// [`generate`](JsonSchemaGenerator::generate) with an [`Aspect`].
65#[derive(Debug, Default, Clone)]
66pub struct JsonSchemaGenerator {
67    options: JsonSchemaOptions,
68}
69
70impl JsonSchemaGenerator {
71    /// Create a generator with default options.
72    pub fn new() -> Self {
73        Self {
74            options: JsonSchemaOptions::default(),
75        }
76    }
77
78    /// Enable `description` fields in the output.
79    pub fn with_descriptions(mut self) -> Self {
80        self.options.include_descriptions = true;
81        self
82    }
83
84    /// Enable `examples` arrays in the output.
85    pub fn with_examples(mut self) -> Self {
86        self.options.include_examples = true;
87        self
88    }
89
90    /// Disable `description` fields.
91    pub fn without_descriptions(mut self) -> Self {
92        self.options.include_descriptions = false;
93        self
94    }
95
96    /// Disable `examples` arrays.
97    pub fn without_examples(mut self) -> Self {
98        self.options.include_examples = false;
99        self
100    }
101
102    /// Choose the language for `title` and `description` lookup.
103    pub fn with_language(mut self, lang: impl Into<String>) -> Self {
104        self.options.language = lang.into();
105        self
106    }
107
108    // ---------------------------------------------------------------- //
109    //  Public API                                                        //
110    // ---------------------------------------------------------------- //
111
112    /// Generate a JSON Schema `Value` for the given `aspect`.
113    ///
114    /// The returned value represents the root schema object and can be
115    /// serialised with `serde_json::to_string_pretty`.
116    pub fn generate(&self, aspect: &Aspect) -> Result<Value> {
117        let mut root = Map::new();
118
119        // JSON Schema meta-schema identifier
120        let schema_keyword = if self.options.use_defs_keyword {
121            "https://json-schema.org/draft/2020-12/schema"
122        } else {
123            "http://json-schema.org/draft-07/schema#"
124        };
125        root.insert(
126            "$schema".to_string(),
127            Value::String(schema_keyword.to_string()),
128        );
129        root.insert("$id".to_string(), Value::String(aspect.urn().to_string()));
130
131        // Title
132        let aspect_name = aspect.name();
133        let title = aspect
134            .metadata()
135            .get_preferred_name(&self.options.language)
136            .map(|s| s.to_string())
137            .unwrap_or_else(|| aspect_name.clone());
138        root.insert("title".to_string(), Value::String(title));
139
140        // Description
141        if self.options.include_descriptions {
142            if let Some(desc) = aspect.metadata().get_description(&self.options.language) {
143                root.insert("description".to_string(), Value::String(desc.to_string()));
144            }
145        }
146
147        root.insert("type".to_string(), Value::String("object".to_string()));
148
149        // Properties and required list
150        let (properties_map, required) = self.build_properties(aspect.properties())?;
151        root.insert("properties".to_string(), Value::Object(properties_map));
152        if !required.is_empty() {
153            root.insert(
154                "required".to_string(),
155                Value::Array(required.into_iter().map(Value::String).collect()),
156            );
157        }
158
159        // No additional properties by default
160        root.insert("additionalProperties".to_string(), Value::Bool(false));
161
162        Ok(Value::Object(root))
163    }
164
165    // ---------------------------------------------------------------- //
166    //  Internal helpers                                                  //
167    // ---------------------------------------------------------------- //
168
169    /// Build a `properties` object from a slice of SAMM properties.
170    ///
171    /// Returns `(properties_map, required_names)`.
172    fn build_properties(&self, props: &[Property]) -> Result<(Map<String, Value>, Vec<String>)> {
173        let mut properties_map = Map::new();
174        let mut required = Vec::new();
175
176        for prop in props {
177            let name = prop_json_name(prop);
178            let prop_schema = self.property_to_schema(prop)?;
179            properties_map.insert(name.clone(), prop_schema);
180
181            if !prop.optional {
182                required.push(name);
183            }
184        }
185
186        Ok((properties_map, required))
187    }
188
189    /// Build a JSON Schema fragment for a single SAMM property.
190    fn property_to_schema(&self, prop: &Property) -> Result<Value> {
191        let mut schema = Map::new();
192
193        // Title from preferred name
194        if let Some(name) = prop.metadata().get_preferred_name(&self.options.language) {
195            schema.insert("title".to_string(), Value::String(name.to_string()));
196        }
197
198        // Description
199        if self.options.include_descriptions {
200            if let Some(desc) = prop.metadata().get_description(&self.options.language) {
201                schema.insert("description".to_string(), Value::String(desc.to_string()));
202            }
203        }
204
205        // Type info from characteristic
206        if let Some(char) = &prop.characteristic {
207            let type_schema = self.characteristic_to_schema(char)?;
208            // Merge type schema into our property schema
209            if let Value::Object(type_map) = type_schema {
210                for (k, v) in type_map {
211                    schema.insert(k, v);
212                }
213            }
214        } else {
215            // No characteristic – accept any value
216            schema.insert("type".to_string(), Value::String("string".to_string()));
217        }
218
219        // Examples
220        if self.options.include_examples && !prop.example_values.is_empty() {
221            let examples: Vec<Value> = prop
222                .example_values
223                .iter()
224                .map(|v| Value::String(v.clone()))
225                .collect();
226            schema.insert("examples".to_string(), Value::Array(examples));
227        }
228
229        Ok(Value::Object(schema))
230    }
231
232    /// Convert a SAMM [`Characteristic`] to a JSON Schema type fragment.
233    fn characteristic_to_schema(&self, char: &Characteristic) -> Result<Value> {
234        let schema = match char.kind() {
235            CharacteristicKind::Trait => {
236                // Use data type if available
237                let json_type = char
238                    .data_type
239                    .as_deref()
240                    .map(|dt| self.data_type_to_json_type(dt))
241                    .unwrap_or("string");
242                json!({ "type": json_type })
243            }
244
245            CharacteristicKind::Quantifiable { unit }
246            | CharacteristicKind::Measurement { unit } => {
247                let json_type = char
248                    .data_type
249                    .as_deref()
250                    .map(|dt| self.data_type_to_json_type(dt))
251                    .unwrap_or("number");
252                json!({
253                    "type": json_type,
254                    "description": format!("Value in {}", unit)
255                })
256            }
257
258            CharacteristicKind::Duration { unit } => {
259                json!({
260                    "type": "number",
261                    "description": format!("Duration value in {}", unit)
262                })
263            }
264
265            CharacteristicKind::Enumeration { values } => {
266                json!({ "enum": values })
267            }
268
269            CharacteristicKind::State {
270                values,
271                default_value,
272            } => {
273                let mut s = json!({ "enum": values });
274                if let (Some(map), Some(default)) = (s.as_object_mut(), default_value.as_deref()) {
275                    map.insert("default".to_string(), Value::String(default.to_string()));
276                }
277                s
278            }
279
280            CharacteristicKind::Collection {
281                element_characteristic,
282            }
283            | CharacteristicKind::List {
284                element_characteristic,
285            }
286            | CharacteristicKind::TimeSeries {
287                element_characteristic,
288            } => {
289                let items = if let Some(inner) = element_characteristic {
290                    self.characteristic_to_schema(inner)?
291                } else {
292                    json!({})
293                };
294                json!({ "type": "array", "items": items })
295            }
296
297            CharacteristicKind::Set {
298                element_characteristic,
299            } => {
300                let items = if let Some(inner) = element_characteristic {
301                    self.characteristic_to_schema(inner)?
302                } else {
303                    json!({})
304                };
305                json!({ "type": "array", "items": items, "uniqueItems": true })
306            }
307
308            CharacteristicKind::SortedSet {
309                element_characteristic,
310            } => {
311                let items = if let Some(inner) = element_characteristic {
312                    self.characteristic_to_schema(inner)?
313                } else {
314                    json!({})
315                };
316                json!({ "type": "array", "items": items, "uniqueItems": true })
317            }
318
319            CharacteristicKind::Code => {
320                json!({ "type": "string" })
321            }
322
323            CharacteristicKind::Either { left, right } => {
324                let left_schema = self.characteristic_to_schema(left)?;
325                let right_schema = self.characteristic_to_schema(right)?;
326                json!({ "oneOf": [left_schema, right_schema] })
327            }
328
329            CharacteristicKind::SingleEntity { entity_type } => {
330                // Reference to an entity definition – produce a $ref
331                let ref_name = entity_type
332                    .split('#')
333                    .next_back()
334                    .unwrap_or(entity_type.as_str());
335                let defs_key = if self.options.use_defs_keyword {
336                    "$defs"
337                } else {
338                    "definitions"
339                };
340                json!({ "$ref": format!("#{}/{}", defs_key, ref_name) })
341            }
342
343            CharacteristicKind::StructuredValue {
344                deconstruction_rule: _,
345                elements: _,
346            } => {
347                // Represented as a string with a custom format hint
348                json!({ "type": "string", "format": "structured-value" })
349            }
350        };
351
352        // Apply constraint keywords on top
353        let schema = self.apply_constraints(char, schema)?;
354
355        Ok(schema)
356    }
357
358    /// Apply SAMM constraints as JSON Schema keywords.
359    fn apply_constraints(&self, char: &Characteristic, mut schema: Value) -> Result<Value> {
360        use crate::metamodel::Constraint;
361
362        for constraint in &char.constraints {
363            if let Some(obj) = schema.as_object_mut() {
364                match constraint {
365                    Constraint::RangeConstraint {
366                        min_value,
367                        max_value,
368                        ..
369                    } => {
370                        if let Some(min) = min_value {
371                            if let Ok(n) = min.parse::<f64>() {
372                                obj.insert("minimum".to_string(), json!(n));
373                            }
374                        }
375                        if let Some(max) = max_value {
376                            if let Ok(n) = max.parse::<f64>() {
377                                obj.insert("maximum".to_string(), json!(n));
378                            }
379                        }
380                    }
381                    Constraint::LengthConstraint {
382                        min_value,
383                        max_value,
384                    } => {
385                        if let Some(min) = min_value {
386                            obj.insert("minLength".to_string(), json!(min));
387                        }
388                        if let Some(max) = max_value {
389                            obj.insert("maxLength".to_string(), json!(max));
390                        }
391                    }
392                    Constraint::RegularExpressionConstraint { pattern } => {
393                        obj.insert("pattern".to_string(), Value::String(pattern.clone()));
394                    }
395                    Constraint::LanguageConstraint { .. } | Constraint::LocaleConstraint { .. } => {
396                        // Represented as metadata; no direct JSON Schema equivalent
397                    }
398                    Constraint::EncodingConstraint { encoding } => {
399                        obj.insert(
400                            "contentEncoding".to_string(),
401                            Value::String(encoding.clone()),
402                        );
403                    }
404                    Constraint::FixedPointConstraint { integer, scale } => {
405                        // Represented as multipleOf: 1 / 10^scale (best effort)
406                        let _ = (integer, scale); // used for documentation only
407                    }
408                }
409            }
410        }
411        Ok(schema)
412    }
413
414    /// Map a SAMM / XSD data type string to a JSON Schema type keyword.
415    pub fn data_type_to_json_type(&self, data_type: &str) -> &'static str {
416        if data_type.ends_with("boolean") {
417            return "boolean";
418        }
419        if data_type.ends_with("int")
420            || data_type.ends_with("integer")
421            || data_type.ends_with("long")
422            || data_type.ends_with("short")
423            || data_type.ends_with("byte")
424            || data_type.ends_with("unsignedInt")
425            || data_type.ends_with("unsignedLong")
426            || data_type.ends_with("unsignedShort")
427            || data_type.ends_with("unsignedByte")
428            || data_type.ends_with("positiveInteger")
429            || data_type.ends_with("negativeInteger")
430            || data_type.ends_with("nonNegativeInteger")
431            || data_type.ends_with("nonPositiveInteger")
432        {
433            return "integer";
434        }
435        if data_type.ends_with("decimal")
436            || data_type.ends_with("float")
437            || data_type.ends_with("double")
438        {
439            return "number";
440        }
441        "string"
442    }
443}
444
445// ------------------------------------------------------------------ //
446//  Utility                                                             //
447// ------------------------------------------------------------------ //
448
449/// Return the JSON field name for a property (payload name if set, otherwise
450/// the local name from the URN).
451fn prop_json_name(prop: &Property) -> String {
452    prop.payload_name.clone().unwrap_or_else(|| prop.name())
453}
454
455// ------------------------------------------------------------------ //
456//  JsonSchemaValidator                                                 //
457// ------------------------------------------------------------------ //
458
459/// A single JSON Schema validation error.
460#[derive(Debug, Clone, PartialEq)]
461pub struct ValidationError {
462    /// JSON Pointer path to the failing instance location (e.g. `"/name"` or `""` for root).
463    pub path: String,
464    /// Human-readable error message.
465    pub message: String,
466    /// JSON Pointer path into the schema where the violation was detected (e.g. `"/type"`).
467    pub schema_path: String,
468}
469
470impl ValidationError {
471    /// Create a new `ValidationError`.
472    pub fn new(
473        path: impl Into<String>,
474        message: impl Into<String>,
475        schema_path: impl Into<String>,
476    ) -> Self {
477        Self {
478            path: path.into(),
479            message: message.into(),
480            schema_path: schema_path.into(),
481        }
482    }
483}
484
485/// Validates a JSON instance against a JSON Schema value.
486///
487/// Supports a practical subset of JSON Schema keywords:
488///
489/// - `type` — string/number/integer/boolean/array/object/null type checking
490/// - `required` — checks that all listed property names are present
491/// - `enum` — validates value membership
492/// - `minimum` / `maximum` — numeric range constraints
493/// - `minLength` / `maxLength` — string length constraints
494/// - `additionalProperties: false` — rejects unknown object properties
495/// - `properties` — recursive validation of nested object properties
496///
497/// # Example
498///
499/// ```rust
500/// use oxirs_samm::codegen::{JsonSchemaValidator, ValidationError};
501/// use serde_json::json;
502///
503/// let validator = JsonSchemaValidator::new();
504/// let schema = json!({ "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } });
505/// let instance = json!({ "name": 42 });
506/// let errors = validator.validate(&schema, &instance);
507/// assert!(!errors.is_empty());
508/// ```
509#[derive(Debug, Default, Clone)]
510pub struct JsonSchemaValidator;
511
512impl JsonSchemaValidator {
513    /// Create a new validator instance.
514    pub fn new() -> Self {
515        Self
516    }
517
518    /// Validate `instance` against `schema`, returning all validation errors.
519    ///
520    /// An empty `Vec` means the instance is valid.
521    pub fn validate(
522        &self,
523        schema: &serde_json::Value,
524        instance: &serde_json::Value,
525    ) -> Vec<ValidationError> {
526        self.validate_with_path(schema, instance, "", "")
527    }
528
529    /// Internal recursive validation with path tracking.
530    fn validate_with_path(
531        &self,
532        schema: &serde_json::Value,
533        instance: &serde_json::Value,
534        path: &str,
535        schema_path: &str,
536    ) -> Vec<ValidationError> {
537        let mut errors = Vec::new();
538
539        // ── type ──────────────────────────────────────────────────────────
540        if let Some(type_value) = schema.get("type") {
541            if let Some(type_str) = type_value.as_str() {
542                let type_ok = match type_str {
543                    "string" => instance.is_string(),
544                    "number" => instance.is_number(),
545                    "integer" => instance.as_f64().map(|n| n.fract() == 0.0).unwrap_or(false),
546                    "boolean" => instance.is_boolean(),
547                    "array" => instance.is_array(),
548                    "object" => instance.is_object(),
549                    "null" => instance.is_null(),
550                    _ => true, // unknown types pass through
551                };
552                if !type_ok {
553                    errors.push(ValidationError::new(
554                        path,
555                        format!(
556                            "expected type '{}' but got '{}'",
557                            type_str,
558                            json_type_name(instance)
559                        ),
560                        format!("{}/type", schema_path),
561                    ));
562                }
563            }
564        }
565
566        // ── required ──────────────────────────────────────────────────────
567        if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
568            if let Some(obj) = instance.as_object() {
569                for req in required {
570                    if let Some(field) = req.as_str() {
571                        if !obj.contains_key(field) {
572                            errors.push(ValidationError::new(
573                                path,
574                                format!("required property '{}' is missing", field),
575                                format!("{}/required", schema_path),
576                            ));
577                        }
578                    }
579                }
580            }
581        }
582
583        // ── enum ──────────────────────────────────────────────────────────
584        if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array()) {
585            if !enum_values.iter().any(|e| e == instance) {
586                errors.push(ValidationError::new(
587                    path,
588                    "value is not one of the allowed enum values".to_string(),
589                    format!("{}/enum", schema_path),
590                ));
591            }
592        }
593
594        // ── minimum / maximum ─────────────────────────────────────────────
595        if let Some(instance_num) = instance.as_f64() {
596            if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
597                if instance_num < min {
598                    errors.push(ValidationError::new(
599                        path,
600                        format!("value {} is less than minimum {}", instance_num, min),
601                        format!("{}/minimum", schema_path),
602                    ));
603                }
604            }
605            if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
606                if instance_num > max {
607                    errors.push(ValidationError::new(
608                        path,
609                        format!("value {} is greater than maximum {}", instance_num, max),
610                        format!("{}/maximum", schema_path),
611                    ));
612                }
613            }
614        }
615
616        // ── minLength / maxLength ─────────────────────────────────────────
617        if let Some(s) = instance.as_str() {
618            let char_count = s.chars().count();
619            if let Some(min_len) = schema.get("minLength").and_then(|v| v.as_u64()) {
620                if (char_count as u64) < min_len {
621                    errors.push(ValidationError::new(
622                        path,
623                        format!(
624                            "string length {} is less than minLength {}",
625                            char_count, min_len
626                        ),
627                        format!("{}/minLength", schema_path),
628                    ));
629                }
630            }
631            if let Some(max_len) = schema.get("maxLength").and_then(|v| v.as_u64()) {
632                if (char_count as u64) > max_len {
633                    errors.push(ValidationError::new(
634                        path,
635                        format!("string length {} exceeds maxLength {}", char_count, max_len),
636                        format!("{}/maxLength", schema_path),
637                    ));
638                }
639            }
640        }
641
642        // ── additionalProperties: false ───────────────────────────────────
643        if let Some(add_props) = schema.get("additionalProperties") {
644            if add_props == &serde_json::Value::Bool(false) {
645                if let Some(obj) = instance.as_object() {
646                    let allowed: std::collections::HashSet<&str> = schema
647                        .get("properties")
648                        .and_then(|p| p.as_object())
649                        .map(|p| p.keys().map(|k| k.as_str()).collect())
650                        .unwrap_or_default();
651
652                    for key in obj.keys() {
653                        if !allowed.contains(key.as_str()) {
654                            let err_path = if path.is_empty() {
655                                key.clone()
656                            } else {
657                                format!("{}/{}", path, key)
658                            };
659                            errors.push(ValidationError::new(
660                                err_path,
661                                format!("additional property '{}' is not allowed", key),
662                                format!("{}/additionalProperties", schema_path),
663                            ));
664                        }
665                    }
666                }
667            }
668        }
669
670        // ── properties (recursive) ────────────────────────────────────────
671        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
672            if let Some(instance_obj) = instance.as_object() {
673                for (prop_key, prop_schema) in properties {
674                    if let Some(prop_value) = instance_obj.get(prop_key) {
675                        let child_path = if path.is_empty() {
676                            format!("/{}", prop_key)
677                        } else {
678                            format!("{}/{}", path, prop_key)
679                        };
680                        let child_schema_path = format!("{}/properties/{}", schema_path, prop_key);
681                        let child_errors = self.validate_with_path(
682                            prop_schema,
683                            prop_value,
684                            &child_path,
685                            &child_schema_path,
686                        );
687                        errors.extend(child_errors);
688                    }
689                }
690            }
691        }
692
693        errors
694    }
695}
696
697/// Return a human-readable type name for a JSON value.
698fn json_type_name(v: &serde_json::Value) -> &'static str {
699    match v {
700        serde_json::Value::Null => "null",
701        serde_json::Value::Bool(_) => "boolean",
702        serde_json::Value::Number(_) => "number",
703        serde_json::Value::String(_) => "string",
704        serde_json::Value::Array(_) => "array",
705        serde_json::Value::Object(_) => "object",
706    }
707}
708
709// ------------------------------------------------------------------ //
710//  Tests                                                               //
711// ------------------------------------------------------------------ //
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::metamodel::{Aspect, Characteristic, CharacteristicKind, Property};
717
718    fn speed_aspect() -> Aspect {
719        let mut aspect = Aspect::new("urn:samm:org.example:1.0.0#Movement".to_string());
720        aspect
721            .metadata
722            .add_preferred_name("en".to_string(), "Movement".to_string());
723        aspect
724            .metadata
725            .add_description("en".to_string(), "Describes movement data".to_string());
726
727        let char = Characteristic::new(
728            "urn:samm:org.example:1.0.0#SpeedChar".to_string(),
729            CharacteristicKind::Measurement {
730                unit: "unit:kilometrePerHour".to_string(),
731            },
732        )
733        .with_data_type("http://www.w3.org/2001/XMLSchema#float".to_string());
734
735        let prop =
736            Property::new("urn:samm:org.example:1.0.0#speed".to_string()).with_characteristic(char);
737
738        aspect.add_property(prop);
739        aspect
740    }
741
742    #[test]
743    fn test_generate_basic_schema() {
744        let aspect = speed_aspect();
745        let gen = JsonSchemaGenerator::new();
746        let schema = gen.generate(&aspect).expect("generation should succeed");
747
748        assert_eq!(
749            schema["$schema"],
750            "https://json-schema.org/draft/2020-12/schema"
751        );
752        assert_eq!(schema["type"], "object");
753        assert!(schema["properties"]["speed"].is_object());
754    }
755
756    #[test]
757    fn test_schema_has_description() {
758        let aspect = speed_aspect();
759        let gen = JsonSchemaGenerator::new().with_descriptions();
760        let schema = gen.generate(&aspect).expect("generation should succeed");
761        assert_eq!(schema["description"], "Describes movement data");
762    }
763
764    #[test]
765    fn test_schema_no_description() {
766        let aspect = speed_aspect();
767        let gen = JsonSchemaGenerator::new().without_descriptions();
768        let schema = gen.generate(&aspect).expect("generation should succeed");
769        assert!(schema.get("description").is_none());
770    }
771
772    #[test]
773    fn test_required_non_optional_properties() {
774        let aspect = speed_aspect();
775        let gen = JsonSchemaGenerator::new();
776        let schema = gen.generate(&aspect).expect("generation should succeed");
777        let required = schema["required"]
778            .as_array()
779            .expect("required should be array");
780        assert!(required.iter().any(|v| v == "speed"));
781    }
782
783    #[test]
784    fn test_optional_not_in_required() {
785        let mut aspect = Aspect::new("urn:samm:org.example:1.0.0#TestAspect".to_string());
786        let char = Characteristic::new(
787            "urn:samm:org.example:1.0.0#Char".to_string(),
788            CharacteristicKind::Trait,
789        )
790        .with_data_type("http://www.w3.org/2001/XMLSchema#string".to_string());
791        let prop = Property::new("urn:samm:org.example:1.0.0#optProp".to_string())
792            .with_characteristic(char)
793            .as_optional();
794        aspect.add_property(prop);
795
796        let gen = JsonSchemaGenerator::new();
797        let schema = gen.generate(&aspect).expect("generation should succeed");
798        // required array may be absent or not contain optProp
799        if let Some(arr) = schema.get("required").and_then(|v| v.as_array()) {
800            assert!(!arr.iter().any(|v| v == "optProp"));
801        }
802    }
803
804    #[test]
805    fn test_enumeration_generates_enum_keyword() {
806        let mut aspect = Aspect::new("urn:samm:org.example:1.0.0#TestAspect".to_string());
807        let char = Characteristic::new(
808            "urn:samm:org.example:1.0.0#StatusEnum".to_string(),
809            CharacteristicKind::Enumeration {
810                values: vec!["Active".to_string(), "Inactive".to_string()],
811            },
812        );
813        let prop = Property::new("urn:samm:org.example:1.0.0#status".to_string())
814            .with_characteristic(char);
815        aspect.add_property(prop);
816
817        let gen = JsonSchemaGenerator::new();
818        let schema = gen.generate(&aspect).expect("generation should succeed");
819        let status_prop = &schema["properties"]["status"];
820        assert!(status_prop["enum"].is_array());
821        let vals = status_prop["enum"].as_array().expect("enum is array");
822        assert_eq!(vals.len(), 2);
823    }
824
825    #[test]
826    fn test_collection_generates_array_type() {
827        let mut aspect = Aspect::new("urn:samm:org.example:1.0.0#TestAspect".to_string());
828        let inner = Characteristic::new(
829            "urn:samm:org.example:1.0.0#Inner".to_string(),
830            CharacteristicKind::Trait,
831        )
832        .with_data_type("http://www.w3.org/2001/XMLSchema#string".to_string());
833        let char = Characteristic::new(
834            "urn:samm:org.example:1.0.0#Names".to_string(),
835            CharacteristicKind::List {
836                element_characteristic: Some(Box::new(inner)),
837            },
838        );
839        let prop =
840            Property::new("urn:samm:org.example:1.0.0#names".to_string()).with_characteristic(char);
841        aspect.add_property(prop);
842
843        let gen = JsonSchemaGenerator::new();
844        let schema = gen.generate(&aspect).expect("generation should succeed");
845        assert_eq!(schema["properties"]["names"]["type"], "array");
846    }
847
848    #[test]
849    fn test_data_type_to_json_type_mapping() {
850        let gen = JsonSchemaGenerator::new();
851        assert_eq!(
852            gen.data_type_to_json_type("http://www.w3.org/2001/XMLSchema#boolean"),
853            "boolean"
854        );
855        assert_eq!(
856            gen.data_type_to_json_type("http://www.w3.org/2001/XMLSchema#int"),
857            "integer"
858        );
859        assert_eq!(
860            gen.data_type_to_json_type("http://www.w3.org/2001/XMLSchema#float"),
861            "number"
862        );
863        assert_eq!(
864            gen.data_type_to_json_type("http://www.w3.org/2001/XMLSchema#string"),
865            "string"
866        );
867        assert_eq!(gen.data_type_to_json_type("xsd:dateTime"), "string");
868    }
869
870    #[test]
871    fn test_either_generates_one_of() {
872        let left = Characteristic::new(
873            "urn:samm:org.example:1.0.0#Left".to_string(),
874            CharacteristicKind::Trait,
875        )
876        .with_data_type("http://www.w3.org/2001/XMLSchema#string".to_string());
877        let right = Characteristic::new(
878            "urn:samm:org.example:1.0.0#Right".to_string(),
879            CharacteristicKind::Trait,
880        )
881        .with_data_type("http://www.w3.org/2001/XMLSchema#int".to_string());
882
883        let char = Characteristic::new(
884            "urn:samm:org.example:1.0.0#EitherChar".to_string(),
885            CharacteristicKind::Either {
886                left: Box::new(left),
887                right: Box::new(right),
888            },
889        );
890
891        let gen = JsonSchemaGenerator::new();
892        let schema = gen
893            .characteristic_to_schema(&char)
894            .expect("generation should succeed");
895        assert!(schema["oneOf"].is_array());
896        assert_eq!(schema["oneOf"].as_array().map(|a| a.len()), Some(2));
897    }
898
899    #[test]
900    fn test_draft_07_schema_identifier() {
901        let gen = JsonSchemaGenerator {
902            options: JsonSchemaOptions {
903                use_defs_keyword: false,
904                ..Default::default()
905            },
906        };
907        let aspect = speed_aspect();
908        let schema = gen.generate(&aspect).expect("generation should succeed");
909        assert_eq!(schema["$schema"], "http://json-schema.org/draft-07/schema#");
910    }
911
912    // ─────────────────────────────────────────────────────────────────────
913    // JsonSchemaValidator tests
914    // ─────────────────────────────────────────────────────────────────────
915
916    #[test]
917    fn test_validator_valid_string_type() {
918        let v = JsonSchemaValidator::new();
919        let schema = serde_json::json!({ "type": "string" });
920        let instance = serde_json::json!("hello");
921        assert!(v.validate(&schema, &instance).is_empty());
922    }
923
924    #[test]
925    fn test_validator_invalid_string_type() {
926        let v = JsonSchemaValidator::new();
927        let schema = serde_json::json!({ "type": "string" });
928        let instance = serde_json::json!(42);
929        let errors = v.validate(&schema, &instance);
930        assert!(!errors.is_empty());
931        assert!(errors[0].message.contains("string"));
932    }
933
934    #[test]
935    fn test_validator_valid_number_type() {
936        let v = JsonSchemaValidator::new();
937        let schema = serde_json::json!({ "type": "number" });
938        assert!(v.validate(&schema, &serde_json::json!(3.5)).is_empty());
939    }
940
941    #[test]
942    fn test_validator_invalid_number_type() {
943        let v = JsonSchemaValidator::new();
944        let schema = serde_json::json!({ "type": "number" });
945        let errors = v.validate(&schema, &serde_json::json!("not a number"));
946        assert!(!errors.is_empty());
947    }
948
949    #[test]
950    fn test_validator_valid_integer_type() {
951        let v = JsonSchemaValidator::new();
952        let schema = serde_json::json!({ "type": "integer" });
953        assert!(v.validate(&schema, &serde_json::json!(7)).is_empty());
954    }
955
956    #[test]
957    fn test_validator_invalid_integer_type() {
958        let v = JsonSchemaValidator::new();
959        let schema = serde_json::json!({ "type": "integer" });
960        // 3.5 has a fractional part — not an integer
961        let errors = v.validate(&schema, &serde_json::json!(3.5));
962        assert!(!errors.is_empty());
963    }
964
965    #[test]
966    fn test_validator_valid_boolean_type() {
967        let v = JsonSchemaValidator::new();
968        let schema = serde_json::json!({ "type": "boolean" });
969        assert!(v.validate(&schema, &serde_json::json!(true)).is_empty());
970    }
971
972    #[test]
973    fn test_validator_invalid_boolean_type() {
974        let v = JsonSchemaValidator::new();
975        let schema = serde_json::json!({ "type": "boolean" });
976        let errors = v.validate(&schema, &serde_json::json!("true"));
977        assert!(!errors.is_empty());
978    }
979
980    #[test]
981    fn test_validator_valid_array_type() {
982        let v = JsonSchemaValidator::new();
983        let schema = serde_json::json!({ "type": "array" });
984        assert!(v
985            .validate(&schema, &serde_json::json!([1, 2, 3]))
986            .is_empty());
987    }
988
989    #[test]
990    fn test_validator_invalid_array_type() {
991        let v = JsonSchemaValidator::new();
992        let schema = serde_json::json!({ "type": "array" });
993        let errors = v.validate(&schema, &serde_json::json!({}));
994        assert!(!errors.is_empty());
995    }
996
997    #[test]
998    fn test_validator_valid_object_type() {
999        let v = JsonSchemaValidator::new();
1000        let schema = serde_json::json!({ "type": "object" });
1001        assert!(v.validate(&schema, &serde_json::json!({"a": 1})).is_empty());
1002    }
1003
1004    #[test]
1005    fn test_validator_invalid_object_type() {
1006        let v = JsonSchemaValidator::new();
1007        let schema = serde_json::json!({ "type": "object" });
1008        let errors = v.validate(&schema, &serde_json::json!([1, 2]));
1009        assert!(!errors.is_empty());
1010    }
1011
1012    #[test]
1013    fn test_validator_valid_null_type() {
1014        let v = JsonSchemaValidator::new();
1015        let schema = serde_json::json!({ "type": "null" });
1016        assert!(v.validate(&schema, &serde_json::json!(null)).is_empty());
1017    }
1018
1019    #[test]
1020    fn test_validator_required_fields_all_present() {
1021        let v = JsonSchemaValidator::new();
1022        let schema = serde_json::json!({
1023            "type": "object",
1024            "required": ["name", "age"]
1025        });
1026        let instance = serde_json::json!({ "name": "Alice", "age": 30 });
1027        assert!(v.validate(&schema, &instance).is_empty());
1028    }
1029
1030    #[test]
1031    fn test_validator_required_fields_missing() {
1032        let v = JsonSchemaValidator::new();
1033        let schema = serde_json::json!({
1034            "type": "object",
1035            "required": ["name"]
1036        });
1037        let instance = serde_json::json!({ "age": 30 });
1038        let errors = v.validate(&schema, &instance);
1039        assert!(!errors.is_empty());
1040        assert!(errors.iter().any(|e| e.message.contains("name")));
1041    }
1042
1043    #[test]
1044    fn test_validator_required_multiple_missing() {
1045        let v = JsonSchemaValidator::new();
1046        let schema = serde_json::json!({
1047            "type": "object",
1048            "required": ["a", "b", "c"]
1049        });
1050        let instance = serde_json::json!({});
1051        let errors = v.validate(&schema, &instance);
1052        // Should have at least 3 errors (one per missing field)
1053        assert!(errors.len() >= 3);
1054    }
1055
1056    #[test]
1057    fn test_validator_enum_valid() {
1058        let v = JsonSchemaValidator::new();
1059        let schema = serde_json::json!({ "enum": ["red", "green", "blue"] });
1060        assert!(v.validate(&schema, &serde_json::json!("green")).is_empty());
1061    }
1062
1063    #[test]
1064    fn test_validator_enum_invalid() {
1065        let v = JsonSchemaValidator::new();
1066        let schema = serde_json::json!({ "enum": ["red", "green", "blue"] });
1067        let errors = v.validate(&schema, &serde_json::json!("purple"));
1068        assert!(!errors.is_empty());
1069        assert!(errors[0].message.contains("enum"));
1070    }
1071
1072    #[test]
1073    fn test_validator_minimum_valid() {
1074        let v = JsonSchemaValidator::new();
1075        let schema = serde_json::json!({ "type": "number", "minimum": 0.0 });
1076        assert!(v.validate(&schema, &serde_json::json!(5)).is_empty());
1077    }
1078
1079    #[test]
1080    fn test_validator_minimum_violation() {
1081        let v = JsonSchemaValidator::new();
1082        let schema = serde_json::json!({ "type": "number", "minimum": 10 });
1083        let errors = v.validate(&schema, &serde_json::json!(5));
1084        assert!(!errors.is_empty());
1085        assert!(errors.iter().any(|e| e.schema_path.contains("minimum")));
1086    }
1087
1088    #[test]
1089    fn test_validator_maximum_valid() {
1090        let v = JsonSchemaValidator::new();
1091        let schema = serde_json::json!({ "type": "number", "maximum": 100 });
1092        assert!(v.validate(&schema, &serde_json::json!(50)).is_empty());
1093    }
1094
1095    #[test]
1096    fn test_validator_maximum_violation() {
1097        let v = JsonSchemaValidator::new();
1098        let schema = serde_json::json!({ "type": "number", "maximum": 10 });
1099        let errors = v.validate(&schema, &serde_json::json!(20));
1100        assert!(!errors.is_empty());
1101        assert!(errors.iter().any(|e| e.schema_path.contains("maximum")));
1102    }
1103
1104    #[test]
1105    fn test_validator_min_length_valid() {
1106        let v = JsonSchemaValidator::new();
1107        let schema = serde_json::json!({ "type": "string", "minLength": 3 });
1108        assert!(v.validate(&schema, &serde_json::json!("abcd")).is_empty());
1109    }
1110
1111    #[test]
1112    fn test_validator_min_length_violation() {
1113        let v = JsonSchemaValidator::new();
1114        let schema = serde_json::json!({ "type": "string", "minLength": 5 });
1115        let errors = v.validate(&schema, &serde_json::json!("hi"));
1116        assert!(!errors.is_empty());
1117        assert!(errors.iter().any(|e| e.schema_path.contains("minLength")));
1118    }
1119
1120    #[test]
1121    fn test_validator_max_length_violation() {
1122        let v = JsonSchemaValidator::new();
1123        let schema = serde_json::json!({ "type": "string", "maxLength": 3 });
1124        let errors = v.validate(&schema, &serde_json::json!("toolong"));
1125        assert!(!errors.is_empty());
1126        assert!(errors.iter().any(|e| e.schema_path.contains("maxLength")));
1127    }
1128
1129    #[test]
1130    fn test_validator_additional_properties_blocked() {
1131        let v = JsonSchemaValidator::new();
1132        let schema = serde_json::json!({
1133            "type": "object",
1134            "properties": { "name": { "type": "string" } },
1135            "additionalProperties": false
1136        });
1137        let instance = serde_json::json!({ "name": "Alice", "extra": "oops" });
1138        let errors = v.validate(&schema, &instance);
1139        assert!(!errors.is_empty());
1140        assert!(errors.iter().any(|e| e.message.contains("extra")));
1141    }
1142
1143    #[test]
1144    fn test_validator_additional_properties_allowed_when_not_false() {
1145        let v = JsonSchemaValidator::new();
1146        let schema = serde_json::json!({
1147            "type": "object",
1148            "properties": { "name": { "type": "string" } }
1149        });
1150        let instance = serde_json::json!({ "name": "Alice", "extra": "ok" });
1151        // No additionalProperties: false — should pass
1152        assert!(v.validate(&schema, &instance).is_empty());
1153    }
1154
1155    #[test]
1156    fn test_validator_nested_property_type_error() {
1157        let v = JsonSchemaValidator::new();
1158        let schema = serde_json::json!({
1159            "type": "object",
1160            "properties": {
1161                "user": {
1162                    "type": "object",
1163                    "properties": {
1164                        "age": { "type": "integer" }
1165                    }
1166                }
1167            }
1168        });
1169        // age should be integer but is string
1170        let instance = serde_json::json!({ "user": { "age": "not-a-number" } });
1171        let errors = v.validate(&schema, &instance);
1172        assert!(!errors.is_empty());
1173        assert!(errors.iter().any(|e| e.path.contains("age")));
1174    }
1175
1176    #[test]
1177    fn test_validation_error_fields() {
1178        let err = ValidationError::new("/name", "required property 'name' is missing", "/required");
1179        assert_eq!(err.path, "/name");
1180        assert!(err.message.contains("name"));
1181        assert_eq!(err.schema_path, "/required");
1182    }
1183
1184    #[test]
1185    fn test_validator_no_errors_for_valid_complex_object() {
1186        let v = JsonSchemaValidator::new();
1187        let schema = serde_json::json!({
1188            "type": "object",
1189            "required": ["id", "name"],
1190            "properties": {
1191                "id": { "type": "integer" },
1192                "name": { "type": "string", "minLength": 1 },
1193                "score": { "type": "number", "minimum": 0.0, "maximum": 100.0 }
1194            },
1195            "additionalProperties": false
1196        });
1197        let instance = serde_json::json!({
1198            "id": 42,
1199            "name": "Alice",
1200            "score": 95.5
1201        });
1202        let errors = v.validate(&schema, &instance);
1203        assert!(errors.is_empty(), "unexpected errors: {:?}", errors);
1204    }
1205}