mockforge_bench/conformance/
request_validator.rs1use 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 schema_json = match resolve_schema_to_json(schema_ref, spec) {
273 Some(s) => s,
274 None => return,
275 };
276
277 match serde_json::from_str::<serde_json::Value>(body_str) {
279 Ok(body_value) => {
280 match jsonschema::validator_for(&schema_json) {
281 Ok(validator) => {
282 let errors: Vec<_> = validator.iter_errors(&body_value).collect();
283 for err in errors.iter().take(5) {
284 violations.push(RequestViolation {
285 check_name: check_name.to_string(),
286 method: method.to_string(),
287 path: path.to_string(),
288 violation_type: "body_schema_violation".to_string(),
289 message: format!(
290 "Request body schema violation at {}: {}",
291 err.instance_path, err
292 ),
293 });
294 }
295 }
296 Err(_) => {
297 }
299 }
300 }
301 Err(e) => {
302 violations.push(RequestViolation {
303 check_name: check_name.to_string(),
304 method: method.to_string(),
305 path: path.to_string(),
306 violation_type: "body_not_json".to_string(),
307 message: format!("Request body is not valid JSON: {}", e),
308 });
309 }
310 }
311 }
312 }
313 }
314}
315
316#[allow(clippy::too_many_arguments)]
318fn validate_parameters(
319 check_name: &str,
320 method: &str,
321 path: &str,
322 check_path_no_query: &str,
323 check_headers: &std::collections::HashMap<String, String>,
324 operation: &openapiv3::Operation,
325 path_item: &openapiv3::PathItem,
326 spec: &OpenAPI,
327 violations: &mut Vec<RequestViolation>,
328) {
329 let mut all_params = Vec::new();
331 for p in &path_item.parameters {
332 if let Some(param) = resolve_parameter(p, spec) {
333 all_params.push(param);
334 }
335 }
336 for p in &operation.parameters {
337 if let Some(param) = resolve_parameter(p, spec) {
338 all_params.push(param);
339 }
340 }
341
342 for param in &all_params {
343 let param_data = match param {
344 openapiv3::Parameter::Query { parameter_data, .. } => {
345 if !parameter_data.required {
346 continue;
347 }
348 let has_param = check_path_no_query != path
350 && path.contains(&format!("{}=", parameter_data.name));
351 if !has_param {
352 violations.push(RequestViolation {
353 check_name: check_name.to_string(),
354 method: method.to_string(),
355 path: path.to_string(),
356 violation_type: "missing_required_query_param".to_string(),
357 message: format!(
358 "Required query parameter '{}' is missing",
359 parameter_data.name
360 ),
361 });
362 }
363 continue;
364 }
365 openapiv3::Parameter::Header { parameter_data, .. } => parameter_data,
366 openapiv3::Parameter::Path { parameter_data, .. } => {
367 let _ = parameter_data;
370 continue;
371 }
372 openapiv3::Parameter::Cookie { .. } => continue,
373 };
374
375 if param_data.required {
376 let has_header = check_headers.keys().any(|k| k.eq_ignore_ascii_case(¶m_data.name));
377 if !has_header {
378 violations.push(RequestViolation {
379 check_name: check_name.to_string(),
380 method: method.to_string(),
381 path: path.to_string(),
382 violation_type: "missing_required_header".to_string(),
383 message: format!("Required header parameter '{}' is missing", param_data.name),
384 });
385 }
386 }
387 }
388}
389
390fn resolve_parameter<'a>(
392 param_ref: &'a ReferenceOr<openapiv3::Parameter>,
393 spec: &'a OpenAPI,
394) -> Option<&'a openapiv3::Parameter> {
395 match param_ref {
396 ReferenceOr::Item(p) => Some(p),
397 ReferenceOr::Reference { reference } => {
398 let name = reference.strip_prefix("#/components/parameters/")?;
399 match spec.components.as_ref()?.parameters.get(name)? {
400 ReferenceOr::Item(p) => Some(p),
401 _ => None,
402 }
403 }
404 }
405}
406
407fn resolve_schema_to_json(
409 schema_ref: &ReferenceOr<openapiv3::Schema>,
410 spec: &OpenAPI,
411) -> Option<serde_json::Value> {
412 let schema = match schema_ref {
413 ReferenceOr::Item(s) => s,
414 ReferenceOr::Reference { reference } => {
415 let name = reference.strip_prefix("#/components/schemas/")?;
416 match spec.components.as_ref()?.schemas.get(name)? {
417 ReferenceOr::Item(s) => s,
418 _ => return None,
419 }
420 }
421 };
422 serde_json::to_value(schema).ok()
423}
424
425pub async fn run_request_validation(
428 spec_files: &[std::path::PathBuf],
429 custom_checks_file: Option<&Path>,
430 base_path: Option<&str>,
431 output_dir: &Path,
432) -> Result<usize> {
433 let custom_file = match custom_checks_file {
434 Some(f) => f,
435 None => return Ok(0),
436 };
437
438 if spec_files.is_empty() {
439 return Ok(0);
440 }
441
442 let parser = SpecParser::from_file(&spec_files[0]).await?;
443 let spec = parser.spec();
444
445 let violations = validate_custom_checks(spec, custom_file, base_path)?;
446
447 if !violations.is_empty() {
448 let path = output_dir.join("conformance-request-violations.json");
449 if let Ok(json) = serde_json::to_string_pretty(&violations) {
450 let _ = std::fs::write(&path, json);
451 tracing::info!(
452 "Found {} request validation violation(s), saved to {}",
453 violations.len(),
454 path.display()
455 );
456 }
457 }
458
459 Ok(violations.len())
460}