1use crate::error::Result;
8use crate::spec_parser::SpecParser;
9use openapiv3::{OpenAPI, ReferenceOr};
10use serde::Serialize;
11use std::collections::HashMap;
12use std::path::Path;
13
14use super::custom::CustomConformanceConfig;
15
16#[derive(Debug, Serialize)]
18pub struct RequestViolation {
19 pub check_name: String,
21 pub method: String,
23 pub path: String,
25 pub violation_type: String,
27 pub message: String,
29}
30
31pub fn validate_custom_checks(
35 spec: &OpenAPI,
36 custom_checks_file: &Path,
37 base_path: Option<&str>,
38) -> Result<Vec<RequestViolation>> {
39 let config = CustomConformanceConfig::from_file(custom_checks_file)?;
40 let mut violations = Vec::new();
41
42 let spec_ops = build_spec_operation_map(spec);
44
45 for check in &config.custom_checks {
46 let check_path = check.path.split('?').next().unwrap_or(&check.path);
48
49 let spec_path = match find_matching_spec_path(check_path, &spec_ops, base_path) {
51 Some(p) => p,
52 None => {
53 violations.push(RequestViolation {
54 check_name: check.name.clone(),
55 method: check.method.clone(),
56 path: check.path.clone(),
57 violation_type: "unknown_path".to_string(),
58 message: format!(
59 "Path '{}' not found in OpenAPI spec (checked with base_path={:?})",
60 check_path, base_path
61 ),
62 });
63 continue;
64 }
65 };
66
67 let path_item = match spec.paths.paths.get(&spec_path) {
69 Some(ReferenceOr::Item(item)) => item,
70 _ => continue,
71 };
72
73 let method_lower = check.method.to_lowercase();
74 let operation = match method_lower.as_str() {
75 "get" => path_item.get.as_ref(),
76 "post" => path_item.post.as_ref(),
77 "put" => path_item.put.as_ref(),
78 "delete" => path_item.delete.as_ref(),
79 "patch" => path_item.patch.as_ref(),
80 "head" => path_item.head.as_ref(),
81 "options" => path_item.options.as_ref(),
82 _ => None,
83 };
84
85 let operation = match operation {
86 Some(op) => op,
87 None => {
88 violations.push(RequestViolation {
89 check_name: check.name.clone(),
90 method: check.method.clone(),
91 path: check.path.clone(),
92 violation_type: "method_not_allowed".to_string(),
93 message: format!(
94 "Method '{}' not defined for path '{}' in the spec",
95 check.method, spec_path
96 ),
97 });
98 continue;
99 }
100 };
101
102 if matches!(method_lower.as_str(), "post" | "put" | "patch") {
104 validate_request_body(
105 &check.name,
106 &check.method,
107 &check.path,
108 check.body.as_deref(),
109 operation,
110 spec,
111 &mut violations,
112 );
113 }
114
115 validate_parameters(
117 &check.name,
118 &check.method,
119 &check.path,
120 check_path,
121 &check.headers,
122 operation,
123 path_item,
124 spec,
125 &mut violations,
126 );
127 }
128
129 Ok(violations)
130}
131
132type SpecOperationMap = HashMap<String, Vec<String>>; fn build_spec_operation_map(spec: &OpenAPI) -> SpecOperationMap {
136 let mut map = HashMap::new();
137 for (path, item_ref) in &spec.paths.paths {
138 if let ReferenceOr::Item(item) = item_ref {
139 let mut methods = Vec::new();
140 if item.get.is_some() {
141 methods.push("GET".to_string());
142 }
143 if item.post.is_some() {
144 methods.push("POST".to_string());
145 }
146 if item.put.is_some() {
147 methods.push("PUT".to_string());
148 }
149 if item.delete.is_some() {
150 methods.push("DELETE".to_string());
151 }
152 if item.patch.is_some() {
153 methods.push("PATCH".to_string());
154 }
155 if item.head.is_some() {
156 methods.push("HEAD".to_string());
157 }
158 if item.options.is_some() {
159 methods.push("OPTIONS".to_string());
160 }
161 map.insert(path.clone(), methods);
162 }
163 }
164 map
165}
166
167fn find_matching_spec_path(
170 check_path: &str,
171 spec_ops: &SpecOperationMap,
172 base_path: Option<&str>,
173) -> Option<String> {
174 if spec_ops.contains_key(check_path) {
176 return Some(check_path.to_string());
177 }
178
179 if let Some(bp) = base_path {
181 let with_base = format!("{}{}", bp.trim_end_matches('/'), check_path);
182 if spec_ops.contains_key(&with_base) {
183 return Some(with_base);
184 }
185 }
186
187 for spec_path in spec_ops.keys() {
189 if path_matches_template(check_path, spec_path)
190 || base_path
191 .map(|bp| {
192 let with_base = format!("{}{}", bp.trim_end_matches('/'), check_path);
193 path_matches_template(&with_base, spec_path)
194 })
195 .unwrap_or(false)
196 {
197 return Some(spec_path.clone());
198 }
199 }
200
201 None
202}
203
204fn path_matches_template(concrete: &str, template: &str) -> bool {
206 let concrete_parts: Vec<&str> = concrete.split('/').collect();
207 let template_parts: Vec<&str> = template.split('/').collect();
208
209 if concrete_parts.len() != template_parts.len() {
210 return false;
211 }
212
213 concrete_parts
214 .iter()
215 .zip(template_parts.iter())
216 .all(|(c, t)| t.starts_with('{') && t.ends_with('}') || c == t)
217}
218
219#[allow(clippy::too_many_arguments)]
221fn validate_request_body(
222 check_name: &str,
223 method: &str,
224 path: &str,
225 body: Option<&str>,
226 operation: &openapiv3::Operation,
227 spec: &OpenAPI,
228 violations: &mut Vec<RequestViolation>,
229) {
230 let request_body_ref = match &operation.request_body {
231 Some(rb) => rb,
232 None => {
233 return;
235 }
236 };
237
238 let request_body = match request_body_ref {
240 ReferenceOr::Item(rb) => rb,
241 ReferenceOr::Reference { reference } => {
242 let name = reference.strip_prefix("#/components/requestBodies/").unwrap_or(reference);
243 match spec.components.as_ref().and_then(|c| c.request_bodies.get(name)) {
244 Some(ReferenceOr::Item(rb)) => rb,
245 _ => return,
246 }
247 }
248 };
249
250 if request_body.required && body.is_none() {
252 violations.push(RequestViolation {
253 check_name: check_name.to_string(),
254 method: method.to_string(),
255 path: path.to_string(),
256 violation_type: "missing_required_body".to_string(),
257 message: "Spec requires a request body but none is provided in the check".to_string(),
258 });
259 return;
260 }
261
262 if let Some(body_str) = body {
264 let json_media = request_body.content.get("application/json").or_else(|| {
266 request_body.content.iter().find(|(k, _)| k.contains("json")).map(|(_, v)| v)
267 });
268
269 if let Some(media) = json_media {
270 if let Some(schema_ref) = &media.schema {
271 let root_schema = match schema_ref {
286 ReferenceOr::Item(s) => s.clone(),
287 ReferenceOr::Reference { reference } => {
288 let name =
289 reference.strip_prefix("#/components/schemas/").unwrap_or(reference);
290 match spec.components.as_ref().and_then(|c| c.schemas.get(name)) {
291 Some(ReferenceOr::Item(s)) => s.clone(),
292 _ => return,
293 }
294 }
295 };
296
297 match serde_json::from_str::<serde_json::Value>(body_str) {
299 Ok(body_value) => {
300 match mockforge_openapi::schema_ref_resolver::build_validator(
301 &root_schema,
302 spec,
303 ) {
304 Ok(validator) => {
305 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
306 for err in errors.iter().take(5) {
307 violations.push(RequestViolation {
308 check_name: check_name.to_string(),
309 method: method.to_string(),
310 path: path.to_string(),
311 violation_type: "body_schema_violation".to_string(),
312 message: format!(
313 "Request body schema violation at {}: {}",
314 err.instance_path, err
315 ),
316 });
317 }
318 }
319 Err(_) => {
320 }
322 }
323 }
324 Err(e) => {
325 violations.push(RequestViolation {
326 check_name: check_name.to_string(),
327 method: method.to_string(),
328 path: path.to_string(),
329 violation_type: "body_not_json".to_string(),
330 message: format!("Request body is not valid JSON: {}", e),
331 });
332 }
333 }
334 }
335 }
336 }
337}
338
339#[allow(clippy::too_many_arguments)]
341fn validate_parameters(
342 check_name: &str,
343 method: &str,
344 path: &str,
345 check_path_no_query: &str,
346 check_headers: &HashMap<String, String>,
347 operation: &openapiv3::Operation,
348 path_item: &openapiv3::PathItem,
349 spec: &OpenAPI,
350 violations: &mut Vec<RequestViolation>,
351) {
352 let mut all_params = Vec::new();
354 for p in &path_item.parameters {
355 if let Some(param) = resolve_parameter(p, spec) {
356 all_params.push(param);
357 }
358 }
359 for p in &operation.parameters {
360 if let Some(param) = resolve_parameter(p, spec) {
361 all_params.push(param);
362 }
363 }
364
365 for param in &all_params {
366 let param_data = match param {
367 openapiv3::Parameter::Query { parameter_data, .. } => {
368 if !parameter_data.required {
369 continue;
370 }
371 let has_param = check_path_no_query != path
373 && path.contains(&format!("{}=", parameter_data.name));
374 if !has_param {
375 violations.push(RequestViolation {
376 check_name: check_name.to_string(),
377 method: method.to_string(),
378 path: path.to_string(),
379 violation_type: "missing_required_query_param".to_string(),
380 message: format!(
381 "Required query parameter '{}' is missing",
382 parameter_data.name
383 ),
384 });
385 }
386 continue;
387 }
388 openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
389 openapiv3::Parameter::Path { parameter_data, .. } => {
390 let _ = parameter_data;
393 continue;
394 }
395 openapiv3::Parameter::Cookie { .. } => continue,
396 };
397
398 if param_data.required {
399 let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(¶m_data.name));
400 if !has_header {
401 violations.push(RequestViolation {
402 check_name: check_name.to_string(),
403 method: method.to_string(),
404 path: path.to_string(),
405 violation_type: "missing_required_header".to_string(),
406 message: format!("Required header parameter '{}' is missing", param_data.name),
407 });
408 }
409 }
410 }
411}
412
413fn resolve_parameter<'a>(
415 param_ref: &'a ReferenceOr<openapiv3::Parameter>,
416 spec: &'a OpenAPI,
417) -> Option<&'a openapiv3::Parameter> {
418 match param_ref {
419 ReferenceOr::Item(p) => Some(p),
420 ReferenceOr::Reference { reference } => {
421 let name = reference.strip_prefix("#/components/parameters/")?;
422 match spec.components.as_ref()?.parameters.get(name)? {
423 ReferenceOr::Item(p) => Some(p),
424 _ => None,
425 }
426 }
427 }
428}
429
430#[allow(dead_code)]
434fn resolve_schema_to_json(
435 schema_ref: &ReferenceOr<openapiv3::Schema>,
436 spec: &OpenAPI,
437) -> Option<serde_json::Value> {
438 let schema = match schema_ref {
439 ReferenceOr::Item(s) => s,
440 ReferenceOr::Reference { reference } => {
441 let name = reference.strip_prefix("#/components/schemas/")?;
442 match spec.components.as_ref()?.schemas.get(name)? {
443 ReferenceOr::Item(s) => s,
444 _ => return None,
445 }
446 }
447 };
448 serde_json::to_value(schema).ok()
449}
450
451pub async fn run_request_validation(
454 spec_files: &[std::path::PathBuf],
455 custom_checks_file: Option<&Path>,
456 base_path: Option<&str>,
457 output_dir: &Path,
458) -> Result<usize> {
459 let custom_file = match custom_checks_file {
460 Some(f) => f,
461 None => return Ok(0),
462 };
463
464 if spec_files.is_empty() {
465 return Ok(0);
466 }
467
468 let parser = SpecParser::from_file(&spec_files[0]).await?;
469 let spec = parser.spec();
470
471 let violations = validate_custom_checks(spec, custom_file, base_path)?;
472
473 if !violations.is_empty() {
474 let path = output_dir.join("conformance-request-violations.json");
475 if let Ok(json) = serde_json::to_string_pretty(&violations) {
476 let _ = std::fs::write(&path, json);
477 tracing::info!(
478 "Found {} request validation violation(s), saved to {}",
479 violations.len(),
480 path.display()
481 );
482 }
483 }
484
485 Ok(violations.len())
486}
487
488pub async fn validate_emitted_requests(
510 spec_files: &[std::path::PathBuf],
511 output_dir: &Path,
512) -> Result<usize> {
513 validate_emitted_requests_with_base_path(spec_files, output_dir, None).await
514}
515
516pub async fn validate_emitted_requests_with_base_path(
535 spec_files: &[std::path::PathBuf],
536 output_dir: &Path,
537 base_path: Option<&str>,
538) -> Result<usize> {
539 use serde_json::Value;
540
541 if spec_files.is_empty() {
542 return Ok(0);
543 }
544 let requests_path = output_dir.join("conformance-requests.json");
545 let self_test_jsonl_path = output_dir.join("conformance-self-test-requests.jsonl");
546
547 let entries: Vec<Value> = if requests_path.exists() {
557 let bytes = match std::fs::read(&requests_path) {
558 Ok(b) => b,
559 Err(_) => return Ok(0),
560 };
561 match serde_json::from_slice(&bytes) {
562 Ok(v) => v,
563 Err(_) => return Ok(0),
564 }
565 } else if self_test_jsonl_path.exists() {
566 let bytes = match std::fs::read(&self_test_jsonl_path) {
567 Ok(b) => b,
568 Err(_) => return Ok(0),
569 };
570 let text = String::from_utf8_lossy(&bytes);
571 text.lines()
572 .filter(|l| !l.is_empty())
573 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
574 .map(|case| {
575 let label = case.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
576 let method = case.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
577 let url = case.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
578 let body = case.get("request_body").cloned().unwrap_or(Value::Null);
579 let mut req = serde_json::Map::new();
580 req.insert("method".into(), Value::String(method));
581 req.insert("url".into(), Value::String(url));
582 req.insert(
583 "body".into(),
584 match body {
585 Value::String(s) => Value::String(s),
586 Value::Null => Value::String(String::new()),
587 other => other,
588 },
589 );
590 let mut out = serde_json::Map::new();
591 out.insert("check".into(), Value::String(label));
592 out.insert("request".into(), Value::Object(req));
593 Value::Object(out)
594 })
595 .collect()
596 } else {
597 return Ok(0);
598 };
599 if entries.is_empty() {
600 return Ok(0);
601 }
602
603 let parser = SpecParser::from_file(&spec_files[0]).await?;
604 let spec = parser.spec();
605 let spec_ops = build_spec_operation_map(spec);
606
607 let mut emitted_violations: Vec<RequestViolation> = Vec::new();
608
609 for entry in &entries {
610 let check = entry.get("check").and_then(|v| v.as_str()).unwrap_or("").to_string();
611 let req = match entry.get("request") {
612 Some(r) => r,
613 None => continue,
614 };
615 let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
616 let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
617 if method.is_empty() || url.is_empty() {
618 continue;
619 }
620 let (path_only, query_string) = match url.find('?') {
621 Some(i) => (url[..i].to_string(), url[i + 1..].to_string()),
622 None => (url.clone(), String::new()),
623 };
624 let path_only = if let Some(stripped) = path_only.split_once("://") {
627 match stripped.1.find('/') {
628 Some(i) => stripped.1[i..].to_string(),
629 None => "/".to_string(),
630 }
631 } else {
632 path_only
633 };
634
635 let lookup_path = if let Some(bp) = base_path {
639 let bp = bp.trim_end_matches('/');
640 if !bp.is_empty() && path_only.starts_with(bp) {
641 let stripped = &path_only[bp.len()..];
642 if stripped.is_empty() {
643 "/".to_string()
644 } else {
645 stripped.to_string()
646 }
647 } else {
648 path_only.clone()
649 }
650 } else {
651 path_only.clone()
652 };
653
654 let spec_path = match find_matching_spec_path(&lookup_path, &spec_ops, None) {
655 Some(p) => p,
656 None => continue,
657 };
658 let path_item = match spec.paths.paths.get(&spec_path) {
659 Some(ReferenceOr::Item(item)) => item,
660 _ => continue,
661 };
662 let operation = match method.as_str() {
663 "GET" => path_item.get.as_ref(),
664 "POST" => path_item.post.as_ref(),
665 "PUT" => path_item.put.as_ref(),
666 "DELETE" => path_item.delete.as_ref(),
667 "PATCH" => path_item.patch.as_ref(),
668 "HEAD" => path_item.head.as_ref(),
669 "OPTIONS" => path_item.options.as_ref(),
670 _ => None,
671 };
672 let Some(operation) = operation else { continue };
673
674 let sent_query: HashMap<String, String> = query_string
679 .split('&')
680 .filter_map(|kv| {
681 let mut it = kv.splitn(2, '=');
682 let k = it.next()?.to_string();
683 let v = it.next().unwrap_or("").to_string();
684 if k.is_empty() {
685 None
686 } else {
687 Some((k, v))
688 }
689 })
690 .collect();
691
692 let path_params: HashMap<String, String> = {
698 let mut out = HashMap::new();
699 let concrete_parts: Vec<&str> = lookup_path.split('/').collect();
700 let template_parts: Vec<&str> = spec_path.split('/').collect();
701 if concrete_parts.len() == template_parts.len() {
702 for (c, t) in concrete_parts.iter().zip(template_parts.iter()) {
703 if t.starts_with('{') && t.ends_with('}') {
704 let name = &t[1..t.len() - 1];
705 out.insert(name.to_string(), (*c).to_string());
706 }
707 }
708 }
709 out
710 };
711
712 let mut all_params: Vec<&openapiv3::Parameter> = Vec::new();
713 for p in &path_item.parameters {
714 if let Some(param) = resolve_parameter(p, spec) {
715 all_params.push(param);
716 }
717 }
718 for p in &operation.parameters {
719 if let Some(param) = resolve_parameter(p, spec) {
720 all_params.push(param);
721 }
722 }
723
724 for param in &all_params {
725 let (loc_str, name, schema_ref) = match param {
726 openapiv3::Parameter::Query { parameter_data, .. } => {
727 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
728 else {
729 continue;
730 };
731 let Some(v) = sent_query.get(¶meter_data.name) else {
732 continue;
733 };
734 ("query", ¶meter_data.name, (sref, v.clone()))
735 }
736 openapiv3::Parameter::Path { parameter_data, .. } => {
737 let openapiv3::ParameterSchemaOrContent::Schema(sref) = ¶meter_data.format
738 else {
739 continue;
740 };
741 let Some(v) = path_params.get(¶meter_data.name) else {
742 continue;
743 };
744 ("path", ¶meter_data.name, (sref, v.clone()))
745 }
746 _ => continue,
747 };
748 let (schema_ref, value) = schema_ref;
749 let Some(schema) = schema_ref.as_item() else {
750 continue;
751 };
752 if let Some(msg) = check_value_against_schema(&value, schema) {
753 emitted_violations.push(RequestViolation {
754 check_name: check.clone(),
755 method: method.clone(),
756 path: url.clone(),
757 violation_type: format!("{}_value_mismatch", loc_str),
758 message: format!("{}.{}: {}", loc_str, name, msg),
759 });
760 }
761 }
762
763 let body_str = req.get("body").and_then(|v| v.as_str()).unwrap_or("");
771 if !body_str.is_empty() {
772 if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(body_str) {
773 if let Some(req_body) = operation.request_body.as_ref().and_then(|r| r.as_item()) {
774 for (ct, media) in &req_body.content {
775 if !ct.contains("json") {
776 continue;
777 }
778 let Some(schema_ref) = &media.schema else {
779 continue;
780 };
781 let Some(schema) = schema_ref.as_item() else {
782 continue;
783 };
784 check_body_against_schema(
785 &check,
786 &method,
787 &url,
788 &body_json,
789 schema,
790 &mut emitted_violations,
791 );
792 }
793 }
794 }
795 }
796 }
797
798 let dst = output_dir.join("conformance-request-violations.json");
800 let mut all: Vec<Value> = if dst.exists() {
801 match std::fs::read(&dst) {
802 Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
803 Err(_) => Vec::new(),
804 }
805 } else {
806 Vec::new()
807 };
808 for v in &emitted_violations {
809 if let Ok(val) = serde_json::to_value(v) {
810 all.push(val);
811 }
812 }
813 if !all.is_empty() {
814 if let Ok(json) = serde_json::to_string_pretty(&all) {
815 let _ = std::fs::write(&dst, json);
816 tracing::info!(
817 "validate-requests: wrote {} entries to {} ({} from emitted requests)",
818 all.len(),
819 dst.display(),
820 emitted_violations.len()
821 );
822 }
823 }
824
825 let grouped_dst = output_dir.join("conformance-request-violations-by-request.json");
834 let grouped_value = group_violations_by_request(&all);
835 if let Ok(json) = serde_json::to_string_pretty(&grouped_value) {
836 let _ = std::fs::write(&grouped_dst, json);
837 }
838
839 let drill_dst = output_dir.join("conformance-request-violations-by-probe.json");
850 let drill_value = group_violations_by_probe(&all);
851 if let Ok(json) = serde_json::to_string_pretty(&drill_value) {
852 let _ = std::fs::write(&drill_dst, json);
853 }
854 Ok(emitted_violations.len())
855}
856
857fn group_violations_by_probe(flat: &[serde_json::Value]) -> serde_json::Value {
864 use serde_json::{Map, Value};
865
866 let mut by_probe_order: Vec<(String, String, String)> = Vec::new();
867 let mut by_probe: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
868 std::collections::HashMap::new();
869
870 for v in flat {
871 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
872 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
873 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
874 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
875 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
876 let key = (check, method, path);
877 if !by_probe.contains_key(&key) {
878 by_probe_order.push(key.clone());
879 }
880 by_probe.entry(key).or_default().push((vt, msg));
881 }
882
883 by_probe_order.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
885
886 let mut rows: Vec<Value> = Vec::with_capacity(by_probe_order.len());
887 for key in &by_probe_order {
888 let (check, method, path) = key;
889 let entries = by_probe.get(key).cloned().unwrap_or_default();
890 let mut row = Map::new();
891 row.insert("check_name".into(), Value::String(check.clone()));
892 row.insert("method".into(), Value::String(method.clone()));
893 row.insert("path".into(), Value::String(path.clone()));
894 row.insert(
895 "violation_count".into(),
896 Value::Number(serde_json::Number::from(entries.len())),
897 );
898 for (i, (vt, msg)) in entries.iter().enumerate() {
899 let mut entry = Map::new();
900 entry.insert("violation_type".into(), Value::String(vt.clone()));
901 entry.insert("message".into(), Value::String(msg.clone()));
902 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
903 }
904 rows.push(Value::Object(row));
905 }
906 Value::Array(rows)
907}
908
909fn group_violations_by_request(flat: &[serde_json::Value]) -> serde_json::Value {
921 use serde_json::{Map, Value};
922
923 let mut by_check_order: Vec<(String, String, String)> = Vec::new();
927 let mut by_check: std::collections::HashMap<(String, String, String), Vec<(String, String)>> =
928 std::collections::HashMap::new();
929 for v in flat {
930 let check = v.get("check_name").and_then(|x| x.as_str()).unwrap_or("").to_string();
931 let method = v.get("method").and_then(|x| x.as_str()).unwrap_or("").to_string();
932 let path = v.get("path").and_then(|x| x.as_str()).unwrap_or("").to_string();
933 let vt = v.get("violation_type").and_then(|x| x.as_str()).unwrap_or("").to_string();
934 let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("").to_string();
935 let key = (check, method, path);
936 if !by_check.contains_key(&key) {
937 by_check_order.push(key.clone());
938 }
939 by_check.entry(key).or_default().push((vt, msg));
940 }
941
942 let mut collapsed_order: Vec<(String, String, Vec<(String, String)>)> = Vec::new();
947 let mut collapsed: std::collections::HashMap<
948 (String, String, Vec<(String, String)>),
949 Vec<String>,
950 > = std::collections::HashMap::new();
951 for key in &by_check_order {
952 let (check, method, path) = key;
953 let entries = by_check.get(key).cloned().unwrap_or_default();
954 let mut signature = entries.clone();
955 signature.sort();
956 let combo = (method.clone(), path.clone(), signature.clone());
957 if !collapsed.contains_key(&combo) {
958 collapsed_order.push(combo.clone());
959 }
960 collapsed.entry(combo).or_default().push(check.clone());
961 }
962
963 let mut rows: Vec<Value> = Vec::with_capacity(collapsed_order.len());
964 for combo in &collapsed_order {
965 let (method, path, signature) = combo;
966 let checks = collapsed.get(combo).cloned().unwrap_or_default();
967 let mut row = Map::new();
968 row.insert(
969 "checks".into(),
970 Value::Array(checks.iter().map(|s| Value::String(s.clone())).collect()),
971 );
972 let dominant_prefix: &str = signature
979 .first()
980 .map(|(vt, _)| {
981 if vt.starts_with("query_") {
982 "param:query"
983 } else if vt.starts_with("body_") {
984 "body:"
985 } else if vt.starts_with("path_") {
986 "param:path"
987 } else if vt.starts_with("header_") {
988 "param:header"
989 } else {
990 ""
991 }
992 })
993 .unwrap_or("");
994 let best_check = if !dominant_prefix.is_empty() {
995 checks
996 .iter()
997 .find(|c| c.starts_with(dominant_prefix))
998 .cloned()
999 .or_else(|| checks.first().cloned())
1000 .unwrap_or_default()
1001 } else {
1002 checks.first().cloned().unwrap_or_default()
1003 };
1004 row.insert("check_name".into(), Value::String(best_check));
1005 row.insert("method".into(), Value::String(method.clone()));
1006 row.insert("path".into(), Value::String(path.clone()));
1007 row.insert(
1008 "violation_count".into(),
1009 Value::Number(serde_json::Number::from(signature.len())),
1010 );
1011 for (i, (vt, msg)) in signature.iter().enumerate() {
1012 let mut entry = Map::new();
1013 entry.insert("violation_type".into(), Value::String(vt.clone()));
1014 entry.insert("message".into(), Value::String(msg.clone()));
1015 row.insert(format!("violation_{}", i + 1), Value::Object(entry));
1016 }
1017 rows.push(Value::Object(row));
1018 }
1019 Value::Array(rows)
1020}
1021
1022fn check_body_against_schema(
1030 check: &str,
1031 method: &str,
1032 url: &str,
1033 body: &serde_json::Value,
1034 schema: &openapiv3::Schema,
1035 violations: &mut Vec<RequestViolation>,
1036) {
1037 use openapiv3::{SchemaKind, Type};
1038
1039 let SchemaKind::Type(Type::Object(obj_type)) = &schema.schema_kind else {
1040 return;
1041 };
1042 let Some(body_obj) = body.as_object() else {
1043 return;
1044 };
1045
1046 for required in &obj_type.required {
1047 if !body_obj.contains_key(required) {
1048 violations.push(RequestViolation {
1049 check_name: check.to_string(),
1050 method: method.to_string(),
1051 path: url.to_string(),
1052 violation_type: "body_missing_required".to_string(),
1053 message: format!("body.{}: required field missing", required),
1054 });
1055 }
1056 }
1057
1058 for (prop_name, prop_ref) in &obj_type.properties {
1059 let Some(value) = body_obj.get(prop_name) else {
1060 continue;
1061 };
1062 let Some(prop_schema) = prop_ref.as_item() else {
1063 continue;
1064 };
1065 if let Some(value_str) = value.as_str() {
1066 if let Some(msg) = check_value_against_schema(value_str, prop_schema) {
1067 violations.push(RequestViolation {
1068 check_name: check.to_string(),
1069 method: method.to_string(),
1070 path: url.to_string(),
1071 violation_type: "body_value_mismatch".to_string(),
1072 message: format!("body.{}: {}", prop_name, msg),
1073 });
1074 }
1075 }
1076 }
1077}
1078
1079fn check_value_against_schema(value: &str, schema: &openapiv3::Schema) -> Option<String> {
1086 use openapiv3::{SchemaKind, Type};
1087
1088 let SchemaKind::Type(t) = &schema.schema_kind else {
1089 return None;
1090 };
1091 match t {
1092 Type::String(s) => {
1093 if !s.enumeration.is_empty() {
1094 let allowed: Vec<String> = s.enumeration.iter().filter_map(|e| e.clone()).collect();
1095 if !allowed.iter().any(|a| a == value) {
1096 let quoted: Vec<String> =
1097 allowed.iter().map(|a| format!("\"{}\"", a)).collect();
1098 return Some(format!(
1099 "value \"{}\" is not one of {}",
1100 value,
1101 quoted.join(" or ")
1102 ));
1103 }
1104 }
1105 None
1106 }
1107 Type::Integer(_) => {
1108 if value.parse::<i64>().is_err() {
1109 Some(format!("value \"{}\" is not of type \"integer\"", value))
1110 } else {
1111 None
1112 }
1113 }
1114 Type::Number(_) => {
1115 if value.parse::<f64>().is_err() {
1116 Some(format!("value \"{}\" is not of type \"number\"", value))
1117 } else {
1118 None
1119 }
1120 }
1121 Type::Boolean(_) => match value {
1122 "true" | "false" => None,
1123 _ => Some(format!("value \"{}\" is not of type \"boolean\"", value)),
1124 },
1125 _ => None,
1126 }
1127}