Skip to main content

onemoney_protocol/client/
hooks.rs

1//! Hook and logging system for request/response middleware.
2
3use std::str;
4
5/// Type alias for redaction callback function.
6/// Takes the original body and returns a redacted version.
7pub type RedactionCallback = Box<dyn Fn(&str) -> String + Send + Sync>;
8
9/// Hook trait for request/response middleware.
10pub trait Hook: Send + Sync {
11    /// Called before sending a request.
12    fn before_request(&self, method: &str, url: &str, body: Option<&str>);
13
14    /// Called after receiving a response.
15    fn after_response(&self, method: &str, url: &str, status: u16, body: Option<&str>);
16}
17
18/// Logger trait for pluggable logging.
19pub trait Logger: Send + Sync {
20    /// Log a message.
21    fn log(&self, level: LogLevel, message: &str);
22}
23
24/// Log levels.
25#[derive(Debug, Clone, Copy)]
26pub enum LogLevel {
27    Trace,
28    Debug,
29    Info,
30    Warn,
31    Error,
32}
33
34/// Simple console logger implementation.
35pub struct ConsoleLogger;
36
37impl Logger for ConsoleLogger {
38    fn log(&self, level: LogLevel, message: &str) {
39        match level {
40            LogLevel::Trace => {} // Skip trace messages
41            LogLevel::Debug => {} // Skip debug messages
42            LogLevel::Info => println!("[INFO] {}", message),
43            LogLevel::Warn => println!("[WARN] {}", message),
44            LogLevel::Error => println!("[ERROR] {}", message),
45        }
46    }
47}
48
49/// Simple request/response logging hook.
50pub struct LoggingHook {
51    logger: Box<dyn Logger>,
52    redaction_callback: Option<RedactionCallback>,
53}
54
55impl LoggingHook {
56    pub fn new(logger: Box<dyn Logger>) -> Self {
57        Self {
58            logger,
59            redaction_callback: None,
60        }
61    }
62
63    /// Create a new LoggingHook with a redaction callback.
64    pub fn with_redaction(logger: Box<dyn Logger>, redaction_callback: RedactionCallback) -> Self {
65        Self {
66            logger,
67            redaction_callback: Some(redaction_callback),
68        }
69    }
70
71    /// Create a safe preview of the body content for logging.
72    /// Applies redaction if configured, then truncates to 100 characters.
73    fn create_safe_preview(&self, body: &str) -> String {
74        if body.is_empty() {
75            return String::new();
76        }
77
78        // Apply redaction callback if provided
79        let processed_body = if let Some(ref redactor) = self.redaction_callback {
80            redactor(body)
81        } else {
82            body.to_string()
83        };
84
85        // Create safe preview - first 100 characters with ellipsis if truncated
86        // Use character-aware truncation to avoid panics on UTF-8 boundaries
87        if processed_body.chars().count() <= 100 {
88            processed_body
89        } else {
90            format!("{}...", processed_body.chars().take(100).collect::<String>())
91        }
92    }
93}
94
95impl Hook for LoggingHook {
96    fn before_request(&self, method: &str, url: &str, body: Option<&str>) {
97        if let Some(body) = body {
98            let safe_preview = self.create_safe_preview(body);
99            if safe_preview.is_empty() {
100                self.logger
101                    .log(LogLevel::Debug, &format!("-> {} {} with body: <empty>", method, url));
102            } else {
103                self.logger.log(
104                    LogLevel::Debug,
105                    &format!("-> {} {} with body: {}", method, url, safe_preview),
106                );
107            }
108        } else {
109            self.logger.log(LogLevel::Debug, &format!("-> {} {}", method, url));
110        }
111    }
112
113    fn after_response(&self, method: &str, url: &str, status: u16, body: Option<&str>) {
114        if let Some(body) = body {
115            let safe_preview = self.create_safe_preview(body);
116            if safe_preview.is_empty() {
117                self.logger.log(
118                    LogLevel::Debug,
119                    &format!("<- {} {} [{}] body: <empty>", method, url, status),
120                );
121            } else {
122                self.logger.log(
123                    LogLevel::Debug,
124                    &format!("<- {} {} [{}] body: {}", method, url, status, safe_preview),
125                );
126            }
127        } else {
128            self.logger
129                .log(LogLevel::Debug, &format!("<- {} {} [{}]", method, url, status));
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use std::sync::{Arc, Mutex};
137
138    use super::*;
139
140    #[derive(Clone)]
141    struct TestLogger {
142        messages: Arc<Mutex<Vec<(LogLevel, String)>>>,
143    }
144
145    impl TestLogger {
146        fn new() -> Self {
147            Self {
148                messages: Arc::new(Mutex::new(Vec::new())),
149            }
150        }
151
152        fn get_messages(&self) -> Vec<(LogLevel, String)> {
153            self.messages.lock().expect("Failed to lock messages mutex").clone()
154        }
155    }
156
157    impl Logger for TestLogger {
158        fn log(&self, level: LogLevel, message: &str) {
159            self.messages
160                .lock()
161                .expect("Failed to lock messages mutex")
162                .push((level, message.to_string()));
163        }
164    }
165
166    impl Logger for Arc<TestLogger> {
167        fn log(&self, level: LogLevel, message: &str) {
168            self.as_ref().log(level, message);
169        }
170    }
171
172    #[test]
173    fn test_safe_preview_short_body() {
174        let logger = Box::new(TestLogger::new());
175        let hook = LoggingHook::new(logger);
176
177        let short_body = "short message";
178        let preview = hook.create_safe_preview(short_body);
179        assert_eq!(preview, "short message");
180    }
181
182    #[test]
183    fn test_safe_preview_long_body() {
184        let logger = Box::new(TestLogger::new());
185        let hook = LoggingHook::new(logger);
186
187        let long_body = "a".repeat(150);
188        let preview = hook.create_safe_preview(&long_body);
189        assert_eq!(preview.len(), 103); // 100 chars + "..."
190        assert!(preview.ends_with("..."));
191        assert_eq!(&preview[..100], &"a".repeat(100));
192    }
193
194    #[test]
195    fn test_safe_preview_empty_body() {
196        let logger = Box::new(TestLogger::new());
197        let hook = LoggingHook::new(logger);
198
199        let preview = hook.create_safe_preview("");
200        assert_eq!(preview, "");
201    }
202
203    #[test]
204    fn test_redaction_callback() {
205        let logger = Box::new(TestLogger::new());
206        let redactor = Box::new(|body: &str| {
207            body.replace("secret123", "***REDACTED***")
208                .replace("token123", "***REDACTED***")
209                .replace("password", "***REDACTED***")
210                .replace("Authorization", "***REDACTED***")
211        });
212        let hook = LoggingHook::with_redaction(logger, redactor);
213
214        let sensitive_body = r#"{"username": "john", "password": "secret123", "Authorization": "Bearer token123"}"#;
215        let preview = hook.create_safe_preview(sensitive_body);
216
217        assert!(!preview.contains("secret123"));
218        assert!(!preview.contains("token123"));
219        assert!(preview.contains("***REDACTED***"));
220    }
221
222    #[test]
223    fn test_before_request_with_empty_body() {
224        let logger = Arc::new(TestLogger::new());
225        let hook = LoggingHook::new(Box::new(logger.clone()));
226
227        hook.before_request("GET", "https://api.example.com", Some(""));
228
229        let messages = logger.get_messages();
230        assert_eq!(messages.len(), 1);
231        assert!(messages[0].1.contains("<empty>"));
232    }
233
234    #[test]
235    fn test_before_request_with_long_body() {
236        let logger = Arc::new(TestLogger::new());
237        let hook = LoggingHook::new(Box::new(logger.clone()));
238
239        let long_body = "x".repeat(150);
240        hook.before_request("POST", "https://api.example.com", Some(&long_body));
241
242        let messages = logger.get_messages();
243        assert_eq!(messages.len(), 1);
244        assert!(messages[0].1.contains("..."));
245        assert!(messages[0].1.len() < long_body.len() + 50); // Much shorter than original
246    }
247
248    #[test]
249    fn test_after_response_with_redaction() {
250        let logger = Arc::new(TestLogger::new());
251        let redactor = Box::new(|body: &str| {
252            body.replace("0x123456789abcdef", "***REDACTED***")
253                .replace("private_key", "***REDACTED***")
254        });
255        let hook = LoggingHook::with_redaction(Box::new(logger.clone()), redactor);
256
257        let response_body = r#"{"success": true, "private_key": "0x123456789abcdef"}"#;
258        hook.after_response("POST", "https://api.example.com", 200, Some(response_body));
259
260        let messages = logger.get_messages();
261        assert_eq!(messages.len(), 1);
262        assert!(!messages[0].1.contains("0x123456789abcdef"));
263        assert!(messages[0].1.contains("***REDACTED***"));
264    }
265
266    #[test]
267    fn test_safe_preview_with_multibyte_characters() {
268        let logger = Box::new(TestLogger::new());
269        let hook = LoggingHook::new(logger);
270
271        // Test with multi-byte UTF-8 characters
272        let multibyte_body = "Hello world with accents: café résumé naïve".repeat(5); // Creates ~215 characters
273        let preview = hook.create_safe_preview(&multibyte_body);
274
275        // Verify no panic occurred and truncation is correct
276        assert!(preview.ends_with("..."));
277        assert!(preview.chars().count() <= 103); // 100 chars + "..."
278
279        // Verify the preview contains valid UTF-8 and doesn't cut multi-byte chars
280        assert!(preview.is_ascii() || str::from_utf8(preview.as_bytes()).is_ok());
281
282        // Test exactly 100 characters (no truncation needed)
283        let exactly_100_chars = "a".repeat(97) + "xyz"; // 97 + 3 = 100 chars exactly
284        let preview_exact = hook.create_safe_preview(&exactly_100_chars);
285        assert_eq!(preview_exact, exactly_100_chars);
286        assert!(!preview_exact.contains("..."));
287
288        // Test short multi-byte string (no truncation)
289        let short_multibyte = "Hello world!";
290        let preview_short = hook.create_safe_preview(short_multibyte);
291        assert_eq!(preview_short, short_multibyte);
292        assert!(!preview_short.contains("..."));
293    }
294}