mockforge_http/middleware/
conn_diagnostics.rs1use axum::{body::Body, extract::ConnectInfo, http::Request, middleware::Next, response::Response};
20use std::net::SocketAddr;
21
22pub fn is_conn_log_enabled() -> bool {
26 std::env::var("MOCKFORGE_HTTP_LOG_CONN")
27 .ok()
28 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
29 .unwrap_or(false)
30}
31
32fn version_str(v: http::Version) -> String {
38 match v {
39 http::Version::HTTP_09 => "HTTP/0.9".to_string(),
40 http::Version::HTTP_10 => "HTTP/1.0".to_string(),
41 http::Version::HTTP_11 => "HTTP/1.1".to_string(),
42 http::Version::HTTP_2 => "HTTP/2.0".to_string(),
43 http::Version::HTTP_3 => "HTTP/3.0".to_string(),
44 other => format!("{:?}", other),
45 }
46}
47
48pub async fn conn_diag_middleware(req: Request<Body>, next: Next) -> Response<Body> {
62 if !is_conn_log_enabled() {
63 return next.run(req).await;
64 }
65
66 let method = req.method().clone();
67 let path = req.uri().path().to_string();
68 let version = req.version();
69 let version_label = version_str(version);
70
71 let req_connection = req
72 .headers()
73 .get(http::header::CONNECTION)
74 .and_then(|v| v.to_str().ok())
75 .unwrap_or("<absent>")
76 .to_string();
77 let req_keep_alive = req
78 .headers()
79 .get("keep-alive")
80 .and_then(|v| v.to_str().ok())
81 .unwrap_or("<absent>")
82 .to_string();
83 let req_host = req
84 .headers()
85 .get(http::header::HOST)
86 .and_then(|v| v.to_str().ok())
87 .unwrap_or("<absent>")
88 .to_string();
89 let req_te = req
90 .headers()
91 .get(http::header::TE)
92 .and_then(|v| v.to_str().ok())
93 .unwrap_or("<absent>")
94 .to_string();
95 let req_upgrade = req
96 .headers()
97 .get(http::header::UPGRADE)
98 .and_then(|v| v.to_str().ok())
99 .unwrap_or("<absent>")
100 .to_string();
101 let peer = req
102 .extensions()
103 .get::<ConnectInfo<SocketAddr>>()
104 .map(|c| c.0.to_string())
105 .unwrap_or_else(|| "<unknown>".to_string());
106
107 let close_decision = match version {
111 http::Version::HTTP_10 => {
112 if req_connection.to_ascii_lowercase().contains("keep-alive") {
113 "keep-alive (HTTP/1.0, explicit Connection: keep-alive)"
114 } else {
115 "close (HTTP/1.0 default — no Connection: keep-alive header)"
116 }
117 }
118 http::Version::HTTP_11 => {
119 if req_connection.to_ascii_lowercase().contains("close") {
120 "close (HTTP/1.1 with Connection: close)"
121 } else {
122 "keep-alive (HTTP/1.1 default — no Connection: close)"
123 }
124 }
125 _ => "n/a (HTTP/2+)",
126 };
127
128 let response = next.run(req).await;
129 let resp_status = response.status().as_u16();
130 let resp_connection = response
131 .headers()
132 .get(http::header::CONNECTION)
133 .and_then(|v| v.to_str().ok())
134 .unwrap_or("<absent>")
135 .to_string();
136 let resp_keep_alive = response
137 .headers()
138 .get("keep-alive")
139 .and_then(|v| v.to_str().ok())
140 .unwrap_or("<absent>")
141 .to_string();
142
143 tracing::info!(
144 target: "mockforge_http::conn_diag",
145 method = %method,
146 path = %path,
147 version = %version_label,
148 req_connection = %req_connection,
149 req_keep_alive = %req_keep_alive,
150 req_host = %req_host,
151 req_te = %req_te,
152 req_upgrade = %req_upgrade,
153 peer = %peer,
154 resp_status = resp_status,
155 resp_connection = %resp_connection,
156 resp_keep_alive = %resp_keep_alive,
157 close_decision = %close_decision,
158 "http_conn_diag",
159 );
160
161 response
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use axum::{routing::get, Router};
168 use http::HeaderValue;
169 use tower::ServiceExt;
170
171 fn isolate_env<F: FnOnce()>(value: Option<&str>, body: F) {
172 let prev = std::env::var("MOCKFORGE_HTTP_LOG_CONN").ok();
175 match value {
176 Some(v) => std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", v),
177 None => std::env::remove_var("MOCKFORGE_HTTP_LOG_CONN"),
178 }
179 body();
180 match prev {
181 Some(p) => std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", p),
182 None => std::env::remove_var("MOCKFORGE_HTTP_LOG_CONN"),
183 }
184 }
185
186 #[test]
187 fn enabled_flag_truthy_values() {
188 isolate_env(Some("1"), || assert!(is_conn_log_enabled()));
189 isolate_env(Some("true"), || assert!(is_conn_log_enabled()));
190 isolate_env(Some("on"), || assert!(is_conn_log_enabled()));
191 isolate_env(Some("yes"), || assert!(is_conn_log_enabled()));
192 isolate_env(Some("0"), || assert!(!is_conn_log_enabled()));
193 isolate_env(None, || assert!(!is_conn_log_enabled()));
194 }
195
196 #[tokio::test]
197 async fn middleware_is_transparent_when_disabled() {
198 isolate_env(None, || {});
199 let app: Router = Router::new()
200 .route("/", get(|| async { "ok" }))
201 .layer(axum::middleware::from_fn(conn_diag_middleware));
202
203 let req = Request::builder().uri("/").body(Body::empty()).unwrap();
204 let res = app.oneshot(req).await.unwrap();
205 assert_eq!(res.status(), 200);
206 }
207
208 #[tokio::test]
209 async fn middleware_passes_through_when_enabled() {
210 let prev = std::env::var("MOCKFORGE_HTTP_LOG_CONN").ok();
213 std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", "1");
214
215 let app: Router = Router::new()
216 .route("/x", get(|| async { "ok" }))
217 .layer(axum::middleware::from_fn(conn_diag_middleware));
218
219 let req = Request::builder()
220 .uri("/x")
221 .header(http::header::CONNECTION, HeaderValue::from_static("keep-alive"))
222 .body(Body::empty())
223 .unwrap();
224 let res = app.oneshot(req).await.unwrap();
225 assert_eq!(res.status(), 200);
226
227 match prev {
228 Some(p) => std::env::set_var("MOCKFORGE_HTTP_LOG_CONN", p),
229 None => std::env::remove_var("MOCKFORGE_HTTP_LOG_CONN"),
230 }
231 }
232
233 #[test]
234 fn version_str_renders_known_versions() {
235 assert_eq!(version_str(http::Version::HTTP_10), "HTTP/1.0");
236 assert_eq!(version_str(http::Version::HTTP_11), "HTTP/1.1");
237 assert_eq!(version_str(http::Version::HTTP_2), "HTTP/2.0");
238 }
239}