dwara_core/openapi/
mod.rs

1//! OpenAPI response validation (DW-070, #258).
2//!
3//! This module validates upstream responses against the OpenAPI spec's
4//! response schemas. When a response violates the spec, it is flagged
5//! as drift and optionally returned as a 502 to the client.
6//!
7//! ## Design (section 5-API Mgmt, section 6-API Craft)
8//!
9//! This is the runtime half of section 5-API Mgmt's "OpenAPI-driven
10//! config" item, whose import/request-validation/mock half already
11//! shipped as DW-047 (M2). This is also the concrete implementation of
12//! Tier 3's "Contract testing mode" (section 6-API Craft): verify live
13//! traffic conforms to the spec DW-047 imported, and flag drift.
14//!
15//! ## Scope
16//!
17//! This covers per-response schema-conformance drift, not route-set
18//! drift (whether the live route set has grown out of sync with the
19//! spec's endpoint list).
20//!
21//! ## Wildcard status codes (#258)
22//!
23//! OpenAPI specs commonly use `default`, `2XX`, `4XX`, and `5XX` as
24//! wildcard response keys. The validator resolves a concrete response
25//! status (e.g. 200) by trying, in order: exact match (200), family
26//! match (2XX), then `default`. The first match wins.
27//!
28//! ## Content types (#258)
29//!
30//! In addition to `application/json`, the validator extracts schemas
31//! for `application/problem+json`, `application/vnd.api+json`,
32//! `text/plain`, and `application/xml`. JSON-variant content types
33//! are parsed as JSON for schema validation; `text/plain` bodies are
34//! wrapped as JSON strings; `application/xml` schemas are registered
35//! (status-code validation) but body schema validation is skipped
36//! (JSON Schema cannot validate XML).
37//!
38//! ## Strict mode (#258)
39//!
40//! When `strict` is true, a response with no matching schema (unknown
41//! status or content type) returns `Invalid` instead of `NoSchema`.
42//! This enforces that every response must be covered by the spec.
43//!
44//! ## Feature gate
45//!
46//! The `openapi_validation` cargo feature must be enabled. Without it,
47//! the module is not compiled and config fields that reference OpenAPI
48//! response validation are accepted but inert.
49
50use std::collections::HashMap;
51use std::sync::Arc;
52
53use jsonschema::Validator;
54use serde_json::Value;
55
56/// A status pattern extracted from an OpenAPI response key.
57///
58/// OpenAPI response keys can be exact (`"200"`), family wildcards
59/// (`"2XX"`, `"4XX"`), or `default`. The validator resolves a concrete
60/// response status by trying exact, then family, then default.
61#[derive(Clone, Debug, PartialEq, Eq, Hash)]
62pub enum StatusPattern {
63    /// An exact status code, e.g. 200, 404.
64    Exact(u16),
65    /// A family wildcard, e.g. 2XX (stored as the leading digit, 2).
66    Family(u8),
67    /// The OpenAPI `default` response key.
68    Default,
69}
70
71impl StatusPattern {
72    /// Check if this pattern matches a concrete status code.
73    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/// A compiled OpenAPI response validator.
83///
84/// Holds compiled JSON Schema validators for each (path, method,
85/// status_pattern, content_type) tuple. Created at config publish
86/// time from the OpenAPI spec's response schemas.
87#[derive(Clone)]
88pub struct ResponseValidator {
89    /// Map: (path, method, status_pattern, content_type) -> validator.
90    schemas: Arc<HashMap<ResponseKey, Arc<Validator>>>,
91    /// When true, unknown statuses/content types are Invalid, not NoSchema.
92    strict: bool,
93}
94
95/// The key for a response schema: (path, method, status_pattern,
96/// content_type).
97#[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/// The result of validating a response.
106#[derive(Clone, Debug)]
107pub enum ValidationResult {
108    /// The response conforms to the spec.
109    Valid,
110    /// The response violates the spec. Contains the validation errors.
111    Invalid(Vec<ValidationError>),
112    /// No schema found for this (path, method, status, content_type).
113    /// The response is not validated (the spec does not cover it).
114    NoSchema,
115}
116
117/// A single validation error.
118#[derive(Clone, Debug)]
119pub struct ValidationError {
120    pub path: String,
121    pub message: String,
122}
123
124/// A response to validate.
125#[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    /// The response body as a JSON value. If the body is not JSON,
132    /// this is None and validation skips schema checks (only status
133    /// code is checked).
134    pub body: Option<Value>,
135}
136
137/// Content types the validator extracts schemas for.
138const 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    /// Create a new empty validator (no schemas — all responses get
148    /// `NoSchema`).
149    pub fn empty() -> Self {
150        Self {
151            schemas: Arc::new(HashMap::new()),
152            strict: false,
153        }
154    }
155
156    /// Create a new validator from a map of compiled schemas.
157    pub fn from_schemas(schemas: HashMap<ResponseKey, Value>) -> Result<Self, String> {
158        Self::from_schemas_strict(schemas, false)
159    }
160
161    /// Create a new validator from a map of compiled schemas with a
162    /// strict mode flag.
163    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    /// Create a new validator from an OpenAPI document.
180    ///
181    /// The document should be a parsed OpenAPI 3.x JSON value. This
182    /// method extracts the response schemas for each (path, method,
183    /// status_pattern, content_type) tuple and compiles them.
184    pub fn from_openapi(doc: &Value) -> Result<Self, String> {
185        Self::from_openapi_strict(doc, false)
186    }
187
188    /// Create a new validator from an OpenAPI document with a strict
189    /// mode flag.
190    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                // Skip non-method fields (parameters, summary, etc.).
205                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                    // Extract schemas for all supported content types.
218                    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    /// Validate a response against its schema.
235    ///
236    /// The lookup tries exact status, then family (2XX/4XX/5XX), then
237    /// default. Within each status pattern, it tries the exact content
238    /// type, then any content type. The first match wins.
239    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        // Build the list of candidate keys in specificity order.
244        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        // If the response has no body, we can only check the status
268        // code (which matched by virtue of finding a schema). If the
269        // schema requires a body, this will be caught by the
270        // validator.
271        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    /// The number of compiled schemas.
292    pub fn schema_count(&self) -> usize {
293        self.schemas.len()
294    }
295
296    /// Whether a schema exists for the given (path, method, status).
297    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    /// Whether strict mode is enabled.
304    pub fn is_strict(&self) -> bool {
305        self.strict
306    }
307}
308
309/// Build a list of candidate `ResponseKey`s for a validate lookup,
310/// ordered from most specific to least specific.
311///
312/// Order: exact status + exact CT, exact status + any CT, family +
313/// exact CT, family + any CT, default + exact CT, default + any CT.
314fn 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    // When a concrete content type is given, try it then all
328    // supported types (for charset-variant matching) then the
329    // empty-string wildcard. When no content type is given, try
330    // all supported types then the empty-string wildcard.
331    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
366/// Check if a string is an HTTP method.
367fn 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
374/// Parse a status code from an OpenAPI response key.
375///
376/// OpenAPI uses status codes like "200", "404", "2XX", "default".
377/// Returns the corresponding `StatusPattern`.
378fn 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    // Handle wildcards like "2XX", "4XX".
384    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
399/// Extract response schemas for all supported content types.
400///
401/// Returns a list of (content_type, schema) pairs. JSON-variant
402/// content types (`application/json`, `application/problem+json`,
403/// `application/vnd.api+json`) are returned as-is. `text/plain`
404/// schemas are returned (the body is wrapped as a JSON string at
405/// validate time). `application/xml` schemas are returned but body
406/// validation is skipped (JSON Schema cannot validate XML).
407fn 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
424/// Normalize a content type for matching by stripping parameters
425/// (e.g. `application/json; charset=utf-8` -> `application/json`).
426fn 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}