sqlserver_mcp_catalog/validation/
validator.rs1use std::collections::HashMap;
23use std::sync::{Mutex, OnceLock};
24
25use serde::Deserialize;
26use serde_json::Value;
27
28use crate::core::errors::McpifyError;
29
30#[derive(Debug, Deserialize)]
31struct OperationSchemas {
32 #[serde(rename = "inputSchema")]
33 input_schema: Value,
34 #[serde(rename = "outputSchema")]
35 output_schema: Value,
36}
37
38fn schemas_zst_for(api_version: &str) -> Option<&'static [u8]> {
40 match api_version {
41 "2025" => Some(include_bytes!("generated_schemas.json.zst")),
42 "2022" => Some(include_bytes!("generated_schemas_v2022.json.zst")),
43 "2019" => Some(include_bytes!("generated_schemas_v2019.json.zst")),
44 "2017" => Some(include_bytes!("generated_schemas_v2017.json.zst")),
45 _ => None,
46 }
47}
48fn schemas_by_operation(api_version: &str) -> &'static HashMap<String, OperationSchemas> {
51 static SCHEMAS: OnceLock<Mutex<HashMap<String, &'static HashMap<String, OperationSchemas>>>> =
52 OnceLock::new();
53 static EMPTY: OnceLock<HashMap<String, OperationSchemas>> = OnceLock::new();
54 let empty = EMPTY.get_or_init(HashMap::new);
55
56 let cache = SCHEMAS.get_or_init(|| Mutex::new(HashMap::new()));
57 let mut cache = cache.lock().unwrap();
58 if let Some(schemas) = cache.get(api_version) {
59 return schemas;
60 }
61
62 let Some(compressed) = schemas_zst_for(api_version) else {
63 return empty;
64 };
65 let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(compressed)
66 .ok()
67 .and_then(|json| serde_json::from_slice(&json).ok())
68 .unwrap_or_default();
69 let leaked: &'static HashMap<String, OperationSchemas> = Box::leak(Box::new(parsed));
70 cache.insert(api_version.to_string(), leaked);
71 leaked
72}
73
74pub fn resolved_schemas_for(
83 api_version: &str,
84 operation_id: &str,
85) -> Option<(&'static Value, &'static Value)> {
86 schemas_by_operation(api_version)
87 .get(operation_id)
88 .map(|schemas| (&schemas.input_schema, &schemas.output_schema))
89}
90
91fn validate_against(
92 cache: &Mutex<HashMap<String, jsonschema::Validator>>,
93 operation_id: &str,
94 schema: &Value,
95 data: &Value,
96) -> Result<(), Vec<String>> {
97 let mut cache = cache.lock().unwrap();
98 let validator = cache.entry(operation_id.to_string()).or_insert_with(|| {
99 jsonschema::validator_for(schema).unwrap_or_else(|_| {
100 jsonschema::validator_for(&Value::Object(Default::default())).unwrap()
101 })
102 });
103
104 if validator.is_valid(data) {
105 return Ok(());
106 }
107 let errors = validator
108 .iter_errors(data)
109 .map(|error| error.to_string())
110 .collect::<Vec<_>>();
111 Err(errors)
112}
113
114pub fn validate_input(
117 api_version: &str,
118 operation_id: &str,
119 data: &Value,
120) -> Result<(), McpifyError> {
121 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
122 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
123
124 let empty = Value::Object(Default::default());
125 let schema = schemas_by_operation(api_version)
126 .get(operation_id)
127 .map(|schemas| &schemas.input_schema)
128 .unwrap_or(&empty);
129
130 let cache_key = format!("{api_version} {operation_id}");
131 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
132 message: format!("invalid input for '{operation_id}'"),
133 details: Some(serde_json::json!(errors)),
134 })
135}
136
137pub fn validate_output(
142 api_version: &str,
143 operation_id: &str,
144 data: &Value,
145) -> Result<(), McpifyError> {
146 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
147 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
148
149 let empty = Value::Object(Default::default());
150 let schema = schemas_by_operation(api_version)
151 .get(operation_id)
152 .map(|schemas| &schemas.output_schema)
153 .unwrap_or(&empty);
154
155 let cache_key = format!("{api_version} {operation_id}");
156 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
157 message: format!("unexpected response shape for '{operation_id}'"),
158 details: Some(serde_json::json!(errors)),
159 })
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
172 fn an_unknown_operation_id_falls_back_to_an_always_valid_schema() {
173 assert!(
174 validate_input(
175 "2025",
176 "__unknown_operation__",
177 &serde_json::json!({"anything": true})
178 )
179 .is_ok()
180 );
181 assert!(validate_output("2025", "__unknown_operation__", &serde_json::json!(42)).is_ok());
182 }
183
184 #[test]
185 fn an_unknown_api_version_also_falls_back_to_an_always_valid_schema() {
186 assert!(
187 validate_input(
188 "__unknown_version__",
189 "__unknown_operation__",
190 &serde_json::json!({"anything": true})
191 )
192 .is_ok()
193 );
194 }
195
196 #[test]
197 fn validate_against_reports_every_schema_violation() {
198 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
199 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
200 let schema = serde_json::json!({
201 "type": "object",
202 "required": ["name"],
203 "properties": { "name": { "type": "string" } },
204 });
205
206 let ok = validate_against(
207 cache,
208 "test_op_ok",
209 &schema,
210 &serde_json::json!({"name": "widget"}),
211 );
212 assert!(ok.is_ok());
213
214 let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
215 assert!(err.is_err());
216 }
217}