1use 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
10pub struct SchemaValidator {
12 schemas: HashMap<String, JSONSchema>,
13}
14
15impl SchemaValidator {
16 pub fn new() -> Self {
18 Self {
19 schemas: HashMap::new(),
20 }
21 }
22
23 pub fn add_schema<T>(mut self, path: &str) -> Self
25 where
26 T: serde::Serialize + for<'de> serde::Deserialize<'de>,
27 {
28 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 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 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 pub fn validate(&self, config: &ConfigValue) -> Result<()> {
60 let json_value = config_value_to_json(config)?;
62
63 let mut validation_errors = Vec::new();
64
65 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 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 pub fn schema_paths(&self) -> Vec<String> {
106 self.schemas.keys().cloned().collect()
107 }
108
109 pub fn has_schema(&self, path: &str) -> bool {
111 self.schemas.contains_key(path)
112 }
113
114 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
126fn 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
155fn 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
176fn generate_schema_for_type<T>() -> Result<JsonValue>
178where
179 T: serde::Serialize + for<'de> serde::Deserialize<'de>,
180{
181 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
195pub mod schemas {
197 use super::*;
198
199 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 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 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 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 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 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 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 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 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_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 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 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}