tideway 0.7.25

A batteries-included Rust web framework built on Axum for building SaaS applications quickly
Documentation
use crate::utils::get_env_with_prefix;
use serde::{Deserialize, Serialize};

/// Request logging configuration for Tideway applications
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RequestLoggingConfig {
    /// Whether request logging is enabled
    #[serde(default = "default_enabled")]
    pub enabled: bool,

    /// Include request headers in logs
    #[serde(default = "default_include_headers")]
    pub include_headers: bool,

    /// Include response headers in logs
    #[serde(default)]
    pub include_response_headers: bool,

    /// Include request body preview (first N bytes)
    /// Set to 0 to disable body preview
    #[serde(default = "default_body_preview_size")]
    pub body_preview_size: usize,

    /// Redact sensitive fields in JSON and form body previews.
    #[serde(default = "default_body_preview_redaction")]
    pub body_preview_redaction: bool,

    /// Field names to redact from structured body previews.
    #[serde(default = "default_sensitive_body_fields")]
    pub sensitive_body_fields: Vec<String>,

    /// Path prefixes for which body previews are always disabled.
    #[serde(default)]
    pub excluded_paths: Vec<String>,

    /// Log level for successful requests (2xx)
    #[serde(default = "default_success_level")]
    pub success_level: LogLevel,

    /// Log level for client errors (4xx)
    #[serde(default = "default_client_error_level")]
    pub client_error_level: LogLevel,

    /// Log level for server errors (5xx)
    #[serde(default = "default_server_error_level")]
    pub server_error_level: LogLevel,
}

/// Log level for request logging
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
    /// Trace level
    Trace,
    /// Debug level
    Debug,
    /// Info level
    Info,
    /// Warn level
    Warn,
    /// Error level
    Error,
}

impl Default for RequestLoggingConfig {
    fn default() -> Self {
        Self {
            enabled: default_enabled(),
            include_headers: default_include_headers(),
            include_response_headers: false,
            body_preview_size: default_body_preview_size(),
            body_preview_redaction: default_body_preview_redaction(),
            sensitive_body_fields: default_sensitive_body_fields(),
            excluded_paths: Vec::new(),
            success_level: LogLevel::Info,
            client_error_level: LogLevel::Warn,
            server_error_level: LogLevel::Error,
        }
    }
}

impl RequestLoggingConfig {
    /// Create a new RequestLoggingConfig builder
    pub fn builder() -> RequestLoggingConfigBuilder {
        RequestLoggingConfigBuilder::new()
    }

    /// Load request logging configuration from environment variables
    pub fn from_env() -> Self {
        let mut config = Self::default();

        if let Some(enabled) = get_env_with_prefix("REQUEST_LOGGING_ENABLED") {
            config.enabled = parse_bool_with_default(&enabled, config.enabled);
        }

        if let Some(include_headers) = get_env_with_prefix("REQUEST_LOGGING_INCLUDE_HEADERS") {
            config.include_headers =
                parse_bool_with_default(&include_headers, config.include_headers);
        }

        if let Some(include_response) =
            get_env_with_prefix("REQUEST_LOGGING_INCLUDE_RESPONSE_HEADERS")
        {
            config.include_response_headers =
                parse_bool_with_default(&include_response, config.include_response_headers);
        }

        if let Some(preview_size) = get_env_with_prefix("REQUEST_LOGGING_BODY_PREVIEW_SIZE")
            && let Ok(size) = preview_size.parse::<usize>()
        {
            config.body_preview_size = size.min(MAX_BODY_PREVIEW_SIZE);
        }

        if let Some(redaction) = get_env_with_prefix("REQUEST_LOGGING_BODY_PREVIEW_REDACTION") {
            config.body_preview_redaction =
                parse_bool_with_default(&redaction, config.body_preview_redaction);
        }

        if let Some(fields) = get_env_with_prefix("REQUEST_LOGGING_SENSITIVE_BODY_FIELDS") {
            for field in split_csv(&fields) {
                if !config
                    .sensitive_body_fields
                    .iter()
                    .any(|existing| existing.eq_ignore_ascii_case(&field))
                {
                    config.sensitive_body_fields.push(field);
                }
            }
        }

        if let Some(paths) = get_env_with_prefix("REQUEST_LOGGING_EXCLUDED_PATHS") {
            config.excluded_paths = split_csv(&paths);
        }

        if let Some(level) = get_env_with_prefix("REQUEST_LOGGING_SUCCESS_LEVEL") {
            config.success_level = parse_log_level(&level);
        }

        if let Some(level) = get_env_with_prefix("REQUEST_LOGGING_CLIENT_ERROR_LEVEL") {
            config.client_error_level = parse_log_level(&level);
        }

        if let Some(level) = get_env_with_prefix("REQUEST_LOGGING_SERVER_ERROR_LEVEL") {
            config.server_error_level = parse_log_level(&level);
        }

        config
    }
}

fn parse_bool_with_default(value: &str, default: bool) -> bool {
    value.parse().unwrap_or(default)
}

fn split_csv(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(str::to_string)
        .collect()
}

fn parse_log_level(s: &str) -> LogLevel {
    match s.to_lowercase().as_str() {
        "trace" => LogLevel::Trace,
        "debug" => LogLevel::Debug,
        "info" => LogLevel::Info,
        "warn" => LogLevel::Warn,
        "error" => LogLevel::Error,
        _ => LogLevel::Info,
    }
}

/// Builder for RequestLoggingConfig
#[must_use = "builder does nothing until you call build()"]
pub struct RequestLoggingConfigBuilder {
    config: RequestLoggingConfig,
}

impl RequestLoggingConfigBuilder {
    pub fn new() -> Self {
        Self {
            config: RequestLoggingConfig::default(),
        }
    }

    pub fn enabled(mut self, enabled: bool) -> Self {
        self.config.enabled = enabled;
        self
    }

    pub fn include_headers(mut self, include: bool) -> Self {
        self.config.include_headers = include;
        self
    }

    pub fn include_response_headers(mut self, include: bool) -> Self {
        self.config.include_response_headers = include;
        self
    }

    pub fn body_preview_size(mut self, size: usize) -> Self {
        self.config.body_preview_size = size;
        self
    }

    pub fn body_preview_redaction(mut self, enabled: bool) -> Self {
        self.config.body_preview_redaction = enabled;
        self
    }

    pub fn sensitive_body_fields(mut self, fields: Vec<String>) -> Self {
        self.config.sensitive_body_fields = fields;
        self
    }

    pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
        self.config.excluded_paths.push(path.into());
        self
    }

    pub fn success_level(mut self, level: LogLevel) -> Self {
        self.config.success_level = level;
        self
    }

    pub fn client_error_level(mut self, level: LogLevel) -> Self {
        self.config.client_error_level = level;
        self
    }

    pub fn server_error_level(mut self, level: LogLevel) -> Self {
        self.config.server_error_level = level;
        self
    }

    pub fn build(self) -> RequestLoggingConfig {
        self.config
    }
}

impl Default for RequestLoggingConfigBuilder {
    fn default() -> Self {
        Self::new()
    }
}

fn default_enabled() -> bool {
    true
}

fn default_include_headers() -> bool {
    false
}

fn default_body_preview_size() -> usize {
    0
}

fn default_body_preview_redaction() -> bool {
    true
}

fn default_sensitive_body_fields() -> Vec<String> {
    [
        "password",
        "current_password",
        "new_password",
        "password_confirmation",
        "token",
        "access_token",
        "refresh_token",
        "id_token",
        "secret",
        "api_key",
        "authorization",
        "code",
        "mfa_code",
        "totp_code",
        "card_number",
        "cvc",
    ]
    .into_iter()
    .map(str::to_string)
    .collect()
}

const MAX_BODY_PREVIEW_SIZE: usize = 1024 * 1024; // 1MB hard cap

fn default_success_level() -> LogLevel {
    LogLevel::Info
}

fn default_client_error_level() -> LogLevel {
    LogLevel::Warn
}

fn default_server_error_level() -> LogLevel {
    LogLevel::Error
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = RequestLoggingConfig::default();
        assert!(config.enabled);
        assert!(!config.include_headers);
        assert_eq!(config.body_preview_size, 0);
        assert_eq!(config.success_level, LogLevel::Info);
    }

    #[test]
    fn test_builder() {
        let config = RequestLoggingConfig::builder()
            .include_headers(true)
            .body_preview_size(512)
            .success_level(LogLevel::Debug)
            .build();

        assert!(config.include_headers);
        assert_eq!(config.body_preview_size, 512);
        assert_eq!(config.success_level, LogLevel::Debug);
    }

    #[test]
    fn test_from_env_invalid_bool_falls_back_to_default() {
        unsafe {
            std::env::set_var("TIDEWAY_REQUEST_LOGGING_ENABLED", "yes");
            std::env::set_var("TIDEWAY_REQUEST_LOGGING_INCLUDE_HEADERS", "nope");
        }

        let config = RequestLoggingConfig::from_env();
        assert!(config.enabled);
        assert!(!config.include_headers);

        unsafe {
            std::env::remove_var("TIDEWAY_REQUEST_LOGGING_ENABLED");
            std::env::remove_var("TIDEWAY_REQUEST_LOGGING_INCLUDE_HEADERS");
        }
    }

    #[test]
    fn test_from_env_body_preview_size_clamps_to_maximum() {
        let huge = MAX_BODY_PREVIEW_SIZE.saturating_add(1).to_string();
        unsafe {
            std::env::set_var("TIDEWAY_REQUEST_LOGGING_BODY_PREVIEW_SIZE", &huge);
        }

        let config = RequestLoggingConfig::from_env();
        assert_eq!(config.body_preview_size, MAX_BODY_PREVIEW_SIZE);

        unsafe {
            std::env::remove_var("TIDEWAY_REQUEST_LOGGING_BODY_PREVIEW_SIZE");
        }
    }
}