Skip to main content

mockforge_proxy/
conformance.rs

1//! Passive conformance validation for proxied traffic (#864).
2//!
3//! When `mockforge proxy` fronts a real upstream with a spec loaded
4//! (`--validate-conformance` / `MOCKFORGE_PROXY_VALIDATE_CONFORMANCE`),
5//! every request and response crossing the proxy is validated against
6//! the spec and the findings land in the SAME
7//! [`mockforge_foundation::conformance_violations`] buffer the bench,
8//! TUI Conformance tab and admin endpoints already read — no new sink.
9//!
10//! Observational by default: traffic always forwards untouched. With
11//! `--validate-conformance-strict`, request violations reject with the
12//! spec's configured status instead of forwarding.
13
14mod encoding;
15mod parameters;
16
17use parameters::{cookie_parameters, header_parameters, query_parameters};
18mod response_schema;
19
20use response_schema::declared_json_schema;
21
22use encoding::decode_body;
23use std::collections::HashMap;
24
25use axum::http::HeaderMap;
26use serde_json::Map as JsonMap;
27use serde_json::Value;
28
29use mockforge_openapi::openapi_routes::{OpenApiRouteRegistry, ValidationMode, ValidationOptions};
30use mockforge_openapi::schema_ref_resolver::merge_components_into;
31use mockforge_openapi::spec::OpenApiSpec;
32
33/// A passive conformance tap over one loaded spec.
34pub struct ConformanceTap {
35    // NOTE: no Debug derive — OpenApiRouteRegistry isn't Debug; ProxyServer
36    // implements Debug manually around it.
37    registry: OpenApiRouteRegistry,
38    strict: bool,
39}
40
41impl ConformanceTap {
42    /// Build a tap from a raw OpenAPI 3.x document (JSON or YAML already
43    /// parsed to a Value).
44    pub fn from_spec_value(spec: Value, strict: bool) -> Result<Self, String> {
45        let openapi = OpenApiSpec::from_json(spec).map_err(|e| format!("invalid spec: {e}"))?;
46        let routes = OpenApiRouteRegistry::new(openapi.clone());
47        // Always detect violations here; this tap decides whether they reject
48        // traffic. Generic mock-server environment overrides must not disable it.
49        let options = ValidationOptions {
50            overrides: routes
51                .routes()
52                .iter()
53                .map(|route| {
54                    (
55                        format!("{} {}", route.method.to_uppercase(), route.path),
56                        ValidationMode::Enforce,
57                    )
58                })
59                .collect(),
60            ..Default::default()
61        };
62        Ok(Self {
63            registry: OpenApiRouteRegistry::new_with_options(openapi, options),
64            strict,
65        })
66    }
67
68    pub fn is_strict(&self) -> bool {
69        self.strict
70    }
71
72    /// Match a concrete request path against spec templates.
73    /// Returns `(template, extracted path params)`.
74    pub fn match_template(
75        &self,
76        method: &str,
77        concrete_path: &str,
78    ) -> Option<(String, HashMap<String, String>)> {
79        // Cheap pre-filter on segment count before per-route matching.
80        self.registry.routes().iter().find_map(|route| {
81            if !route.method.eq_ignore_ascii_case(method) {
82                return None;
83            }
84            match_template(concrete_path, &route.path).map(|params| (route.path.clone(), params))
85        })
86    }
87
88    /// Resolve the matched route object for a concrete path.
89    fn matched_route(
90        &self,
91        method: &str,
92        concrete_path: &str,
93    ) -> Option<(String, &mockforge_openapi::route::OpenApiRoute)> {
94        let (template, _) = self.match_template(method, concrete_path)?;
95        let route = self.registry.get_route(&template, method)?;
96        Some((template, route))
97    }
98
99    /// Validate an inbound request against the matched route.
100    ///
101    /// Violations are recorded into the shared conformance buffer inside
102    /// `run_validation_with_recording_ex`. Returns `Some((status, payload))`
103    /// only in STRICT mode when validation fails, so the caller can reject
104    /// instead of forwarding.
105    pub async fn validate_request(
106        &self,
107        method: &str,
108        concrete_path: &str,
109        query: Option<&str>,
110        headers: &HeaderMap,
111        body: Option<&[u8]>,
112    ) -> Option<(u16, Value)> {
113        let Some((template, path_params)) = self.match_template(method, concrete_path) else {
114            return None; // not in spec — nothing to validate against
115        };
116
117        let query_map = query_parameters(query);
118        let route = self.matched_route(method, concrete_path).map(|(_, route)| route);
119        let header_values = header_parameters(headers, route);
120
121        // Path params feed the validator as their own bucket.
122        let path_param_map: JsonMap<String, Value> =
123            path_params.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
124
125        let decoded;
126        let body = if let Some(encoding) = headers.get(http::header::CONTENT_ENCODING) {
127            let result = match encoding.to_str() {
128                Ok(encoding) => decode_body(body.unwrap_or_default(), Some(encoding)).await,
129                Err(_) => Err("request content-encoding is invalid".to_string()),
130            };
131            match result {
132                Ok(bytes) => {
133                    decoded = bytes;
134                    Some(decoded.as_slice())
135                }
136                Err(error) => {
137                    let reason = format!("request body cannot be validated: {error}");
138                    mockforge_foundation::conformance_violations::record(
139                        mockforge_foundation::conformance_violations::ServerConformanceViolation {
140                            timestamp: chrono::Utc::now(),
141                            method: method.to_string(),
142                            path: concrete_path.to_string(),
143                            client_ip: "unknown".to_string(),
144                            status: 400,
145                            reason: reason.clone(),
146                            category: "request-body".to_string(),
147                            occurrences: 1,
148                            client_mockforge_version: None,
149                            client_sent_at: None,
150                            summary: String::new(),
151                            categories: vec!["request-body".to_string()],
152                        },
153                    );
154                    return self.strict.then(|| (400, serde_json::json!({"error": reason})));
155                }
156            }
157        } else {
158            body
159        };
160        let (body_json, body_present) = parse_body(body);
161        let json_content_type = headers
162            .get(http::header::CONTENT_TYPE)
163            .and_then(|value| value.to_str().ok())
164            .and_then(|value| value.split(';').next())
165            .map(|value| value.trim().to_ascii_lowercase())
166            .is_some_and(|value| value == "application/json" || value.ends_with("+json"));
167        if body_present && body_json.is_none() && json_content_type {
168            let reason = "request body is not valid JSON";
169            mockforge_foundation::conformance_violations::record(
170                mockforge_foundation::conformance_violations::ServerConformanceViolation {
171                    timestamp: chrono::Utc::now(),
172                    method: method.to_string(),
173                    path: concrete_path.to_string(),
174                    client_ip: "unknown".to_string(),
175                    status: 400,
176                    reason: reason.to_string(),
177                    category: "request-body".to_string(),
178                    occurrences: 1,
179                    client_mockforge_version: None,
180                    client_sent_at: None,
181                    summary: String::new(),
182                    categories: vec!["request-body".to_string()],
183                },
184            );
185            return self.strict.then(|| (400, serde_json::json!({"error": reason})));
186        }
187
188        let cookies = cookie_parameters(headers);
189
190        match self.registry.run_validation_with_recording_ex(
191            &template,
192            method,
193            &path_param_map,
194            &query_map,
195            &header_values,
196            &cookies,
197            body_json.as_ref(),
198            body_present,
199        ) {
200            Ok(()) => None,
201            Err((status, payload)) => self.strict.then_some((status, payload)),
202        }
203    }
204
205    /// Validate a response received from upstream against the matched
206    /// route's declared responses:
207    /// - undeclared status code (and no default) → `response-shape` violation;
208    /// - declared JSON schema → body validated via `validate_json_value`,
209    ///   errors collapsed into one `response-shape` violation.
210    ///
211    /// Always observational; never mutates the response.
212    pub async fn validate_response(
213        &self,
214        method: &str,
215        concrete_path: &str,
216        status: u16,
217        body: Option<&[u8]>,
218    ) {
219        self.check_response(method, concrete_path, status, body).await;
220    }
221
222    /// Record response violations and return whether the declared contract holds.
223    /// This permits the caller to enforce an explicitly requested strict policy.
224    pub async fn check_response(
225        &self,
226        method: &str,
227        concrete_path: &str,
228        status: u16,
229        body: Option<&[u8]>,
230    ) -> bool {
231        self.check_encoded_response(method, concrete_path, status, body, None).await
232    }
233
234    /// Validate a decoded copy while leaving the encoded upstream bytes untouched.
235    /// Decoded JSON is bounded to 16 MiB and at most four encoding layers.
236    pub async fn check_encoded_response(
237        &self,
238        method: &str,
239        concrete_path: &str,
240        status: u16,
241        body: Option<&[u8]>,
242        content_encoding: Option<&str>,
243    ) -> bool {
244        let mut headers = HeaderMap::new();
245        headers
246            .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/json"));
247        if let Some(encoding) = content_encoding {
248            let Ok(value) = http::HeaderValue::from_str(encoding) else {
249                return false;
250            };
251            headers.insert(http::header::CONTENT_ENCODING, value);
252        }
253        self.check_response_headers(method, concrete_path, status, body, &headers).await
254    }
255
256    /// Select the contract for the upstream media type and validate a body copy.
257    pub async fn check_response_headers(
258        &self,
259        method: &str,
260        concrete_path: &str,
261        status: u16,
262        body: Option<&[u8]>,
263        headers: &HeaderMap,
264    ) -> bool {
265        let Some((template, _)) = self.match_template(method, concrete_path) else {
266            return true;
267        };
268        let Some(route) = self.registry.get_route(&template, method) else {
269            return true;
270        };
271
272        // 1. Is this status even declared?
273        let responses = &route.operation.responses;
274        let status_declared = responses.responses.keys().any(|code| match code {
275            openapiv3::StatusCode::Code(c) => *c == status,
276            openapiv3::StatusCode::Range(class) => status / 100 == *class,
277        });
278        let has_default = responses.default.is_some();
279        if !status_declared && !has_default {
280            record_response_shape(
281                method,
282                concrete_path,
283                status,
284                &format!("response status {status} is not declared for {method} {template}"),
285            );
286            return false;
287        }
288
289        // HEAD and bodyless HTTP statuses carry no representation to validate.
290        if method.eq_ignore_ascii_case("HEAD") || status == 204 || status == 304 {
291            return true;
292        }
293        let content_type = headers.get(http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
294        let schema_value =
295            match declared_json_schema(responses, status, &self.registry.spec().spec, content_type)
296            {
297                Ok(Some(schema)) => schema,
298                Ok(None) => return true,
299                Err(error) => {
300                    record_response_shape(method, concrete_path, status, &error);
301                    return false;
302                }
303            };
304        let content_encoding =
305            headers.get(http::header::CONTENT_ENCODING).and_then(|v| v.to_str().ok());
306        let decoded = match decode_body(body.unwrap_or_default(), content_encoding).await {
307            Ok(decoded) => decoded,
308            Err(error) => {
309                record_response_shape(method, concrete_path, status, &error);
310                return false;
311            }
312        };
313        let Ok(body_json) = serde_json::from_slice::<Value>(&decoded) else {
314            record_response_shape(
315                method,
316                concrete_path,
317                status,
318                "response body is not valid JSON for the declared JSON schema",
319            );
320            return false;
321        };
322
323        let merged = merge_components_into(schema_value.clone(), &self.registry.spec().spec);
324        let result =
325            mockforge_openapi::openapi_routes::validation::validate_json_value(&body_json, &merged);
326        if !result.errors.is_empty() {
327            let detail =
328                result.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
329            record_response_shape(
330                method,
331                concrete_path,
332                status,
333                &format!("response body violates {method} {template} {status} schema: {detail}"),
334            );
335            return false;
336        }
337        true
338    }
339}
340
341fn record_response_shape(method: &str, path: &str, status: u16, reason: &str) {
342    use mockforge_foundation::conformance_violations::{self, ServerConformanceViolation};
343    conformance_violations::record(ServerConformanceViolation {
344        timestamp: chrono::Utc::now(),
345        method: method.to_string(),
346        path: path.to_string(),
347        client_ip: "unknown".to_string(),
348        status,
349        reason: reason.to_string(),
350        category: "response-shape".to_string(),
351        occurrences: 1,
352        client_mockforge_version: None,
353        client_sent_at: None,
354        summary: String::new(),
355        categories: vec!["response-shape".to_string()],
356    });
357}
358
359/// Segment-wise template matcher: `/users/{id}` vs concrete paths.
360/// Returns extracted `{placeholder}` values.
361fn match_template(concrete: &str, template: &str) -> Option<HashMap<String, String>> {
362    let mut params = HashMap::new();
363    let mut c_parts = concrete.split('/');
364    for t in template.split('/') {
365        let c = c_parts.next()?;
366        if t.starts_with('{') && t.ends_with('}') {
367            params.insert(t[1..t.len() - 1].to_string(), c.to_string());
368        } else if t != c {
369            return None;
370        }
371    }
372    if c_parts.next().is_some() {
373        return None; // concrete path longer than template
374    }
375    Some(params)
376}
377
378fn parse_body(body: Option<&[u8]>) -> (Option<Value>, bool) {
379    match body {
380        None => (None, false),
381        Some([]) => (None, false),
382        Some(b) => (serde_json::from_slice::<Value>(b).ok(), true),
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use mockforge_foundation::conformance_violations;
390
391    #[tokio::test]
392    async fn response_uses_actual_media_type_and_http_body_semantics() {
393        let spec = serde_json::json!({
394            "openapi":"3.0.0", "info":{"title":"Media", "version":"1"},
395            "paths":{"/media":{"get":{"responses":{
396                "200":{"description":"ok", "content":{
397                    "application/json":{"schema":{"type":"object", "required":["id"]}},
398                    "application/problem+json":{"schema":{"type":"object", "required":["title"]}},
399                    "application/xml":{"schema":{"type":"string"}}
400                }},
401                "204":{"description":"no content", "content":{"application/json":{"schema":{"type":"object"}}}},
402                "304":{"description":"not modified", "content":{"application/json":{"schema":{"type":"object"}}}}
403            }}, "head":{"responses":{"200":{"description":"headers", "content":{"application/json":{"schema":{"type":"object"}}}}}}}}
404        });
405        let tap = ConformanceTap::from_spec_value(spec, true).unwrap();
406        let mut headers = HeaderMap::new();
407        headers.insert(
408            http::header::CONTENT_TYPE,
409            http::HeaderValue::from_static("application/xml; charset=utf-8"),
410        );
411        assert!(
412            tap.check_response_headers("GET", "/media", 200, Some(b"<id>1</id>"), &headers)
413                .await
414        );
415        headers.insert(
416            http::header::CONTENT_TYPE,
417            http::HeaderValue::from_static("application/problem+json"),
418        );
419        assert!(
420            tap.check_response_headers(
421                "GET",
422                "/media",
423                200,
424                Some(br#"{"title":"Problem"}"#),
425                &headers
426            )
427            .await
428        );
429        assert!(
430            !tap.check_response_headers("GET", "/media", 200, Some(br#"{"id":1}"#), &headers)
431                .await
432        );
433        headers.insert(
434            http::header::CONTENT_TYPE,
435            http::HeaderValue::from_static("Application/JSON; charset=utf-8"),
436        );
437        assert!(
438            tap.check_response_headers("GET", "/media", 200, Some(br#"{"id":1}"#), &headers)
439                .await
440        );
441        headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("text/plain"));
442        assert!(
443            !tap.check_response_headers("GET", "/media", 200, Some(b"unexpected"), &headers)
444                .await
445        );
446        headers.clear();
447        assert!(
448            !tap.check_response_headers("GET", "/media", 200, Some(br#"{"id":1}"#), &headers)
449                .await
450        );
451        for status in [204, 304] {
452            assert!(tap.check_response_headers("GET", "/media", status, None, &headers).await);
453        }
454        assert!(tap.check_response_headers("HEAD", "/media", 200, None, &headers).await);
455        assert!(!tap.check_response_headers("GET", "/media", 418, None, &headers).await);
456    }
457
458    fn demo_spec() -> Value {
459        serde_json::json!({
460            "openapi": "3.0.0",
461            "info": { "title": "T", "version": "1" },
462            "paths": {
463                "/users/{id}": {
464                    "get": {
465                        "parameters": [
466                            { "name": "id", "in": "path", "required": true,
467                              "schema": { "type": "string" } },
468                            { "name": "X-Trace", "in": "header", "required": true,
469                              "schema": { "type": "string" } }
470                        ],
471                        "responses": {
472                            "200": { "description": "ok",
473                                     "content": { "application/json": {
474                                         "schema": { "type": "object",
475                                             "required": ["id"],
476                                             "properties": { "id": { "type": "string" } } } } } }
477                        }
478                    }
479                },
480                "/users": {
481                    "post": {
482                        "requestBody": {
483                            "required": true,
484                            "content": { "application/json": {
485                                "schema": { "type": "object",
486                                    "required": ["email"],
487                                    "properties": { "email": { "type": "string" } } } } }
488                        },
489                        "responses": { "200": { "description": "ok" } }
490                    }
491                }
492            }
493        })
494    }
495
496    async fn tap(strict: bool) -> ConformanceTap {
497        ConformanceTap::from_spec_value(demo_spec(), strict).expect("spec builds")
498    }
499
500    #[tokio::test]
501    async fn missing_required_header_records_query_side_violation() {
502        conformance_violations::clear();
503        let t = tap(false).await;
504
505        // X-Trace header required by spec but absent.
506        let rejected = t.validate_request("GET", "/users/abc", None, &HeaderMap::new(), None).await;
507        assert!(rejected.is_none(), "observational mode must not reject");
508
509        let snap = conformance_violations::snapshot();
510        assert!(
511            snap.iter().any(|v| v.path == "/users/{id}" && !v.reason.is_empty()),
512            "violation should land in the shared buffer, got {:?}",
513            snap
514        );
515    }
516
517    #[tokio::test]
518    async fn strict_mode_returns_rejection_payload() {
519        let t = tap(true).await;
520        let mut headers = HeaderMap::new();
521        headers.insert("X-Trace", "abc".parse().unwrap());
522        let rejected = t.validate_request("GET", "/users/ok", None, &headers, None).await;
523        // /users/ok matches no route? It DOES match /users/{id}; body absent
524        // and all params satisfied -> no rejection.
525        assert!(rejected.is_none());
526    }
527
528    #[tokio::test]
529    async fn unmatched_paths_are_skipped_silently() {
530        conformance_violations::clear();
531        let t = tap(false).await;
532        assert!(t.match_template("GET", "/unknown/path/deep").is_none());
533        assert!(t
534            .validate_request("GET", "/unknown/path/deep", None, &HeaderMap::new(), None)
535            .await
536            .is_none());
537        assert!(
538            !conformance_violations::snapshot()
539                .iter()
540                .any(|v| v.path.starts_with("/unknown")),
541            "unmatched paths must not produce violations"
542        );
543    }
544
545    #[test]
546    fn template_matcher_extracts_params() {
547        let params =
548            match_template("/users/42/orders/7", "/users/{u}/orders/{o}").expect("matches");
549        assert_eq!(params.get("u").map(String::as_str), Some("42"));
550        assert_eq!(params.get("o").map(String::as_str), Some("7"));
551        assert!(match_template("/users/42/extra", "/users/{u}").is_none());
552        assert!(match_template("/users", "/users/{u}").is_none());
553    }
554
555    #[tokio::test]
556    async fn undeclared_response_status_records_response_shape() {
557        conformance_violations::clear();
558        let t = tap(false).await;
559
560        // 503 is not declared on GET /users/{id} (only 200) — the proxy
561        // records a response-shape violation observationally.
562        t.validate_response("GET", "/users/abc", 503, None).await;
563
564        let snap = conformance_violations::snapshot();
565        assert!(
566            snap.iter().any(|v| v.category == "response-shape"),
567            "undeclared status should record a response-shape violation"
568        );
569    }
570}