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
14use std::collections::HashMap;
15
16use axum::http::HeaderMap;
17use serde_json::Map as JsonMap;
18use serde_json::Value;
19
20use mockforge_openapi::openapi_routes::OpenApiRouteRegistry;
21use mockforge_openapi::schema_ref_resolver::merge_components_into;
22use mockforge_openapi::spec::OpenApiSpec;
23
24/// A passive conformance tap over one loaded spec.
25pub struct ConformanceTap {
26    // NOTE: no Debug derive — OpenApiRouteRegistry isn't Debug; ProxyServer
27    // implements Debug manually around it.
28    registry: OpenApiRouteRegistry,
29    strict: bool,
30}
31
32impl ConformanceTap {
33    /// Build a tap from a raw OpenAPI 3.x document (JSON or YAML already
34    /// parsed to a Value).
35    pub fn from_spec_value(spec: Value, strict: bool) -> Result<Self, String> {
36        let openapi = OpenApiSpec::from_json(spec).map_err(|e| format!("invalid spec: {e}"))?;
37        Ok(Self {
38            registry: OpenApiRouteRegistry::new(openapi),
39            strict,
40        })
41    }
42
43    pub fn is_strict(&self) -> bool {
44        self.strict
45    }
46
47    /// Match a concrete request path against spec templates.
48    /// Returns `(template, extracted path params)`.
49    pub fn match_template(
50        &self,
51        method: &str,
52        concrete_path: &str,
53    ) -> Option<(String, HashMap<String, String>)> {
54        // Cheap pre-filter on segment count before per-route matching.
55        self.registry.routes().iter().find_map(|route| {
56            if !route.method.eq_ignore_ascii_case(method) {
57                return None;
58            }
59            match_template(concrete_path, &route.path).map(|params| (route.path.clone(), params))
60        })
61    }
62
63    /// Resolve the matched route object for a concrete path.
64    fn matched_route(
65        &self,
66        method: &str,
67        concrete_path: &str,
68    ) -> Option<(String, &mockforge_openapi::route::OpenApiRoute)> {
69        let (template, _) = self.match_template(method, concrete_path)?;
70        let route = self.registry.get_route(&template, method)?;
71        Some((template, route))
72    }
73
74    /// Validate an inbound request against the matched route.
75    ///
76    /// Violations are recorded into the shared conformance buffer inside
77    /// `run_validation_with_recording_ex`. Returns `Some((status, payload))`
78    /// only in STRICT mode when validation fails, so the caller can reject
79    /// instead of forwarding.
80    pub async fn validate_request(
81        &self,
82        method: &str,
83        concrete_path: &str,
84        query: Option<&str>,
85        headers: &HeaderMap,
86        body: Option<&[u8]>,
87    ) -> Option<(u16, Value)> {
88        let Some((template, path_params)) = self.match_template(method, concrete_path) else {
89            return None; // not in spec — nothing to validate against
90        };
91
92        let mut query_map = JsonMap::new();
93        if let Some(q) = query {
94            for pair in q.split('&') {
95                let mut kv = pair.splitn(2, '=');
96                if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
97                    if let (Ok(k), Ok(v)) = (urlencoding_decode(k), urlencoding_decode(v)) {
98                        query_map.insert(k, Value::String(v));
99                    }
100                }
101            }
102        }
103
104        let mut header_values = JsonMap::new();
105        for (k, v) in headers {
106            if let Ok(vs) = v.to_str() {
107                header_values.insert(k.to_string(), Value::String(vs.to_string()));
108            }
109        }
110        // The validator looks up header params case-sensitively by their
111        // SPEC spelling, while axum lowercases wire names. Re-key every
112        // spec-declared header from the (case-insensitive) HeaderMap so
113        // `X-Trace` in the spec matches `x-trace` on the wire.
114        if let Some((_, route)) = self.matched_route(method, concrete_path) {
115            for p in route.operation.parameters.iter() {
116                if let Some(openapiv3::Parameter::Header { parameter_data, .. }) = match p {
117                    openapiv3::ReferenceOr::Item(param) => Some(param),
118                    _ => None,
119                } {
120                    if !header_values.contains_key(&parameter_data.name) {
121                        if let Some(v) = headers.get(&parameter_data.name) {
122                            if let Ok(vs) = v.to_str() {
123                                header_values.insert(
124                                    parameter_data.name.clone(),
125                                    Value::String(vs.to_string()),
126                                );
127                            }
128                        }
129                    }
130                }
131            }
132        }
133
134        // Path params feed the validator as their own bucket.
135        let path_param_map: JsonMap<String, Value> =
136            path_params.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
137
138        let (body_json, body_present) = parse_body(body);
139
140        match self.registry.run_validation_with_recording_ex(
141            &template,
142            method,
143            &path_param_map,
144            &query_map,
145            &header_values,
146            &JsonMap::new(), // cookies
147            body_json.as_ref(),
148            body_present,
149        ) {
150            Ok(()) => None,
151            Err((status, payload)) => self.strict.then_some((status, payload)),
152        }
153    }
154
155    /// Validate a response received from upstream against the matched
156    /// route's declared responses:
157    /// - undeclared status code (and no default) → `response-shape` violation;
158    /// - declared JSON schema → body validated via `validate_json_value`,
159    ///   errors collapsed into one `response-shape` violation.
160    ///
161    /// Always observational; never mutates the response.
162    pub async fn validate_response(
163        &self,
164        method: &str,
165        concrete_path: &str,
166        status: u16,
167        body: Option<&[u8]>,
168    ) {
169        let Some((template, _)) = self.match_template(method, concrete_path) else {
170            return;
171        };
172        let Some(route) = self.registry.get_route(&template, method) else {
173            return;
174        };
175
176        // 1. Is this status even declared?
177        let responses = &route.operation.responses;
178        let status_declared = responses.responses.keys().any(|code| match code {
179            openapiv3::StatusCode::Code(c) => *c == status,
180            openapiv3::StatusCode::Range(start) => status >= *start && status < *start + 100,
181        });
182        let has_default = responses.default.is_some();
183        if !status_declared && !has_default {
184            record_response_shape(
185                method,
186                concrete_path,
187                status,
188                &format!("response status {status} is not declared for {method} {template}"),
189            );
190            return;
191        }
192
193        // 2. Declared + JSON body → schema check.
194        let Some(schema_value) = declared_json_schema(responses, status) else {
195            return;
196        };
197        let Some(bytes) = body else { return };
198        let Ok(body_json) = serde_json::from_slice::<Value>(bytes) else {
199            return; // non-JSON bodies have no schema contract here
200        };
201
202        let merged = merge_components_into(schema_value.clone(), &self.registry.spec().spec);
203        let result =
204            mockforge_openapi::openapi_routes::validation::validate_json_value(&body_json, &merged);
205        if !result.errors.is_empty() {
206            let detail =
207                result.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
208            record_response_shape(
209                method,
210                concrete_path,
211                status,
212                &format!("response body violates {method} {template} {status} schema: {detail}"),
213            );
214        }
215    }
216}
217
218fn record_response_shape(method: &str, path: &str, status: u16, reason: &str) {
219    use mockforge_foundation::conformance_violations::{self, ServerConformanceViolation};
220    conformance_violations::record(ServerConformanceViolation {
221        timestamp: chrono::Utc::now(),
222        method: method.to_string(),
223        path: path.to_string(),
224        client_ip: "unknown".to_string(),
225        status,
226        reason: reason.to_string(),
227        category: "response-shape".to_string(),
228        occurrences: 1,
229        client_mockforge_version: None,
230        client_sent_at: None,
231        summary: String::new(),
232        categories: vec!["response-shape".to_string()],
233    });
234}
235
236fn declared_json_schema(responses: &openapiv3::Responses, status: u16) -> Option<Value> {
237    use openapiv3::{ReferenceOr, Response, StatusCode};
238    let response_ref: Option<&ReferenceOr<Response>> =
239        responses.responses.iter().find_map(|(code, r)| match code {
240            StatusCode::Code(c) if *c == status => Some(r),
241            StatusCode::Range(start) if status >= *start && status < *start + 100 => Some(r),
242            _ => None,
243        });
244    let response = match response_ref? {
245        ReferenceOr::Item(r) => r,
246        ReferenceOr::Reference { .. } => return None, // external ref: skip
247    };
248    let media = response.content.get("application/json")?;
249    match &media.schema {
250        Some(ReferenceOr::Item(schema)) => Some(serde_json::to_value(schema).ok()?),
251        Some(ReferenceOr::Reference { reference }) => {
252            // Inline local refs stay as $ref objects; the caller merges
253            // components over them.
254            Some(serde_json::json!({ "$ref": reference }))
255        }
256        None => None,
257    }
258}
259
260/// Segment-wise template matcher: `/users/{id}` vs concrete paths.
261/// Returns extracted `{placeholder}` values.
262fn match_template(concrete: &str, template: &str) -> Option<HashMap<String, String>> {
263    let mut params = HashMap::new();
264    let mut c_parts = concrete.split('/');
265    for t in template.split('/') {
266        let c = c_parts.next()?;
267        if t.starts_with('{') && t.ends_with('}') {
268            params.insert(t[1..t.len() - 1].to_string(), c.to_string());
269        } else if t != c {
270            return None;
271        }
272    }
273    if c_parts.next().is_some() {
274        return None; // concrete path longer than template
275    }
276    Some(params)
277}
278
279fn urlencoding_decode(v: &str) -> Result<String, ()> {
280    urlencoding::decode(v).map(|c| c.into_owned()).map_err(|_| ())
281}
282
283fn parse_body(body: Option<&[u8]>) -> (Option<Value>, bool) {
284    match body {
285        None => (None, false),
286        Some([]) => (None, false),
287        Some(b) => (serde_json::from_slice::<Value>(b).ok(), true),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use mockforge_foundation::conformance_violations;
295
296    fn demo_spec() -> Value {
297        serde_json::json!({
298            "openapi": "3.0.0",
299            "info": { "title": "T", "version": "1" },
300            "paths": {
301                "/users/{id}": {
302                    "get": {
303                        "parameters": [
304                            { "name": "id", "in": "path", "required": true,
305                              "schema": { "type": "string" } },
306                            { "name": "X-Trace", "in": "header", "required": true,
307                              "schema": { "type": "string" } }
308                        ],
309                        "responses": {
310                            "200": { "description": "ok",
311                                     "content": { "application/json": {
312                                         "schema": { "type": "object",
313                                             "required": ["id"],
314                                             "properties": { "id": { "type": "string" } } } } } }
315                        }
316                    }
317                },
318                "/users": {
319                    "post": {
320                        "requestBody": {
321                            "required": true,
322                            "content": { "application/json": {
323                                "schema": { "type": "object",
324                                    "required": ["email"],
325                                    "properties": { "email": { "type": "string" } } } } }
326                        },
327                        "responses": { "200": { "description": "ok" } }
328                    }
329                }
330            }
331        })
332    }
333
334    async fn tap(strict: bool) -> ConformanceTap {
335        ConformanceTap::from_spec_value(demo_spec(), strict).expect("spec builds")
336    }
337
338    #[tokio::test]
339    async fn missing_required_header_records_query_side_violation() {
340        conformance_violations::clear();
341        let t = tap(false).await;
342
343        // X-Trace header required by spec but absent.
344        let rejected = t.validate_request("GET", "/users/abc", None, &HeaderMap::new(), None).await;
345        assert!(rejected.is_none(), "observational mode must not reject");
346
347        let snap = conformance_violations::snapshot();
348        assert!(
349            snap.iter().any(|v| v.path == "/users/{id}" && !v.reason.is_empty()),
350            "violation should land in the shared buffer, got {:?}",
351            snap
352        );
353    }
354
355    #[tokio::test]
356    async fn strict_mode_returns_rejection_payload() {
357        let t = tap(true).await;
358        let mut headers = HeaderMap::new();
359        headers.insert("X-Trace", "abc".parse().unwrap());
360        let rejected = t.validate_request("GET", "/users/ok", None, &headers, None).await;
361        // /users/ok matches no route? It DOES match /users/{id}; body absent
362        // and all params satisfied -> no rejection.
363        assert!(rejected.is_none());
364    }
365
366    #[tokio::test]
367    async fn unmatched_paths_are_skipped_silently() {
368        conformance_violations::clear();
369        let t = tap(false).await;
370        assert!(t.match_template("GET", "/unknown/path/deep").is_none());
371        assert!(t
372            .validate_request("GET", "/unknown/path/deep", None, &HeaderMap::new(), None)
373            .await
374            .is_none());
375        assert!(
376            !conformance_violations::snapshot()
377                .iter()
378                .any(|v| v.path.starts_with("/unknown")),
379            "unmatched paths must not produce violations"
380        );
381    }
382
383    #[test]
384    fn template_matcher_extracts_params() {
385        let params =
386            match_template("/users/42/orders/7", "/users/{u}/orders/{o}").expect("matches");
387        assert_eq!(params.get("u").map(String::as_str), Some("42"));
388        assert_eq!(params.get("o").map(String::as_str), Some("7"));
389        assert!(match_template("/users/42/extra", "/users/{u}").is_none());
390        assert!(match_template("/users", "/users/{u}").is_none());
391    }
392
393    #[tokio::test]
394    async fn undeclared_response_status_records_response_shape() {
395        conformance_violations::clear();
396        let t = tap(false).await;
397
398        // 503 is not declared on GET /users/{id} (only 200) — the proxy
399        // records a response-shape violation observationally.
400        t.validate_response("GET", "/users/abc", 503, None).await;
401
402        let snap = conformance_violations::snapshot();
403        assert!(
404            snap.iter().any(|v| v.category == "response-shape"),
405            "undeclared status should record a response-shape violation"
406        );
407    }
408}