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