sherpack_core/
schema.rs

1//! Schema validation for values
2//!
3//! This module provides schema validation with support for two formats:
4//! - Standard JSON Schema (compatible with Helm's values.schema.json)
5//! - Simplified Sherpack schema format (more intuitive for YAML users)
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value as JsonValue;
9use std::collections::HashMap;
10use std::path::Path;
11
12use crate::error::{CoreError, Result, ValidationErrorInfo};
13use crate::values::Values;
14
15/// Simplified Sherpack schema type
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum SherpType {
19    String,
20    Number,
21    Integer,
22    Boolean,
23    Array,
24    Object,
25    Any,
26}
27
28/// Simplified Sherpack schema definition for a single property
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct SherpProperty {
32    /// Type of the property
33    #[serde(rename = "type")]
34    pub prop_type: SherpType,
35
36    /// Description for documentation
37    #[serde(default)]
38    pub description: Option<String>,
39
40    /// Default value
41    #[serde(default)]
42    pub default: Option<JsonValue>,
43
44    /// Whether this property is required
45    #[serde(default)]
46    pub required: bool,
47
48    /// Allowed values (enum constraint)
49    #[serde(default)]
50    pub enum_values: Option<Vec<JsonValue>>,
51
52    /// Pattern for string validation (regex)
53    #[serde(default)]
54    pub pattern: Option<String>,
55
56    /// Minimum value for numbers
57    #[serde(default)]
58    pub min: Option<f64>,
59
60    /// Maximum value for numbers
61    #[serde(default)]
62    pub max: Option<f64>,
63
64    /// Minimum length for strings
65    #[serde(default)]
66    pub min_length: Option<usize>,
67
68    /// Maximum length for strings
69    #[serde(default)]
70    pub max_length: Option<usize>,
71
72    /// Nested properties for objects
73    #[serde(default)]
74    pub properties: Option<HashMap<String, SherpProperty>>,
75
76    /// Item schema for arrays
77    #[serde(default)]
78    pub items: Option<Box<SherpProperty>>,
79
80    /// Minimum array items
81    #[serde(default)]
82    pub min_items: Option<usize>,
83
84    /// Maximum array items
85    #[serde(default)]
86    pub max_items: Option<usize>,
87}
88
89/// Root schema definition in simplified format
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct SherpSchema {
93    /// Schema format identifier
94    #[serde(default = "default_schema_version")]
95    pub schema_version: String,
96
97    /// Optional schema title
98    #[serde(default)]
99    pub title: Option<String>,
100
101    /// Optional schema description
102    #[serde(default)]
103    pub description: Option<String>,
104
105    /// Property definitions
106    pub properties: HashMap<String, SherpProperty>,
107}
108
109fn default_schema_version() -> String {
110    "sherpack/v1".to_string()
111}
112
113/// Schema format detection
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub enum SchemaFormat {
116    JsonSchema,
117    SherpSchema,
118}
119
120/// Unified schema that handles both formats
121#[derive(Debug, Clone)]
122pub enum Schema {
123    /// Standard JSON Schema
124    JsonSchema(JsonValue),
125    /// Simplified Sherpack schema
126    SherpSchema(SherpSchema),
127}
128
129impl Schema {
130    /// Load schema from a file, auto-detecting format
131    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
132        let path = path.as_ref();
133        let content = std::fs::read_to_string(path)?;
134
135        // Detect format based on extension and content
136        let format = detect_schema_format(path, &content)?;
137
138        match format {
139            SchemaFormat::JsonSchema => {
140                let value: JsonValue = if path.extension().map(|e| e == "json").unwrap_or(false) {
141                    serde_json::from_str(&content)?
142                } else {
143                    serde_yaml::from_str(&content)?
144                };
145                Ok(Schema::JsonSchema(value))
146            }
147            SchemaFormat::SherpSchema => {
148                let sherp: SherpSchema = serde_yaml::from_str(&content)?;
149                Ok(Schema::SherpSchema(sherp))
150            }
151        }
152    }
153
154    /// Load from JSON Schema string
155    pub fn from_json_schema(json: &str) -> Result<Self> {
156        let value: JsonValue = serde_json::from_str(json)?;
157        Ok(Schema::JsonSchema(value))
158    }
159
160    /// Load from simplified schema YAML string
161    pub fn from_sherp_schema(yaml: &str) -> Result<Self> {
162        let sherp: SherpSchema = serde_yaml::from_str(yaml)?;
163        Ok(Schema::SherpSchema(sherp))
164    }
165
166    /// Convert to JSON Schema for validation
167    pub fn to_json_schema(&self) -> JsonValue {
168        match self {
169            Schema::JsonSchema(v) => v.clone(),
170            Schema::SherpSchema(s) => convert_sherp_to_json_schema(s),
171        }
172    }
173
174    /// Extract defaults from the schema
175    pub fn extract_defaults(&self) -> JsonValue {
176        match self {
177            Schema::JsonSchema(v) => extract_json_schema_defaults(v),
178            Schema::SherpSchema(s) => extract_sherp_defaults(s),
179        }
180    }
181
182    /// Get defaults as Values
183    pub fn defaults_as_values(&self) -> Values {
184        Values(self.extract_defaults())
185    }
186}
187
188/// Detect schema format from file path and content
189fn detect_schema_format(path: &Path, content: &str) -> Result<SchemaFormat> {
190    // Check file extension first
191    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
192    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
193
194    // values.schema.json -> JSON Schema
195    if name == "values.schema.json" || ext == "json" {
196        return Ok(SchemaFormat::JsonSchema);
197    }
198
199    // Check for JSON Schema markers in content
200    let value: JsonValue = serde_yaml::from_str(content).map_err(|e| CoreError::InvalidSchema {
201        message: format!("Failed to parse schema: {}", e),
202    })?;
203
204    if let Some(obj) = value.as_object() {
205        // JSON Schema indicators
206        if obj.contains_key("$schema") || obj.contains_key("$id") {
207            return Ok(SchemaFormat::JsonSchema);
208        }
209
210        // Sherpack schema indicator
211        if obj
212            .get("schemaVersion")
213            .and_then(|v| v.as_str())
214            .map(|s| s.starts_with("sherpack/"))
215            .unwrap_or(false)
216        {
217            return Ok(SchemaFormat::SherpSchema);
218        }
219
220        // Heuristic: if it has "type": "object" at root with nested structure,
221        // treat as JSON Schema; otherwise Sherpack
222        if obj.contains_key("type") && obj.get("type") == Some(&JsonValue::String("object".into()))
223        {
224            return Ok(SchemaFormat::JsonSchema);
225        }
226    }
227
228    // Default to Sherpack schema for .yaml files
229    Ok(SchemaFormat::SherpSchema)
230}
231
232/// Convert Sherpack schema to JSON Schema
233fn convert_sherp_to_json_schema(sherp: &SherpSchema) -> JsonValue {
234    let mut schema = serde_json::Map::new();
235
236    schema.insert(
237        "$schema".into(),
238        JsonValue::String("http://json-schema.org/draft-07/schema#".into()),
239    );
240    schema.insert("type".into(), JsonValue::String("object".into()));
241
242    if let Some(title) = &sherp.title {
243        schema.insert("title".into(), JsonValue::String(title.clone()));
244    }
245    if let Some(desc) = &sherp.description {
246        schema.insert("description".into(), JsonValue::String(desc.clone()));
247    }
248
249    let (properties, required) = convert_sherp_properties(&sherp.properties);
250    schema.insert("properties".into(), properties);
251
252    if !required.is_empty() {
253        schema.insert(
254            "required".into(),
255            JsonValue::Array(required.into_iter().map(JsonValue::String).collect()),
256        );
257    }
258
259    JsonValue::Object(schema)
260}
261
262fn convert_sherp_properties(props: &HashMap<String, SherpProperty>) -> (JsonValue, Vec<String>) {
263    let mut json_props = serde_json::Map::new();
264    let mut required = Vec::new();
265
266    for (name, prop) in props {
267        json_props.insert(name.clone(), convert_sherp_property(prop));
268        if prop.required {
269            required.push(name.clone());
270        }
271    }
272
273    (JsonValue::Object(json_props), required)
274}
275
276fn convert_sherp_property(prop: &SherpProperty) -> JsonValue {
277    let mut json = serde_json::Map::new();
278
279    // Type conversion
280    let type_str = match prop.prop_type {
281        SherpType::String => "string",
282        SherpType::Number => "number",
283        SherpType::Integer => "integer",
284        SherpType::Boolean => "boolean",
285        SherpType::Array => "array",
286        SherpType::Object => "object",
287        SherpType::Any => {
288            // JSON Schema doesn't have "any", omit type
289            return JsonValue::Object(json);
290        }
291    };
292    json.insert("type".into(), JsonValue::String(type_str.into()));
293
294    // Optional fields
295    if let Some(desc) = &prop.description {
296        json.insert("description".into(), JsonValue::String(desc.clone()));
297    }
298    if let Some(default) = &prop.default {
299        json.insert("default".into(), default.clone());
300    }
301    if let Some(enum_vals) = &prop.enum_values {
302        json.insert("enum".into(), JsonValue::Array(enum_vals.clone()));
303    }
304    if let Some(pattern) = &prop.pattern {
305        json.insert("pattern".into(), JsonValue::String(pattern.clone()));
306    }
307
308    // Numeric constraints
309    if let Some(min) = prop.min {
310        json.insert("minimum".into(), JsonValue::from(min));
311    }
312    if let Some(max) = prop.max {
313        json.insert("maximum".into(), JsonValue::from(max));
314    }
315
316    // String constraints
317    if let Some(min_len) = prop.min_length {
318        json.insert("minLength".into(), JsonValue::from(min_len));
319    }
320    if let Some(max_len) = prop.max_length {
321        json.insert("maxLength".into(), JsonValue::from(max_len));
322    }
323
324    // Nested objects
325    if let Some(nested_props) = &prop.properties {
326        let (nested_json, nested_required) = convert_sherp_properties(nested_props);
327        json.insert("properties".into(), nested_json);
328        if !nested_required.is_empty() {
329            json.insert(
330                "required".into(),
331                JsonValue::Array(nested_required.into_iter().map(JsonValue::String).collect()),
332            );
333        }
334    }
335
336    // Array items
337    if let Some(items) = &prop.items {
338        json.insert("items".into(), convert_sherp_property(items));
339    }
340    if let Some(min_items) = prop.min_items {
341        json.insert("minItems".into(), JsonValue::from(min_items));
342    }
343    if let Some(max_items) = prop.max_items {
344        json.insert("maxItems".into(), JsonValue::from(max_items));
345    }
346
347    JsonValue::Object(json)
348}
349
350/// Extract defaults from JSON Schema
351fn extract_json_schema_defaults(schema: &JsonValue) -> JsonValue {
352    extract_defaults_recursive(schema)
353}
354
355fn extract_defaults_recursive(schema: &JsonValue) -> JsonValue {
356    let obj = match schema.as_object() {
357        Some(o) => o,
358        None => return JsonValue::Null,
359    };
360
361    // If there's a direct default, return it
362    if let Some(default) = obj.get("default") {
363        return default.clone();
364    }
365
366    // For objects, recursively extract property defaults
367    if obj.get("type") == Some(&JsonValue::String("object".into()))
368        && let Some(props) = obj.get("properties").and_then(|p| p.as_object())
369    {
370        let mut defaults = serde_json::Map::new();
371
372        for (key, prop_schema) in props {
373            let prop_default = extract_defaults_recursive(prop_schema);
374            if !prop_default.is_null() {
375                defaults.insert(key.clone(), prop_default);
376            }
377        }
378
379        if !defaults.is_empty() {
380            return JsonValue::Object(defaults);
381        }
382    }
383
384    JsonValue::Null
385}
386
387/// Extract defaults from Sherpack schema
388fn extract_sherp_defaults(sherp: &SherpSchema) -> JsonValue {
389    extract_sherp_property_defaults(&sherp.properties)
390}
391
392fn extract_sherp_property_defaults(props: &HashMap<String, SherpProperty>) -> JsonValue {
393    let mut defaults = serde_json::Map::new();
394
395    for (name, prop) in props {
396        let value = if let Some(default) = &prop.default {
397            default.clone()
398        } else if let Some(nested) = &prop.properties {
399            let nested_defaults = extract_sherp_property_defaults(nested);
400            if nested_defaults.is_null()
401                || nested_defaults
402                    .as_object()
403                    .map(|o| o.is_empty())
404                    .unwrap_or(true)
405            {
406                continue;
407            }
408            nested_defaults
409        } else {
410            continue;
411        };
412
413        defaults.insert(name.clone(), value);
414    }
415
416    if defaults.is_empty() {
417        JsonValue::Null
418    } else {
419        JsonValue::Object(defaults)
420    }
421}
422
423/// Result of schema validation
424#[derive(Debug)]
425pub struct ValidationResult {
426    /// Whether the values are valid
427    pub is_valid: bool,
428    /// Validation errors
429    pub errors: Vec<ValidationErrorInfo>,
430}
431
432impl ValidationResult {
433    /// Create a successful validation result
434    pub fn success() -> Self {
435        Self {
436            is_valid: true,
437            errors: vec![],
438        }
439    }
440
441    /// Create a failed validation result with errors
442    pub fn failure(errors: Vec<ValidationErrorInfo>) -> Self {
443        Self {
444            is_valid: false,
445            errors,
446        }
447    }
448}
449
450/// Schema validator with cached compiled schema
451pub struct SchemaValidator {
452    /// The original schema
453    schema: Schema,
454
455    /// Compiled JSON Schema validator
456    compiled: jsonschema::Validator,
457
458    /// Extracted default values
459    defaults: JsonValue,
460}
461
462impl SchemaValidator {
463    /// Create a new validator from a schema
464    pub fn new(schema: Schema) -> Result<Self> {
465        let json_schema = schema.to_json_schema();
466        let defaults = schema.extract_defaults();
467
468        let compiled =
469            jsonschema::validator_for(&json_schema).map_err(|e| CoreError::InvalidSchema {
470                message: format!("Invalid schema: {}", e),
471            })?;
472
473        Ok(Self {
474            schema,
475            compiled,
476            defaults,
477        })
478    }
479
480    /// Validate values against the schema
481    pub fn validate(&self, values: &JsonValue) -> ValidationResult {
482        if self.compiled.is_valid(values) {
483            return ValidationResult::success();
484        }
485
486        // Collect all validation errors
487        let errors: Vec<ValidationErrorInfo> = self
488            .compiled
489            .iter_errors(values)
490            .map(|e| {
491                let path = e.instance_path().to_string();
492                ValidationErrorInfo {
493                    path: if path.is_empty() {
494                        "(root)".to_string()
495                    } else {
496                        path
497                    },
498                    message: format_validation_error(&e),
499                    expected: None,
500                    actual: None,
501                }
502            })
503            .collect();
504
505        ValidationResult::failure(errors)
506    }
507
508    /// Get extracted default values
509    pub fn defaults(&self) -> &JsonValue {
510        &self.defaults
511    }
512
513    /// Get defaults as Values
514    pub fn defaults_as_values(&self) -> Values {
515        Values(self.defaults.clone())
516    }
517
518    /// Get the original schema
519    pub fn schema(&self) -> &Schema {
520        &self.schema
521    }
522}
523
524/// Format a validation error into a user-friendly message
525fn format_validation_error(error: &jsonschema::ValidationError) -> String {
526    let msg = error.to_string();
527
528    // Clean up common patterns for better readability
529    msg.replace("\"", "'")
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_sherp_schema_parse() {
538        let yaml = r#"
539schemaVersion: sherpack/v1
540title: Test Schema
541properties:
542  app:
543    type: object
544    properties:
545      name:
546        type: string
547        required: true
548      replicas:
549        type: integer
550        default: 1
551        min: 0
552        max: 100
553"#;
554
555        let schema = Schema::from_sherp_schema(yaml).unwrap();
556        match schema {
557            Schema::SherpSchema(s) => {
558                assert_eq!(s.title, Some("Test Schema".to_string()));
559                assert!(s.properties.contains_key("app"));
560            }
561            _ => panic!("Expected SherpSchema"),
562        }
563    }
564
565    #[test]
566    fn test_sherp_to_json_schema_conversion() {
567        let yaml = r#"
568schemaVersion: sherpack/v1
569properties:
570  name:
571    type: string
572    required: true
573  replicas:
574    type: integer
575    default: 3
576"#;
577
578        let schema = Schema::from_sherp_schema(yaml).unwrap();
579        let json_schema = schema.to_json_schema();
580
581        let obj = json_schema.as_object().unwrap();
582        assert_eq!(obj.get("type"), Some(&JsonValue::String("object".into())));
583        assert!(obj.contains_key("properties"));
584
585        let required = obj.get("required").unwrap().as_array().unwrap();
586        assert!(required.contains(&JsonValue::String("name".into())));
587    }
588
589    #[test]
590    fn test_extract_defaults() {
591        let yaml = r#"
592schemaVersion: sherpack/v1
593properties:
594  replicas:
595    type: integer
596    default: 3
597  image:
598    type: object
599    properties:
600      tag:
601        type: string
602        default: latest
603      pullPolicy:
604        type: string
605        default: IfNotPresent
606"#;
607
608        let schema = Schema::from_sherp_schema(yaml).unwrap();
609        let defaults = schema.extract_defaults();
610
611        assert_eq!(defaults.get("replicas"), Some(&JsonValue::from(3)));
612
613        let image = defaults.get("image").unwrap();
614        assert_eq!(image.get("tag"), Some(&JsonValue::String("latest".into())));
615        assert_eq!(
616            image.get("pullPolicy"),
617            Some(&JsonValue::String("IfNotPresent".into()))
618        );
619    }
620
621    #[test]
622    fn test_validation_success() {
623        let yaml = r#"
624schemaVersion: sherpack/v1
625properties:
626  replicas:
627    type: integer
628    min: 0
629    max: 10
630"#;
631
632        let schema = Schema::from_sherp_schema(yaml).unwrap();
633        let validator = SchemaValidator::new(schema).unwrap();
634
635        let values = serde_json::json!({
636            "replicas": 5
637        });
638
639        let result = validator.validate(&values);
640        assert!(result.is_valid);
641        assert!(result.errors.is_empty());
642    }
643
644    #[test]
645    fn test_validation_failure() {
646        let yaml = r#"
647schemaVersion: sherpack/v1
648properties:
649  replicas:
650    type: integer
651    min: 0
652    max: 10
653"#;
654
655        let schema = Schema::from_sherp_schema(yaml).unwrap();
656        let validator = SchemaValidator::new(schema).unwrap();
657
658        let values = serde_json::json!({
659            "replicas": "not a number"
660        });
661
662        let result = validator.validate(&values);
663        assert!(!result.is_valid);
664        assert!(!result.errors.is_empty());
665    }
666
667    #[test]
668    fn test_json_schema_detection() {
669        let json_schema = r#"{
670            "$schema": "http://json-schema.org/draft-07/schema#",
671            "type": "object",
672            "properties": {
673                "name": { "type": "string" }
674            }
675        }"#;
676
677        let schema = Schema::from_json_schema(json_schema).unwrap();
678        match schema {
679            Schema::JsonSchema(_) => {}
680            _ => panic!("Expected JsonSchema"),
681        }
682    }
683}