Skip to main content

mockforge_bench/conformance/
request_validator.rs

1//! Request validation against OpenAPI spec.
2//!
3//! Validates that conformance test requests (especially from HAR custom checks)
4//! conform to the OpenAPI specification: correct paths, required parameters,
5//! valid request body schemas, and matching content types.
6
7use 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/// A single request validation violation
17#[derive(Debug, Serialize)]
18pub struct RequestViolation {
19    /// Check name from the custom YAML
20    pub check_name: String,
21    /// Request method
22    pub method: String,
23    /// Request path
24    pub path: String,
25    /// Type of violation
26    pub violation_type: String,
27    /// Human-readable description
28    pub message: String,
29}
30
31/// Validate custom conformance checks against an OpenAPI spec.
32///
33/// Returns a list of violations (empty if all checks are valid).
34pub 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    // Build a map of spec paths -> operations for matching
43    let spec_ops = build_spec_operation_map(spec);
44
45    for check in &config.custom_checks {
46        // Strip query string from path for matching
47        let check_path = check.path.split('?').next().unwrap_or(&check.path);
48
49        // Try to match the check's path to a spec operation
50        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        // Check if the method is defined for this path
68        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        // Validate request body for POST/PUT/PATCH
103        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        // Check required parameters
116        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
132/// Collected spec operations indexed by path
133type SpecOperationMap = HashMap<String, Vec<String>>; // path -> [methods]
134
135fn 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
167/// Try to match a concrete path (e.g., "/users/123") to a spec path template
168/// (e.g., "/users/{id}"). Handles base_path stripping.
169fn find_matching_spec_path(
170    check_path: &str,
171    spec_ops: &SpecOperationMap,
172    base_path: Option<&str>,
173) -> Option<String> {
174    // Try exact match first
175    if spec_ops.contains_key(check_path) {
176        return Some(check_path.to_string());
177    }
178
179    // Try with base_path prepended
180    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    // Try template matching (e.g., /users/123 matches /users/{id})
188    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
204/// Check if a concrete path matches a path template with {param} segments
205fn 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/// Validate request body against the spec's requestBody schema
220#[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            // Spec doesn't define a requestBody — body is optional
234            return;
235        }
236    };
237
238    // Resolve $ref if needed
239    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    // Check if body is required but missing
251    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 body is provided, validate against schema
263    if let Some(body_str) = body {
264        // Find JSON content type
265        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                // Resolve schema $ref
272                let schema_json = match resolve_schema_to_json(schema_ref, spec) {
273                    Some(s) => s,
274                    None => return,
275                };
276
277                // Parse body as JSON and validate against schema
278                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                                // Schema itself is invalid — skip validation
298                            }
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/// Validate required parameters from the spec
317#[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    // Collect all parameters (path-level + operation-level)
330    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                // Check if query param is in the path's query string
349                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                // Path params are always required — but they're embedded in the URL
368                // so we can't easily validate them here (they're already resolved)
369                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(&param_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
390/// Resolve a parameter reference
391fn 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
407/// Resolve a schema reference to a serde_json::Value for validation
408fn 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
425/// Run request validation and write results to a file.
426/// Called from the conformance execution path.
427pub 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}