distributed_config/
validation.rs

1//! Configuration validation using JSON Schema
2
3use crate::error::{ConfigError, Result};
4use crate::value::ConfigValue;
5use jsonschema::{Draft, JSONSchema};
6use serde_json::Value as JsonValue;
7use std::collections::HashMap;
8use tracing::{debug, info};
9
10/// Schema validator for configuration values
11pub struct SchemaValidator {
12    schemas: HashMap<String, JSONSchema>,
13}
14
15impl SchemaValidator {
16    /// Create a new schema validator
17    pub fn new() -> Self {
18        Self {
19            schemas: HashMap::new(),
20        }
21    }
22
23    /// Add a schema for a specific configuration path
24    pub fn add_schema<T>(mut self, path: &str) -> Self
25    where
26        T: serde::Serialize + for<'de> serde::Deserialize<'de>,
27    {
28        // Generate JSON schema from the type
29        if let Ok(schema) = generate_schema_for_type::<T>() {
30            if let Ok(compiled) = JSONSchema::compile(&schema) {
31                self.schemas.insert(path.to_string(), compiled);
32                info!("Added schema for configuration path: {}", path);
33            }
34        }
35        self
36    }
37
38    /// Add a schema from a JSON schema object
39    pub fn add_schema_from_json(mut self, path: &str, schema: JsonValue) -> Result<Self> {
40        let compiled = JSONSchema::options()
41            .with_draft(Draft::Draft7)
42            .compile(&schema)
43            .map_err(|e| ConfigError::ValidationError(format!("Invalid schema: {e}")))?;
44
45        self.schemas.insert(path.to_string(), compiled);
46        info!("Added JSON schema for configuration path: {}", path);
47        Ok(self)
48    }
49
50    /// Add a schema from a JSON schema string
51    pub fn add_schema_from_string(self, path: &str, schema_str: &str) -> Result<Self> {
52        let schema: JsonValue = serde_json::from_str(schema_str)
53            .map_err(|e| ConfigError::ValidationError(format!("Invalid schema JSON: {e}")))?;
54
55        self.add_schema_from_json(path, schema)
56    }
57
58    /// Validate a configuration value against all applicable schemas
59    pub fn validate(&self, config: &ConfigValue) -> Result<()> {
60        // Convert ConfigValue to JSON for validation
61        let json_value = config_value_to_json(config)?;
62
63        let mut validation_errors = Vec::new();
64
65        // Validate against each schema
66        for (path, schema) in &self.schemas {
67            if let Some(value_to_validate) = get_value_at_path(&json_value, path) {
68                if let Err(errors) = schema.validate(&value_to_validate) {
69                    for error in errors {
70                        validation_errors.push(format!("Path '{path}': {error}"));
71                    }
72                }
73            } else {
74                debug!("No value found at path '{}' for validation", path);
75            }
76        }
77
78        if !validation_errors.is_empty() {
79            return Err(ConfigError::ValidationError(validation_errors.join("; ")));
80        }
81
82        debug!(
83            "Configuration validation passed for {} schemas",
84            self.schemas.len()
85        );
86        Ok(())
87    }
88
89    /// Validate a specific configuration value at a path
90    pub fn validate_path(&self, path: &str, value: &ConfigValue) -> Result<()> {
91        if let Some(schema) = self.schemas.get(path) {
92            let json_value = config_value_to_json(value)?;
93
94            let result = schema.validate(&json_value);
95            if let Err(errors) = result {
96                let error_messages: Vec<String> = errors.map(|e| e.to_string()).collect();
97                return Err(ConfigError::ValidationError(error_messages.join("; ")));
98            }
99        }
100
101        Ok(())
102    }
103
104    /// Get the list of schema paths
105    pub fn schema_paths(&self) -> Vec<String> {
106        self.schemas.keys().cloned().collect()
107    }
108
109    /// Check if a schema exists for a given path
110    pub fn has_schema(&self, path: &str) -> bool {
111        self.schemas.contains_key(path)
112    }
113
114    /// Remove a schema for a path
115    pub fn remove_schema(&mut self, path: &str) -> bool {
116        self.schemas.remove(path).is_some()
117    }
118}
119
120impl Default for SchemaValidator {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126/// Convert ConfigValue to JSON Value for validation
127fn config_value_to_json(config: &ConfigValue) -> Result<JsonValue> {
128    match config {
129        ConfigValue::Null => Ok(JsonValue::Null),
130        ConfigValue::Bool(b) => Ok(JsonValue::Bool(*b)),
131        ConfigValue::Integer(i) => Ok(JsonValue::Number((*i).into())),
132        ConfigValue::Float(f) => {
133            if let Some(num) = serde_json::Number::from_f64(*f) {
134                Ok(JsonValue::Number(num))
135            } else {
136                Ok(JsonValue::Null)
137            }
138        }
139        ConfigValue::String(s) => Ok(JsonValue::String(s.clone())),
140        ConfigValue::Array(arr) => {
141            let json_arr: Result<Vec<JsonValue>> = arr.iter().map(config_value_to_json).collect();
142            Ok(JsonValue::Array(json_arr?))
143        }
144        ConfigValue::Object(obj) => {
145            let json_obj: Result<serde_json::Map<String, JsonValue>> = obj
146                .iter()
147                .map(|(k, v)| config_value_to_json(v).map(|json_v| (k.clone(), json_v)))
148                .collect();
149            Ok(JsonValue::Object(json_obj?))
150        }
151        ConfigValue::Duration(d) => Ok(JsonValue::Number(d.as_secs().into())),
152    }
153}
154
155/// Get a value at a specific path in a JSON object
156fn get_value_at_path(json: &JsonValue, path: &str) -> Option<JsonValue> {
157    if path.is_empty() {
158        return Some(json.clone());
159    }
160
161    let parts: Vec<&str> = path.split('.').collect();
162    let mut current = json;
163
164    for part in parts {
165        match current {
166            JsonValue::Object(obj) => {
167                current = obj.get(part)?;
168            }
169            _ => return None,
170        }
171    }
172
173    Some(current.clone())
174}
175
176/// Generate a JSON schema for a Rust type
177fn generate_schema_for_type<T>() -> Result<JsonValue>
178where
179    T: serde::Serialize + for<'de> serde::Deserialize<'de>,
180{
181    // This is a simplified schema generator
182    // In a real implementation, you might want to use a crate like `schemars`
183
184    // For now, we'll create a basic schema structure
185    let schema = serde_json::json!({
186        "$schema": "http://json-schema.org/draft-07/schema#",
187        "type": "object",
188        "properties": {},
189        "additionalProperties": true
190    });
191
192    Ok(schema)
193}
194
195/// Common validation schemas
196pub mod schemas {
197    use super::*;
198
199    /// Database configuration schema
200    pub fn database_config() -> JsonValue {
201        serde_json::json!({
202            "$schema": "http://json-schema.org/draft-07/schema#",
203            "type": "object",
204            "properties": {
205                "host": {
206                    "type": "string",
207                    "format": "hostname"
208                },
209                "port": {
210                    "type": "integer",
211                    "minimum": 1,
212                    "maximum": 65535
213                },
214                "username": {
215                    "type": "string",
216                    "minLength": 1
217                },
218                "password": {
219                    "type": "string",
220                    "minLength": 1
221                },
222                "database": {
223                    "type": "string",
224                    "minLength": 1
225                },
226                "max_connections": {
227                    "type": "integer",
228                    "minimum": 1
229                },
230                "timeout": {
231                    "type": "integer",
232                    "minimum": 0
233                }
234            },
235            "required": ["host", "port", "username", "password", "database"],
236            "additionalProperties": false
237        })
238    }
239
240    /// Server configuration schema
241    pub fn server_config() -> JsonValue {
242        serde_json::json!({
243            "$schema": "http://json-schema.org/draft-07/schema#",
244            "type": "object",
245            "properties": {
246                "host": {
247                    "type": "string",
248                    "default": "0.0.0.0"
249                },
250                "port": {
251                    "type": "integer",
252                    "minimum": 1,
253                    "maximum": 65535
254                },
255                "workers": {
256                    "type": "integer",
257                    "minimum": 1
258                },
259                "debug": {
260                    "type": "boolean",
261                    "default": false
262                },
263                "log_level": {
264                    "type": "string",
265                    "enum": ["trace", "debug", "info", "warn", "error"]
266                }
267            },
268            "required": ["port"],
269            "additionalProperties": false
270        })
271    }
272
273    /// Feature flags schema
274    pub fn feature_flags() -> JsonValue {
275        serde_json::json!({
276            "$schema": "http://json-schema.org/draft-07/schema#",
277            "type": "object",
278            "patternProperties": {
279                "^[a-zA-Z][a-zA-Z0-9_-]*$": {
280                    "type": "boolean"
281                }
282            },
283            "additionalProperties": false
284        })
285    }
286
287    /// API configuration schema
288    pub fn api_config() -> JsonValue {
289        serde_json::json!({
290            "$schema": "http://json-schema.org/draft-07/schema#",
291            "type": "object",
292            "properties": {
293                "base_url": {
294                    "type": "string",
295                    "format": "uri"
296                },
297                "timeout": {
298                    "type": "integer",
299                    "minimum": 0
300                },
301                "retries": {
302                    "type": "integer",
303                    "minimum": 0,
304                    "maximum": 10
305                },
306                "rate_limit": {
307                    "type": "object",
308                    "properties": {
309                        "requests_per_second": {
310                            "type": "integer",
311                            "minimum": 1
312                        },
313                        "burst_size": {
314                            "type": "integer",
315                            "minimum": 1
316                        }
317                    },
318                    "additionalProperties": false
319                }
320            },
321            "required": ["base_url"],
322            "additionalProperties": false
323        })
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::collections::HashMap;
331
332    #[test]
333    fn test_schema_validator_basic() {
334        let mut validator = SchemaValidator::new();
335
336        // Add a simple schema
337        let schema = serde_json::json!({
338            "type": "object",
339            "properties": {
340                "name": {"type": "string"},
341                "age": {"type": "integer", "minimum": 0}
342            },
343            "required": ["name"]
344        });
345
346        validator = validator.add_schema_from_json("person", schema).unwrap();
347
348        // Valid configuration
349        let mut config = HashMap::new();
350        config.insert("name".to_string(), ConfigValue::String("John".to_string()));
351        config.insert("age".to_string(), ConfigValue::Integer(25));
352        let valid_config = ConfigValue::Object(config);
353
354        assert!(validator.validate_path("person", &valid_config).is_ok());
355
356        // Invalid configuration (missing required field)
357        let mut invalid_config = HashMap::new();
358        invalid_config.insert("age".to_string(), ConfigValue::Integer(25));
359        let invalid_config = ConfigValue::Object(invalid_config);
360
361        assert!(validator.validate_path("person", &invalid_config).is_err());
362    }
363
364    #[test]
365    fn test_database_schema() {
366        let mut validator = SchemaValidator::new();
367        validator = validator
368            .add_schema_from_json("database", schemas::database_config())
369            .unwrap();
370
371        // Valid database config
372        let mut config = HashMap::new();
373        config.insert(
374            "host".to_string(),
375            ConfigValue::String("localhost".to_string()),
376        );
377        config.insert("port".to_string(), ConfigValue::Integer(5432));
378        config.insert(
379            "username".to_string(),
380            ConfigValue::String("user".to_string()),
381        );
382        config.insert(
383            "password".to_string(),
384            ConfigValue::String("pass".to_string()),
385        );
386        config.insert(
387            "database".to_string(),
388            ConfigValue::String("mydb".to_string()),
389        );
390        let valid_config = ConfigValue::Object(config);
391
392        assert!(validator.validate_path("database", &valid_config).is_ok());
393
394        // Invalid database config (invalid port)
395        let mut invalid_config = HashMap::new();
396        invalid_config.insert(
397            "host".to_string(),
398            ConfigValue::String("localhost".to_string()),
399        );
400        invalid_config.insert("port".to_string(), ConfigValue::Integer(70000)); // Invalid port
401        invalid_config.insert(
402            "username".to_string(),
403            ConfigValue::String("user".to_string()),
404        );
405        invalid_config.insert(
406            "password".to_string(),
407            ConfigValue::String("pass".to_string()),
408        );
409        invalid_config.insert(
410            "database".to_string(),
411            ConfigValue::String("mydb".to_string()),
412        );
413        let invalid_config = ConfigValue::Object(invalid_config);
414
415        assert!(validator
416            .validate_path("database", &invalid_config)
417            .is_err());
418    }
419
420    #[test]
421    fn test_feature_flags_schema() {
422        let mut validator = SchemaValidator::new();
423        validator = validator
424            .add_schema_from_json("feature_flags", schemas::feature_flags())
425            .unwrap();
426
427        // Valid feature flags
428        let mut config = HashMap::new();
429        config.insert("new_ui".to_string(), ConfigValue::Bool(true));
430        config.insert("beta_feature".to_string(), ConfigValue::Bool(false));
431        let valid_config = ConfigValue::Object(config);
432
433        assert!(validator
434            .validate_path("feature_flags", &valid_config)
435            .is_ok());
436
437        // Invalid feature flags (non-boolean value)
438        let mut invalid_config = HashMap::new();
439        invalid_config.insert(
440            "new_ui".to_string(),
441            ConfigValue::String("true".to_string()),
442        );
443        let invalid_config = ConfigValue::Object(invalid_config);
444
445        assert!(validator
446            .validate_path("feature_flags", &invalid_config)
447            .is_err());
448    }
449
450    #[test]
451    fn test_get_value_at_path() {
452        let json = serde_json::json!({
453            "app": {
454                "database": {
455                    "host": "localhost",
456                    "port": 5432
457                }
458            }
459        });
460
461        assert_eq!(
462            get_value_at_path(&json, "app.database.host"),
463            Some(serde_json::json!("localhost"))
464        );
465
466        assert_eq!(
467            get_value_at_path(&json, "app.database.port"),
468            Some(serde_json::json!(5432))
469        );
470
471        assert_eq!(get_value_at_path(&json, "app.nonexistent"), None);
472    }
473}