github_mcp/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 "gh-2026-03-10" => Some(include_bytes!("generated_schemas.json.zst")),
42 "ghec-2026-03-10" => Some(include_bytes!(
43 "generated_schemas_vghec-2026-03-10.json.zst"
44 )),
45 "ghes-3.21" => Some(include_bytes!("generated_schemas_vghes-3.21.json.zst")),
46 "ghes-3.20" => Some(include_bytes!("generated_schemas_vghes-3.20.json.zst")),
47 "ghes-3.19" => Some(include_bytes!("generated_schemas_vghes-3.19.json.zst")),
48 "ghes-2.22" => Some(include_bytes!("generated_schemas_vghes-2.22.json.zst")),
49 _ => None,
50 }
51}
52fn schemas_by_operation(api_version: &str) -> &'static HashMap<String, OperationSchemas> {
55 static SCHEMAS: OnceLock<Mutex<HashMap<String, &'static HashMap<String, OperationSchemas>>>> =
56 OnceLock::new();
57 static EMPTY: OnceLock<HashMap<String, OperationSchemas>> = OnceLock::new();
58 let empty = EMPTY.get_or_init(HashMap::new);
59
60 let cache = SCHEMAS.get_or_init(|| Mutex::new(HashMap::new()));
61 let mut cache = cache.lock().unwrap();
62 if let Some(schemas) = cache.get(api_version) {
63 return schemas;
64 }
65
66 let Some(compressed) = schemas_zst_for(api_version) else {
67 return empty;
68 };
69 let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(compressed)
70 .ok()
71 .and_then(|json| serde_json::from_slice(&json).ok())
72 .unwrap_or_default();
73 let leaked: &'static HashMap<String, OperationSchemas> = Box::leak(Box::new(parsed));
74 cache.insert(api_version.to_string(), leaked);
75 leaked
76}
77
78fn validate_against(
79 cache: &Mutex<HashMap<String, jsonschema::Validator>>,
80 operation_id: &str,
81 schema: &Value,
82 data: &Value,
83) -> Result<(), Vec<String>> {
84 let mut cache = cache.lock().unwrap();
85 let validator = cache.entry(operation_id.to_string()).or_insert_with(|| {
86 jsonschema::validator_for(schema).unwrap_or_else(|_| {
87 jsonschema::validator_for(&Value::Object(Default::default())).unwrap()
88 })
89 });
90
91 if validator.is_valid(data) {
92 return Ok(());
93 }
94 let errors = validator
95 .iter_errors(data)
96 .map(|error| error.to_string())
97 .collect::<Vec<_>>();
98 Err(errors)
99}
100
101pub fn validate_input(
104 api_version: &str,
105 operation_id: &str,
106 data: &Value,
107) -> Result<(), McpifyError> {
108 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
109 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
110
111 let empty = Value::Object(Default::default());
112 let schema = schemas_by_operation(api_version)
113 .get(operation_id)
114 .map(|schemas| &schemas.input_schema)
115 .unwrap_or(&empty);
116
117 let cache_key = format!("{api_version} {operation_id}");
118 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
119 message: format!("invalid input for '{operation_id}'"),
120 details: Some(serde_json::json!(errors)),
121 })
122}
123
124pub fn validate_output(
129 api_version: &str,
130 operation_id: &str,
131 data: &Value,
132) -> Result<(), McpifyError> {
133 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
134 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
135
136 let empty = Value::Object(Default::default());
137 let schema = schemas_by_operation(api_version)
138 .get(operation_id)
139 .map(|schemas| &schemas.output_schema)
140 .unwrap_or(&empty);
141
142 let cache_key = format!("{api_version} {operation_id}");
143 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
144 message: format!("unexpected response shape for '{operation_id}'"),
145 details: Some(serde_json::json!(errors)),
146 })
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
159 fn an_unknown_operation_id_falls_back_to_an_always_valid_schema() {
160 assert!(
161 validate_input(
162 "gh-2026-03-10",
163 "__unknown_operation__",
164 &serde_json::json!({"anything": true})
165 )
166 .is_ok()
167 );
168 assert!(
169 validate_output(
170 "gh-2026-03-10",
171 "__unknown_operation__",
172 &serde_json::json!(42)
173 )
174 .is_ok()
175 );
176 }
177
178 #[test]
179 fn an_unknown_api_version_also_falls_back_to_an_always_valid_schema() {
180 assert!(
181 validate_input(
182 "__unknown_version__",
183 "__unknown_operation__",
184 &serde_json::json!({"anything": true})
185 )
186 .is_ok()
187 );
188 }
189
190 #[test]
191 fn validate_against_reports_every_schema_violation() {
192 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
193 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
194 let schema = serde_json::json!({
195 "type": "object",
196 "required": ["name"],
197 "properties": { "name": { "type": "string" } },
198 });
199
200 let ok = validate_against(
201 cache,
202 "test_op_ok",
203 &schema,
204 &serde_json::json!({"name": "widget"}),
205 );
206 assert!(ok.is_ok());
207
208 let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
209 assert!(err.is_err());
210 }
211}