mockforge_http/
request_logging.rs

1//! HTTP request logging middleware
2
3use axum::{
4    extract::{ConnectInfo, MatchedPath, Request},
5    http::HeaderMap,
6    middleware::Next,
7    response::Response,
8};
9use mockforge_core::{
10    create_http_log_entry_with_query, log_request_global,
11    reality_continuum::response_trace::ResponseGenerationTrace,
12    request_logger::RealityTraceMetadata,
13};
14use std::collections::HashMap;
15use std::net::SocketAddr;
16use std::time::Instant;
17use tracing::info;
18
19/// HTTP request logging middleware
20pub async fn log_http_requests(
21    ConnectInfo(addr): ConnectInfo<SocketAddr>,
22    matched_path: Option<MatchedPath>,
23    req: Request,
24    next: Next,
25) -> Response {
26    let start_time = Instant::now();
27    let method = req.method().to_string();
28    let uri = req.uri().to_string();
29    let path = matched_path
30        .map(|mp| mp.as_str().to_string())
31        .unwrap_or_else(|| uri.split('?').next().unwrap_or(&uri).to_string());
32
33    // Extract query parameters from URI
34    let query_params: HashMap<String, String> = req
35        .uri()
36        .query()
37        .map(|q| url::form_urlencoded::parse(q.as_bytes()).into_owned().collect())
38        .unwrap_or_default();
39
40    // Extract headers (filter sensitive ones)
41    let headers = extract_safe_headers(req.headers());
42
43    // Extract user agent
44    let user_agent = req
45        .headers()
46        .get("user-agent")
47        .and_then(|h| h.to_str().ok())
48        .map(|s| s.to_string());
49
50    // Extract reality metadata from request extensions (set by consistency middleware)
51    // Must be done before calling next.run() which consumes the request
52    let reality_metadata = req.extensions().get::<RealityTraceMetadata>().cloned();
53
54    // Call the next middleware/handler
55    let response = next.run(req).await;
56
57    // Calculate response time
58    let response_time_ms = start_time.elapsed().as_millis() as u64;
59    let status_code = response.status().as_u16();
60
61    // Estimate response size (not perfect but good enough)
62    let response_size_bytes = response
63        .headers()
64        .get("content-length")
65        .and_then(|h| h.to_str().ok())
66        .and_then(|s| s.parse::<u64>().ok())
67        .unwrap_or(0);
68
69    // Determine if this is an error
70    let error_message = if status_code >= 400 {
71        Some(format!(
72            "HTTP {} {}",
73            status_code,
74            response.status().canonical_reason().unwrap_or("Unknown")
75        ))
76    } else {
77        None
78    };
79
80    // Log the request with query parameters
81    let mut log_entry = create_http_log_entry_with_query(
82        &method,
83        &path,
84        status_code,
85        response_time_ms,
86        Some(addr.ip().to_string()),
87        user_agent,
88        headers,
89        query_params.clone(),
90        response_size_bytes,
91        error_message,
92    );
93
94    // Attach reality metadata if available
95    log_entry.reality_metadata = reality_metadata;
96
97    // Extract response generation trace from response extensions (set by handler)
98    if let Some(trace) = response.extensions().get::<ResponseGenerationTrace>() {
99        // Serialize trace to JSON string and store in metadata
100        if let Ok(trace_json) = serde_json::to_string(trace) {
101            log_entry.metadata.insert("response_generation_trace".to_string(), trace_json);
102        }
103    }
104
105    // Log to centralized logger
106    log_request_global(log_entry).await;
107
108    // Also log to console for debugging (include query params if present)
109    if !query_params.is_empty() {
110        let query_params_clone = query_params.clone();
111        info!(
112            method = %method,
113            path = %path,
114            query = ?query_params_clone,
115            status = status_code,
116            duration_ms = response_time_ms,
117            client_ip = %addr.ip(),
118            "HTTP request processed"
119        );
120    } else {
121        info!(
122            method = %method,
123            path = %path,
124            status = status_code,
125            duration_ms = response_time_ms,
126            client_ip = %addr.ip(),
127            "HTTP request processed"
128        );
129    }
130
131    response
132}
133
134/// Extract safe headers (exclude sensitive ones)
135fn extract_safe_headers(headers: &HeaderMap) -> HashMap<String, String> {
136    let mut safe_headers = HashMap::new();
137
138    // List of safe headers to include
139    let safe_header_names = [
140        "accept",
141        "accept-encoding",
142        "accept-language",
143        "cache-control",
144        "content-type",
145        "content-length",
146        "user-agent",
147        "referer",
148        "host",
149        "x-forwarded-for",
150        "x-real-ip",
151    ];
152
153    for name in safe_header_names {
154        if let Some(value) = headers.get(name) {
155            if let Ok(value_str) = value.to_str() {
156                safe_headers.insert(name.to_string(), value_str.to_string());
157            }
158        }
159    }
160
161    safe_headers
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use axum::http::HeaderValue;
168
169    #[test]
170    fn test_extract_safe_headers_empty() {
171        let headers = HeaderMap::new();
172        let safe_headers = extract_safe_headers(&headers);
173        assert_eq!(safe_headers.len(), 0);
174    }
175
176    #[test]
177    fn test_extract_safe_headers_with_safe_headers() {
178        let mut headers = HeaderMap::new();
179        headers.insert("content-type", HeaderValue::from_static("application/json"));
180        headers.insert("user-agent", HeaderValue::from_static("test-agent"));
181        headers.insert("accept", HeaderValue::from_static("application/json"));
182
183        let safe_headers = extract_safe_headers(&headers);
184
185        assert_eq!(safe_headers.len(), 3);
186        assert_eq!(safe_headers.get("content-type"), Some(&"application/json".to_string()));
187        assert_eq!(safe_headers.get("user-agent"), Some(&"test-agent".to_string()));
188        assert_eq!(safe_headers.get("accept"), Some(&"application/json".to_string()));
189    }
190
191    #[test]
192    fn test_extract_safe_headers_excludes_sensitive_headers() {
193        let mut headers = HeaderMap::new();
194        headers.insert("content-type", HeaderValue::from_static("application/json"));
195        headers.insert("authorization", HeaderValue::from_static("Bearer token123"));
196        headers.insert("cookie", HeaderValue::from_static("session=abc123"));
197        headers.insert("x-api-key", HeaderValue::from_static("secret-key"));
198
199        let safe_headers = extract_safe_headers(&headers);
200
201        // Should only include content-type
202        assert_eq!(safe_headers.len(), 1);
203        assert_eq!(safe_headers.get("content-type"), Some(&"application/json".to_string()));
204
205        // Should not include sensitive headers
206        assert!(!safe_headers.contains_key("authorization"));
207        assert!(!safe_headers.contains_key("cookie"));
208        assert!(!safe_headers.contains_key("x-api-key"));
209    }
210
211    #[test]
212    fn test_extract_safe_headers_all_safe_header_types() {
213        let mut headers = HeaderMap::new();
214
215        // Add all safe headers
216        headers.insert("accept", HeaderValue::from_static("application/json"));
217        headers.insert("accept-encoding", HeaderValue::from_static("gzip, deflate"));
218        headers.insert("accept-language", HeaderValue::from_static("en-US"));
219        headers.insert("cache-control", HeaderValue::from_static("no-cache"));
220        headers.insert("content-type", HeaderValue::from_static("application/json"));
221        headers.insert("content-length", HeaderValue::from_static("123"));
222        headers.insert("user-agent", HeaderValue::from_static("Mozilla/5.0"));
223        headers.insert("referer", HeaderValue::from_static("https://example.com"));
224        headers.insert("host", HeaderValue::from_static("api.example.com"));
225        headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1"));
226        headers.insert("x-real-ip", HeaderValue::from_static("192.168.1.2"));
227
228        let safe_headers = extract_safe_headers(&headers);
229
230        assert_eq!(safe_headers.len(), 11);
231        assert_eq!(safe_headers.get("accept"), Some(&"application/json".to_string()));
232        assert_eq!(safe_headers.get("accept-encoding"), Some(&"gzip, deflate".to_string()));
233        assert_eq!(safe_headers.get("accept-language"), Some(&"en-US".to_string()));
234        assert_eq!(safe_headers.get("cache-control"), Some(&"no-cache".to_string()));
235        assert_eq!(safe_headers.get("content-type"), Some(&"application/json".to_string()));
236        assert_eq!(safe_headers.get("content-length"), Some(&"123".to_string()));
237        assert_eq!(safe_headers.get("user-agent"), Some(&"Mozilla/5.0".to_string()));
238        assert_eq!(safe_headers.get("referer"), Some(&"https://example.com".to_string()));
239        assert_eq!(safe_headers.get("host"), Some(&"api.example.com".to_string()));
240        assert_eq!(safe_headers.get("x-forwarded-for"), Some(&"192.168.1.1".to_string()));
241        assert_eq!(safe_headers.get("x-real-ip"), Some(&"192.168.1.2".to_string()));
242    }
243
244    #[test]
245    fn test_extract_safe_headers_handles_invalid_utf8() {
246        let mut headers = HeaderMap::new();
247        headers.insert("content-type", HeaderValue::from_static("application/json"));
248        // Note: HeaderValue doesn't allow invalid UTF-8, so this test ensures the code handles
249        // the to_str() error gracefully by checking if the header exists but can't be converted
250
251        let safe_headers = extract_safe_headers(&headers);
252        assert!(safe_headers.contains_key("content-type"));
253    }
254
255    #[test]
256    fn test_extract_safe_headers_case_insensitive() {
257        let mut headers = HeaderMap::new();
258        // HeaderMap is case-insensitive, but we insert with lowercase
259        headers.insert("Content-Type", HeaderValue::from_static("application/json"));
260        headers.insert("User-Agent", HeaderValue::from_static("test"));
261
262        let safe_headers = extract_safe_headers(&headers);
263
264        // The function looks for lowercase names, but HeaderMap handles case-insensitivity
265        assert_eq!(safe_headers.len(), 2);
266        assert!(safe_headers.contains_key("content-type"));
267        assert!(safe_headers.contains_key("user-agent"));
268    }
269
270    #[test]
271    fn test_extract_safe_headers_mixed_safe_and_unsafe() {
272        let mut headers = HeaderMap::new();
273        headers.insert("content-type", HeaderValue::from_static("application/json"));
274        headers.insert("authorization", HeaderValue::from_static("Bearer token"));
275        headers.insert("user-agent", HeaderValue::from_static("Mozilla/5.0"));
276        headers.insert("x-api-key", HeaderValue::from_static("secret"));
277        headers.insert("accept", HeaderValue::from_static("*/*"));
278
279        let safe_headers = extract_safe_headers(&headers);
280
281        // Should only include the safe ones
282        assert_eq!(safe_headers.len(), 3);
283        assert!(safe_headers.contains_key("content-type"));
284        assert!(safe_headers.contains_key("user-agent"));
285        assert!(safe_headers.contains_key("accept"));
286
287        // Should not include unsafe ones
288        assert!(!safe_headers.contains_key("authorization"));
289        assert!(!safe_headers.contains_key("x-api-key"));
290    }
291}