Skip to main content

mockforge_http/
contract_diff_middleware.rs

1//! Pillars: [Contracts]
2//!
3//! Contract diff middleware for capturing requests
4//!
5//! This middleware captures incoming HTTP requests for contract diff analysis.
6//! It extracts request data and stores it in the capture manager.
7
8use axum::http::header::CONTENT_LENGTH;
9use axum::http::HeaderMap;
10use axum::{body::Body, extract::Request, middleware::Next, response::Response};
11use mockforge_core::{
12    ai_contract_diff::CapturedRequest, request_capture::get_global_capture_manager,
13};
14use std::collections::HashMap;
15use tracing::debug;
16
17/// Maximum request body size to buffer for capture.
18///
19/// Issue #79 — Srikanth reported `200 OK` returned mid-upload on 10 MB
20/// chunked PATCH requests against MockForge. Root cause was right here:
21/// the old buffer limit was 1 MiB and the over-limit branch silently
22/// substituted `Body::empty()` before forwarding, so every downstream
23/// handler (Json/Bytes extractors, the OpenAPI route handler) saw an
24/// empty body and either parsed-error-then-responded or responded with
25/// a default — *before* the client finished uploading. The bumped
26/// default plus the cleaner "skip capture, forward original body"
27/// branch below fix both the limit and the empty-body-substitution.
28fn max_capture_body_size() -> usize {
29    const DEFAULT_MB: usize = 10;
30    std::env::var("MOCKFORGE_CONTRACT_DIFF_MAX_BODY_MB")
31        .ok()
32        .and_then(|v| v.parse::<usize>().ok())
33        .unwrap_or(DEFAULT_MB)
34        .saturating_mul(1024 * 1024)
35}
36
37/// Middleware to capture requests for contract diff analysis
38pub async fn capture_for_contract_diff(req: Request<Body>, next: Next) -> Response {
39    let method = req.method().to_string();
40    let uri = req.uri().clone();
41    let path = uri.path().to_string();
42    let query = uri.query();
43    let max_body = max_capture_body_size();
44
45    // Issue #79 — if the request advertises a Content-Length larger than
46    // the capture cap, skip the capture entirely (and don't consume the
47    // body). This avoids the previous bug where over-limit bodies were
48    // replaced with Body::empty() before being forwarded to the handler.
49    let content_length = req
50        .headers()
51        .get(CONTENT_LENGTH)
52        .and_then(|v| v.to_str().ok())
53        .and_then(|s| s.parse::<usize>().ok());
54    if let Some(len) = content_length {
55        if len > max_body {
56            debug!(
57                "contract_diff: skipping capture for {} {} — content-length {} exceeds cap {}",
58                method, path, len, max_body
59            );
60            return next.run(req).await;
61        }
62    }
63
64    // Extract headers
65    let headers = extract_headers_for_capture(req.headers());
66
67    // Extract query parameters
68    let query_params = if let Some(query) = query {
69        parse_query_params(query)
70    } else {
71        HashMap::new()
72    };
73
74    // Buffer the request body so we can capture it and still forward it.
75    let (parts, body) = req.into_parts();
76    let body_bytes = match axum::body::to_bytes(body, max_body).await {
77        Ok(b) => b,
78        Err(_) => {
79            // Chunked body that exceeded the cap (no Content-Length to
80            // pre-check). The body has been partially consumed and we
81            // can't put it back. The least-bad behaviour is a 413
82            // PayloadTooLarge so the caller knows the request was
83            // rejected — emitting `Body::empty()` here used to silently
84            // truncate the request and cause the handler to respond
85            // before the client finished uploading. Issue #79.
86            return Response::builder()
87                .status(axum::http::StatusCode::PAYLOAD_TOO_LARGE)
88                .header(
89                    axum::http::header::CONTENT_TYPE,
90                    "application/json",
91                )
92                .body(Body::from(format!(
93                    r#"{{"error":"PAYLOAD_TOO_LARGE","message":"chunked request body exceeded contract_diff capture cap (~{} MiB); raise MOCKFORGE_CONTRACT_DIFF_MAX_BODY_MB or send Content-Length"}}"#,
94                    max_body / (1024 * 1024)
95                )))
96                .unwrap_or_else(|_| {
97                    Response::new(Body::from("payload too large"))
98                });
99        }
100    };
101
102    // Try to parse body as JSON for structured capture
103    let captured_body = if !body_bytes.is_empty() {
104        serde_json::from_slice::<serde_json::Value>(&body_bytes).ok()
105    } else {
106        None
107    };
108
109    // Reconstruct the request with the buffered body
110    let rebuilt = Request::from_parts(parts, Body::from(body_bytes));
111
112    // Call the next middleware/handler
113    let response = next.run(rebuilt).await;
114
115    // Extract response status
116    let status_code = response.status().as_u16();
117
118    // Create captured request with body
119    let mut captured = CapturedRequest::new(&method, &path, "proxy_middleware")
120        .with_headers(headers)
121        .with_query_params(query_params)
122        .with_response(status_code, None);
123
124    if let Some(body_value) = captured_body {
125        captured = captured.with_body(body_value);
126    }
127
128    // Capture the request (fire and forget)
129    if let Some(capture_manager) = get_global_capture_manager() {
130        if let Err(e) = capture_manager.capture(captured).await {
131            debug!("Failed to capture request for contract diff: {}", e);
132        }
133    }
134
135    response
136}
137
138/// Extract headers for capture (excluding sensitive ones)
139fn extract_headers_for_capture(headers: &HeaderMap) -> HashMap<String, String> {
140    let mut captured_headers = HashMap::new();
141
142    // Safe headers to capture
143    let safe_headers = [
144        "accept",
145        "accept-encoding",
146        "accept-language",
147        "content-type",
148        "content-length",
149        "user-agent",
150        "referer",
151        "origin",
152        "x-requested-with",
153    ];
154
155    for header_name in safe_headers {
156        if let Some(value) = headers.get(header_name) {
157            if let Ok(value_str) = value.to_str() {
158                captured_headers.insert(header_name.to_string(), value_str.to_string());
159            }
160        }
161    }
162
163    captured_headers
164}
165
166/// Parse query string into HashMap
167fn parse_query_params(query: &str) -> HashMap<String, String> {
168    let mut params = HashMap::new();
169
170    for pair in query.split('&') {
171        if let Some((key, value)) = pair.split_once('=') {
172            let decoded_key = urlencoding::decode(key).unwrap_or_else(|_| key.into());
173            let decoded_value = urlencoding::decode(value).unwrap_or_else(|_| value.into());
174            params.insert(decoded_key.to_string(), decoded_value.to_string());
175        }
176    }
177
178    params
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use axum::http::HeaderValue;
185
186    #[test]
187    fn test_extract_headers_for_capture() {
188        let mut headers = HeaderMap::new();
189        headers.insert("content-type", HeaderValue::from_static("application/json"));
190        headers.insert("authorization", HeaderValue::from_static("Bearer token"));
191        headers.insert("accept", HeaderValue::from_static("application/json"));
192
193        let captured = extract_headers_for_capture(&headers);
194
195        assert_eq!(captured.get("content-type"), Some(&"application/json".to_string()));
196        assert_eq!(captured.get("accept"), Some(&"application/json".to_string()));
197        assert!(!captured.contains_key("authorization")); // Should exclude sensitive headers
198    }
199
200    #[test]
201    fn test_parse_query_params() {
202        let query = "name=John&age=30&city=New%20York";
203        let params = parse_query_params(query);
204
205        assert_eq!(params.get("name"), Some(&"John".to_string()));
206        assert_eq!(params.get("age"), Some(&"30".to_string()));
207        assert_eq!(params.get("city"), Some(&"New York".to_string()));
208    }
209
210    #[test]
211    fn test_parse_query_params_empty() {
212        let params = parse_query_params("");
213        assert!(params.is_empty());
214    }
215}