use hyper::body::Incoming;
use hyper::{Request, Response};
use tracing::{Instrument, info, info_span};
use crate::context::RequestContext;
use crate::response::BoxBody;
use super::{BoxFuture, Middleware, Next};
#[derive(Debug, Clone, Default)]
pub struct RequestLogConfig {
pub log_headers: bool,
pub log_query_params: bool,
pub log_body_size: bool,
pub redacted_headers: Vec<String>,
}
impl RequestLogConfig {
pub fn verbose() -> Self {
Self {
log_headers: true,
log_query_params: true,
log_body_size: true,
redacted_headers: default_redacted_headers(),
}
}
pub fn log_headers(mut self, enabled: bool) -> Self {
self.log_headers = enabled;
self
}
pub fn log_query_params(mut self, enabled: bool) -> Self {
self.log_query_params = enabled;
self
}
pub fn log_body_size(mut self, enabled: bool) -> Self {
self.log_body_size = enabled;
self
}
pub fn redact_header(mut self, name: impl Into<String>) -> Self {
self.redacted_headers.push(name.into());
self
}
}
fn default_redacted_headers() -> Vec<String> {
vec![
"authorization".to_string(),
"proxy-authorization".to_string(),
"cookie".to_string(),
"set-cookie".to_string(),
"x-api-key".to_string(),
]
}
fn format_headers(headers: &hyper::HeaderMap, redacted: &[String]) -> String {
let mut parts: Vec<String> = Vec::new();
for (name, value) in headers.iter() {
let name_lower = name.as_str().to_lowercase();
let val = if redacted.iter().any(|r| r.to_lowercase() == name_lower) {
"[REDACTED]".to_string()
} else {
value.to_str().unwrap_or("[non-utf8]").to_string()
};
parts.push(format!("{}={}", name.as_str(), val));
}
parts.join("; ")
}
#[derive(Debug, Clone)]
pub struct RequestLogMiddleware {
config: RequestLogConfig,
}
impl RequestLogMiddleware {
pub fn new() -> Self {
Self {
config: RequestLogConfig::default(),
}
}
pub fn verbose() -> Self {
Self {
config: RequestLogConfig::verbose(),
}
}
pub fn with_config(config: RequestLogConfig) -> Self {
Self { config }
}
}
impl Default for RequestLogMiddleware {
fn default() -> Self {
Self::new()
}
}
impl Middleware for RequestLogMiddleware {
fn handle<'a>(
&'a self,
req: Request<Incoming>,
ctx: &'a RequestContext,
next: Next<'a>,
) -> BoxFuture<'a, Response<BoxBody>> {
let method = req.method().clone();
let path = req.uri().path().to_string();
let trace_id = ctx.trace_id().to_owned();
let verbose =
self.config.log_headers || self.config.log_query_params || self.config.log_body_size;
let req_headers = if self.config.log_headers {
Some(format_headers(req.headers(), &self.config.redacted_headers))
} else {
None
};
let query = if self.config.log_query_params {
Some(req.uri().query().unwrap_or("").to_string())
} else {
None
};
let req_body_size = if self.config.log_body_size {
req.headers()
.get(hyper::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
};
let span = info_span!(
"request",
method = %method,
path = %path,
trace_id = %trace_id,
);
Box::pin(
async move {
let response = next.run(req).await;
let duration = ctx.elapsed();
let status = response.status().as_u16();
if verbose {
let res_headers = if self.config.log_headers {
Some(format_headers(
response.headers(),
&self.config.redacted_headers,
))
} else {
None
};
let res_body_size = if self.config.log_body_size {
response
.headers()
.get(hyper::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
};
info!(
status = status,
duration_ms = duration.as_millis() as u64,
request_headers = req_headers.as_deref().unwrap_or_default(),
response_headers = res_headers.as_deref().unwrap_or_default(),
query = query.as_deref().unwrap_or_default(),
request_body_size = req_body_size.as_deref().unwrap_or_default(),
response_body_size = res_body_size.as_deref().unwrap_or_default(),
"request completed"
);
} else {
info!(
status = status,
duration_ms = duration.as_millis() as u64,
"request completed"
);
}
response
}
.instrument(span),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_all_flags_off() {
let config = RequestLogConfig::default();
assert!(!config.log_headers);
assert!(!config.log_query_params);
assert!(!config.log_body_size);
assert!(config.redacted_headers.is_empty());
}
#[test]
fn test_verbose_config_all_flags_on() {
let config = RequestLogConfig::verbose();
assert!(config.log_headers);
assert!(config.log_query_params);
assert!(config.log_body_size);
assert_eq!(config.redacted_headers.len(), 5);
assert!(
config
.redacted_headers
.contains(&"authorization".to_string())
);
assert!(
config
.redacted_headers
.contains(&"proxy-authorization".to_string())
);
assert!(config.redacted_headers.contains(&"cookie".to_string()));
assert!(config.redacted_headers.contains(&"set-cookie".to_string()));
assert!(config.redacted_headers.contains(&"x-api-key".to_string()));
}
#[test]
fn test_builder_toggles_individual_flags() {
let config = RequestLogConfig::default()
.log_headers(true)
.log_body_size(true);
assert!(config.log_headers);
assert!(!config.log_query_params);
assert!(config.log_body_size);
}
#[test]
fn test_redact_header_appends() {
let config = RequestLogConfig::verbose().redact_header("x-custom-secret");
assert_eq!(config.redacted_headers.len(), 6);
assert!(
config
.redacted_headers
.contains(&"x-custom-secret".to_string())
);
}
#[test]
fn test_format_headers_redacts_case_insensitive() {
let mut headers = hyper::HeaderMap::new();
headers.insert("authorization", "Bearer secret".parse().unwrap());
headers.insert("content-type", "application/json".parse().unwrap());
let redacted = vec!["Authorization".to_string()];
let formatted = format_headers(&headers, &redacted);
assert!(formatted.contains("authorization=[REDACTED]"));
assert!(formatted.contains("content-type=application/json"));
assert!(!formatted.contains("secret"));
}
#[test]
fn test_format_headers_no_redaction() {
let mut headers = hyper::HeaderMap::new();
headers.insert("content-type", "text/plain".parse().unwrap());
let formatted = format_headers(&headers, &[]);
assert!(formatted.contains("content-type=text/plain"));
}
#[test]
fn test_middleware_new_uses_default_config() {
let mw = RequestLogMiddleware::new();
assert!(!mw.config.log_headers);
}
#[test]
fn test_middleware_verbose_uses_verbose_config() {
let mw = RequestLogMiddleware::verbose();
assert!(mw.config.log_headers);
assert_eq!(mw.config.redacted_headers.len(), 5);
}
#[test]
fn test_middleware_with_config() {
let config = RequestLogConfig::default().log_query_params(true);
let mw = RequestLogMiddleware::with_config(config);
assert!(mw.config.log_query_params);
assert!(!mw.config.log_headers);
}
#[test]
fn test_middleware_default() {
let mw: RequestLogMiddleware = Default::default();
assert!(!mw.config.log_headers);
}
}