1use std::collections::HashMap;
51use std::sync::Arc;
52
53use jsonschema::Validator;
54use serde_json::Value;
55
56#[derive(Clone, Debug, PartialEq, Eq, Hash)]
62pub enum StatusPattern {
63 Exact(u16),
65 Family(u8),
67 Default,
69}
70
71impl StatusPattern {
72 pub fn matches(&self, status: u16) -> bool {
74 match self {
75 StatusPattern::Exact(s) => *s == status,
76 StatusPattern::Family(d) => (status / 100) as u8 == *d,
77 StatusPattern::Default => true,
78 }
79 }
80}
81
82#[derive(Clone)]
88pub struct ResponseValidator {
89 schemas: Arc<HashMap<ResponseKey, Arc<Validator>>>,
91 strict: bool,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Hash)]
98pub struct ResponseKey {
99 pub path: String,
100 pub method: String,
101 pub status: StatusPattern,
102 pub content_type: String,
103}
104
105#[derive(Clone, Debug)]
107pub enum ValidationResult {
108 Valid,
110 Invalid(Vec<ValidationError>),
112 NoSchema,
115}
116
117#[derive(Clone, Debug)]
119pub struct ValidationError {
120 pub path: String,
121 pub message: String,
122}
123
124#[derive(Clone, Debug)]
126pub struct ResponseToValidate {
127 pub path: String,
128 pub method: String,
129 pub status: u16,
130 pub content_type: Option<String>,
131 pub body: Option<Value>,
135}
136
137const SUPPORTED_CONTENT_TYPES: &[&str] = &[
139 "application/json",
140 "application/problem+json",
141 "application/vnd.api+json",
142 "text/plain",
143 "application/xml",
144];
145
146impl ResponseValidator {
147 pub fn empty() -> Self {
150 Self {
151 schemas: Arc::new(HashMap::new()),
152 strict: false,
153 }
154 }
155
156 pub fn from_schemas(schemas: HashMap<ResponseKey, Value>) -> Result<Self, String> {
158 Self::from_schemas_strict(schemas, false)
159 }
160
161 pub fn from_schemas_strict(
164 schemas: HashMap<ResponseKey, Value>,
165 strict: bool,
166 ) -> Result<Self, String> {
167 let mut compiled = HashMap::new();
168 for (key, schema) in schemas {
169 let validator =
170 Validator::new(&schema).map_err(|e| format!("compile schema for {key:?}: {e}"))?;
171 compiled.insert(key, Arc::new(validator));
172 }
173 Ok(Self {
174 schemas: Arc::new(compiled),
175 strict,
176 })
177 }
178
179 pub fn from_openapi(doc: &Value) -> Result<Self, String> {
185 Self::from_openapi_strict(doc, false)
186 }
187
188 pub fn from_openapi_strict(doc: &Value, strict: bool) -> Result<Self, String> {
191 let mut schemas = HashMap::new();
192
193 let paths = doc
194 .get("paths")
195 .and_then(|v| v.as_object())
196 .ok_or_else(|| "OpenAPI doc missing 'paths'".to_string())?;
197
198 for (path, path_item) in paths {
199 let path_item = path_item
200 .as_object()
201 .ok_or_else(|| format!("path '{path}' is not an object"))?;
202
203 for (method, operation) in path_item {
204 if !is_http_method(method) {
206 continue;
207 }
208
209 let responses = operation
210 .get("responses")
211 .and_then(|v| v.as_object())
212 .ok_or_else(|| format!("operation {method} {path} missing 'responses'"))?;
213
214 for (status_str, response) in responses {
215 let status = parse_status(status_str, path, method)?;
216
217 for (content_type, schema) in extract_response_schemas(response) {
219 let key = ResponseKey {
220 path: path.clone(),
221 method: method.to_uppercase(),
222 status: status.clone(),
223 content_type,
224 };
225 schemas.insert(key, schema);
226 }
227 }
228 }
229 }
230
231 Self::from_schemas_strict(schemas, strict)
232 }
233
234 pub fn validate(&self, response: &ResponseToValidate) -> ValidationResult {
240 let method = response.method.to_uppercase();
241 let ct = normalize_content_type(response.content_type.as_deref());
242
243 let candidates = lookup_candidates(&response.path, &method, response.status, ct.as_deref());
245
246 let validator = candidates
247 .iter()
248 .find_map(|key| self.schemas.get(key))
249 .cloned();
250
251 let validator = match validator {
252 Some(v) => v,
253 None => {
254 if self.strict {
255 return ValidationResult::Invalid(vec![ValidationError {
256 path: response.path.clone(),
257 message: format!(
258 "no schema for {} {} status {} content_type {:?} (strict mode)",
259 method, response.path, response.status, ct
260 ),
261 }]);
262 }
263 return ValidationResult::NoSchema;
264 }
265 };
266
267 let body = match &response.body {
272 Some(b) => b,
273 None => return ValidationResult::Valid,
274 };
275
276 let errors: Vec<_> = validator.iter_errors(body).collect();
277 if errors.is_empty() {
278 ValidationResult::Valid
279 } else {
280 let errors: Vec<ValidationError> = errors
281 .into_iter()
282 .map(|e| ValidationError {
283 path: e.instance_path().to_string(),
284 message: e.to_string(),
285 })
286 .collect();
287 ValidationResult::Invalid(errors)
288 }
289 }
290
291 pub fn schema_count(&self) -> usize {
293 self.schemas.len()
294 }
295
296 pub fn has_schema(&self, path: &str, method: &str, status: u16) -> bool {
298 let method = method.to_uppercase();
299 let candidates = lookup_candidates(path, &method, status, None);
300 candidates.iter().any(|key| self.schemas.contains_key(key))
301 }
302
303 pub fn is_strict(&self) -> bool {
305 self.strict
306 }
307}
308
309fn lookup_candidates(
315 path: &str,
316 method: &str,
317 status: u16,
318 content_type: Option<&str>,
319) -> Vec<ResponseKey> {
320 let family = (status / 100) as u8;
321 let patterns = vec![
322 StatusPattern::Exact(status),
323 StatusPattern::Family(family),
324 StatusPattern::Default,
325 ];
326
327 let ct_candidates: Vec<String> = match content_type {
332 Some(ct) => {
333 let mut v = vec![ct.to_string()];
334 for sct in SUPPORTED_CONTENT_TYPES {
335 if *sct != ct {
336 v.push(sct.to_string());
337 }
338 }
339 v.push(String::new());
340 v
341 }
342 None => {
343 let mut v: Vec<String> = SUPPORTED_CONTENT_TYPES
344 .iter()
345 .map(|s| s.to_string())
346 .collect();
347 v.push(String::new());
348 v
349 }
350 };
351
352 let mut keys = Vec::new();
353 for pattern in &patterns {
354 for ct in &ct_candidates {
355 keys.push(ResponseKey {
356 path: path.to_string(),
357 method: method.to_string(),
358 status: pattern.clone(),
359 content_type: ct.clone(),
360 });
361 }
362 }
363 keys
364}
365
366fn is_http_method(s: &str) -> bool {
368 matches!(
369 s.to_lowercase().as_str(),
370 "get" | "post" | "put" | "delete" | "patch" | "head" | "options"
371 )
372}
373
374fn parse_status(status_str: &str, path: &str, method: &str) -> Result<StatusPattern, String> {
379 if status_str == "default" {
380 return Ok(StatusPattern::Default);
381 }
382
383 if status_str.ends_with("XX") && status_str.len() == 3 {
385 let digit = status_str
386 .chars()
387 .next()
388 .and_then(|c| c.to_digit(10))
389 .ok_or_else(|| format!("invalid wildcard status '{status_str}' in {method} {path}"))?;
390 return Ok(StatusPattern::Family(digit as u8));
391 }
392
393 let code: u16 = status_str
394 .parse()
395 .map_err(|_| format!("invalid status code '{status_str}' in {method} {path}"))?;
396 Ok(StatusPattern::Exact(code))
397}
398
399fn extract_response_schemas(response: &Value) -> Vec<(String, Value)> {
408 let content = match response.get("content").and_then(|c| c.as_object()) {
409 Some(c) => c,
410 None => return Vec::new(),
411 };
412
413 let mut result = Vec::new();
414 for ct in SUPPORTED_CONTENT_TYPES {
415 if let Some(media_type) = content.get(*ct) {
416 if let Some(schema) = media_type.get("schema").cloned() {
417 result.push((ct.to_string(), schema));
418 }
419 }
420 }
421 result
422}
423
424fn normalize_content_type(ct: Option<&str>) -> Option<String> {
427 let ct = ct?;
428 let base = ct.split(';').next()?.trim();
429 if base.is_empty() {
430 None
431 } else {
432 Some(base.to_string())
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn status_pattern_exact_matches() {
442 assert!(StatusPattern::Exact(200).matches(200));
443 assert!(!StatusPattern::Exact(200).matches(201));
444 }
445
446 #[test]
447 fn status_pattern_family_matches() {
448 assert!(StatusPattern::Family(2).matches(200));
449 assert!(StatusPattern::Family(2).matches(204));
450 assert!(!StatusPattern::Family(2).matches(404));
451 }
452
453 #[test]
454 fn status_pattern_default_matches_all() {
455 assert!(StatusPattern::Default.matches(200));
456 assert!(StatusPattern::Default.matches(404));
457 assert!(StatusPattern::Default.matches(500));
458 }
459
460 #[test]
461 fn parse_status_exact() {
462 assert_eq!(
463 parse_status("200", "/x", "get").unwrap(),
464 StatusPattern::Exact(200)
465 );
466 }
467
468 #[test]
469 fn parse_status_family() {
470 assert_eq!(
471 parse_status("2XX", "/x", "get").unwrap(),
472 StatusPattern::Family(2)
473 );
474 assert_eq!(
475 parse_status("4XX", "/x", "get").unwrap(),
476 StatusPattern::Family(4)
477 );
478 }
479
480 #[test]
481 fn parse_status_default() {
482 assert_eq!(
483 parse_status("default", "/x", "get").unwrap(),
484 StatusPattern::Default
485 );
486 }
487
488 #[test]
489 fn parse_status_invalid() {
490 assert!(parse_status("abc", "/x", "get").is_err());
491 }
492
493 #[test]
494 fn normalize_content_type_strips_params() {
495 assert_eq!(
496 normalize_content_type(Some("application/json; charset=utf-8")),
497 Some("application/json".to_string())
498 );
499 assert_eq!(
500 normalize_content_type(Some("application/json")),
501 Some("application/json".to_string())
502 );
503 assert_eq!(normalize_content_type(None), None);
504 }
505
506 #[test]
507 fn extract_response_schemas_multiple_types() {
508 let response = serde_json::json!({
509 "content": {
510 "application/json": {
511 "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}
512 },
513 "application/problem+json": {
514 "schema": {"type": "object", "properties": {"error": {"type": "string"}}}
515 },
516 "text/plain": {
517 "schema": {"type": "string"}
518 }
519 }
520 });
521 let schemas = extract_response_schemas(&response);
522 assert_eq!(schemas.len(), 3);
523 let cts: Vec<&str> = schemas.iter().map(|(c, _)| c.as_str()).collect();
524 assert!(cts.contains(&"application/json"));
525 assert!(cts.contains(&"application/problem+json"));
526 assert!(cts.contains(&"text/plain"));
527 }
528}