Skip to main content

fastapi_output/components/
logging.rs

1//! Request/response logging component.
2//!
3//! Provides colorized HTTP request/response logging with timing information.
4
5use crate::mode::OutputMode;
6use crate::themes::FastApiTheme;
7use std::time::Duration;
8
9const ANSI_RESET: &str = "\x1b[0m";
10const ANSI_BOLD: &str = "\x1b[1m";
11
12/// HTTP methods supported for logging.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum HttpMethod {
15    /// GET request.
16    Get,
17    /// POST request.
18    Post,
19    /// PUT request.
20    Put,
21    /// DELETE request.
22    Delete,
23    /// PATCH request.
24    Patch,
25    /// OPTIONS request.
26    Options,
27    /// HEAD request.
28    Head,
29    /// TRACE request.
30    Trace,
31    /// CONNECT request.
32    Connect,
33}
34
35impl HttpMethod {
36    /// Get the method name as a string.
37    #[must_use]
38    pub const fn as_str(self) -> &'static str {
39        match self {
40            Self::Get => "GET",
41            Self::Post => "POST",
42            Self::Put => "PUT",
43            Self::Delete => "DELETE",
44            Self::Patch => "PATCH",
45            Self::Options => "OPTIONS",
46            Self::Head => "HEAD",
47            Self::Trace => "TRACE",
48            Self::Connect => "CONNECT",
49        }
50    }
51
52    /// Get the color for this method from the theme.
53    fn color(self, theme: &FastApiTheme) -> crate::themes::Color {
54        match self {
55            Self::Get => theme.http_get,
56            Self::Post => theme.http_post,
57            Self::Put => theme.http_put,
58            Self::Delete => theme.http_delete,
59            Self::Patch => theme.http_patch,
60            Self::Options => theme.http_options,
61            Self::Head => theme.http_head,
62            // Fallback for less common methods
63            Self::Trace | Self::Connect => theme.muted,
64        }
65    }
66}
67
68impl std::str::FromStr for HttpMethod {
69    type Err = ();
70
71    fn from_str(s: &str) -> Result<Self, Self::Err> {
72        match s.to_uppercase().as_str() {
73            "GET" => Ok(Self::Get),
74            "POST" => Ok(Self::Post),
75            "PUT" => Ok(Self::Put),
76            "DELETE" => Ok(Self::Delete),
77            "PATCH" => Ok(Self::Patch),
78            "OPTIONS" => Ok(Self::Options),
79            "HEAD" => Ok(Self::Head),
80            "TRACE" => Ok(Self::Trace),
81            "CONNECT" => Ok(Self::Connect),
82            _ => Err(()),
83        }
84    }
85}
86
87/// Response timing information.
88#[derive(Debug, Clone, Copy)]
89pub struct ResponseTiming {
90    /// Total request duration.
91    pub total: Duration,
92}
93
94impl ResponseTiming {
95    /// Create a new timing with the given duration.
96    #[must_use]
97    pub const fn new(total: Duration) -> Self {
98        Self { total }
99    }
100
101    /// Format the timing as a human-readable string.
102    #[must_use]
103    pub fn format(&self) -> String {
104        let micros = self.total.as_micros();
105        if micros < 1000 {
106            format!("{micros}µs")
107        } else if micros < 1_000_000 {
108            let whole = micros / 1000;
109            let frac = (micros % 1000) / 10;
110            format!("{whole}.{frac:02}ms")
111        } else {
112            let whole = micros / 1_000_000;
113            let frac = (micros % 1_000_000) / 10_000;
114            format!("{whole}.{frac:02}s")
115        }
116    }
117}
118
119/// A log entry for request/response logging.
120#[derive(Debug, Clone)]
121pub struct LogEntry {
122    /// HTTP method.
123    pub method: HttpMethod,
124    /// Request path.
125    pub path: String,
126    /// Query string (if any).
127    pub query: Option<String>,
128    /// Response status code.
129    pub status: u16,
130    /// Response timing.
131    pub timing: Option<ResponseTiming>,
132    /// Client IP address.
133    pub client_ip: Option<String>,
134    /// Request ID.
135    pub request_id: Option<String>,
136}
137
138impl LogEntry {
139    /// Create a new log entry.
140    #[must_use]
141    pub fn new(method: HttpMethod, path: impl Into<String>, status: u16) -> Self {
142        Self {
143            method,
144            path: path.into(),
145            query: None,
146            status,
147            timing: None,
148            client_ip: None,
149            request_id: None,
150        }
151    }
152
153    /// Set the query string.
154    #[must_use]
155    pub fn query(mut self, query: impl Into<String>) -> Self {
156        self.query = Some(query.into());
157        self
158    }
159
160    /// Set the response timing.
161    #[must_use]
162    pub fn timing(mut self, timing: ResponseTiming) -> Self {
163        self.timing = Some(timing);
164        self
165    }
166
167    /// Set the client IP.
168    #[must_use]
169    pub fn client_ip(mut self, ip: impl Into<String>) -> Self {
170        self.client_ip = Some(ip.into());
171        self
172    }
173
174    /// Set the request ID.
175    #[must_use]
176    pub fn request_id(mut self, id: impl Into<String>) -> Self {
177        self.request_id = Some(id.into());
178        self
179    }
180}
181
182/// Request/response logger.
183#[derive(Debug, Clone)]
184pub struct RequestLogger {
185    mode: OutputMode,
186    theme: FastApiTheme,
187    /// Show client IP in logs.
188    pub show_client_ip: bool,
189    /// Show request ID in logs.
190    pub show_request_id: bool,
191    /// Show query string in logs.
192    pub show_query: bool,
193}
194
195impl RequestLogger {
196    /// Create a new logger with the specified mode.
197    #[must_use]
198    pub fn new(mode: OutputMode) -> Self {
199        Self {
200            mode,
201            theme: FastApiTheme::default(),
202            show_client_ip: false,
203            show_request_id: false,
204            show_query: true,
205        }
206    }
207
208    /// Set the theme.
209    #[must_use]
210    pub fn theme(mut self, theme: FastApiTheme) -> Self {
211        self.theme = theme;
212        self
213    }
214
215    /// Format a log entry to a string.
216    #[must_use]
217    pub fn format(&self, entry: &LogEntry) -> String {
218        match self.mode {
219            OutputMode::Plain => self.format_plain(entry),
220            OutputMode::Minimal => self.format_minimal(entry),
221            OutputMode::Rich => self.format_rich(entry),
222        }
223    }
224
225    fn format_plain(&self, entry: &LogEntry) -> String {
226        let mut parts = Vec::new();
227
228        // Method
229        parts.push(format!("{:7}", entry.method.as_str()));
230
231        // Path with query
232        let path = if self.show_query {
233            match &entry.query {
234                Some(q) => format!("{}?{}", entry.path, q),
235                None => entry.path.clone(),
236            }
237        } else {
238            entry.path.clone()
239        };
240        parts.push(path);
241
242        // Status
243        parts.push(format!("{}", entry.status));
244
245        // Timing
246        if let Some(timing) = &entry.timing {
247            parts.push(timing.format());
248        }
249
250        // Client IP
251        if self.show_client_ip {
252            if let Some(ip) = &entry.client_ip {
253                parts.push(format!("[{ip}]"));
254            }
255        }
256
257        // Request ID
258        if self.show_request_id {
259            if let Some(id) = &entry.request_id {
260                parts.push(format!("({id})"));
261            }
262        }
263
264        parts.join(" ")
265    }
266
267    fn format_minimal(&self, entry: &LogEntry) -> String {
268        let method_color = entry.method.color(&self.theme).to_ansi_fg();
269        let status_color = self.status_color(entry.status).to_ansi_fg();
270
271        let mut parts = Vec::new();
272
273        // Method with color
274        parts.push(format!(
275            "{method_color}{:7}{ANSI_RESET}",
276            entry.method.as_str()
277        ));
278
279        // Path with query
280        let path = if self.show_query {
281            match &entry.query {
282                Some(q) => format!("{}?{}", entry.path, q),
283                None => entry.path.clone(),
284            }
285        } else {
286            entry.path.clone()
287        };
288        parts.push(path);
289
290        // Status with color
291        parts.push(format!("{status_color}{}{ANSI_RESET}", entry.status));
292
293        // Timing
294        if let Some(timing) = &entry.timing {
295            let muted = self.theme.muted.to_ansi_fg();
296            parts.push(format!("{muted}{}{ANSI_RESET}", timing.format()));
297        }
298
299        parts.join(" ")
300    }
301
302    fn format_rich(&self, entry: &LogEntry) -> String {
303        let status_color = self.status_color(entry.status).to_ansi_fg();
304        let muted = self.theme.muted.to_ansi_fg();
305
306        let mut parts = Vec::new();
307
308        // Method badge
309        let method_bg = entry.method.color(&self.theme).to_ansi_bg();
310        parts.push(format!(
311            "{method_bg}{ANSI_BOLD} {:7} {ANSI_RESET}",
312            entry.method.as_str()
313        ));
314
315        // Path with query highlighting
316        if self.show_query {
317            match &entry.query {
318                Some(q) => {
319                    let accent = self.theme.accent.to_ansi_fg();
320                    parts.push(format!("{}{accent}?{q}{ANSI_RESET}", entry.path));
321                }
322                None => parts.push(entry.path.clone()),
323            }
324        } else {
325            parts.push(entry.path.clone());
326        }
327
328        // Status code with icon
329        let status_icon = Self::status_icon(entry.status);
330        parts.push(format!(
331            "{status_color}{status_icon} {}{ANSI_RESET}",
332            entry.status
333        ));
334
335        // Timing
336        if let Some(timing) = &entry.timing {
337            parts.push(format!("{muted}{}{ANSI_RESET}", timing.format()));
338        }
339
340        // Client IP
341        if self.show_client_ip {
342            if let Some(ip) = &entry.client_ip {
343                parts.push(format!("{muted}[{ip}]{ANSI_RESET}"));
344            }
345        }
346
347        // Request ID
348        if self.show_request_id {
349            if let Some(id) = &entry.request_id {
350                parts.push(format!("{muted}({id}){ANSI_RESET}"));
351            }
352        }
353
354        parts.join(" ")
355    }
356
357    fn status_color(&self, status: u16) -> crate::themes::Color {
358        match status {
359            100..=199 => self.theme.status_1xx,
360            200..=299 => self.theme.status_2xx,
361            300..=399 => self.theme.status_3xx,
362            400..=499 => self.theme.status_4xx,
363            500..=599 => self.theme.status_5xx,
364            _ => self.theme.muted,
365        }
366    }
367
368    fn status_icon(status: u16) -> &'static str {
369        match status {
370            100..=199 => "ℹ",
371            200..=299 => "✓",
372            300..=399 => "→",
373            400..=499 => "⚠",
374            500..=599 => "✗",
375            _ => "?",
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_http_method_as_str() {
386        assert_eq!(HttpMethod::Get.as_str(), "GET");
387        assert_eq!(HttpMethod::Post.as_str(), "POST");
388        assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
389    }
390
391    #[test]
392    fn test_http_method_from_str() {
393        assert_eq!("get".parse::<HttpMethod>().ok(), Some(HttpMethod::Get));
394        assert_eq!("POST".parse::<HttpMethod>().ok(), Some(HttpMethod::Post));
395        assert!("invalid".parse::<HttpMethod>().is_err());
396    }
397
398    #[test]
399    fn test_response_timing_format() {
400        assert_eq!(
401            ResponseTiming::new(Duration::from_micros(500)).format(),
402            "500µs"
403        );
404        assert_eq!(
405            ResponseTiming::new(Duration::from_micros(1500)).format(),
406            "1.50ms"
407        );
408        assert_eq!(
409            ResponseTiming::new(Duration::from_secs(2)).format(),
410            "2.00s"
411        );
412    }
413
414    #[test]
415    fn test_log_entry_builder() {
416        let entry = LogEntry::new(HttpMethod::Get, "/api/users", 200)
417            .query("page=1")
418            .timing(ResponseTiming::new(Duration::from_millis(50)))
419            .client_ip("127.0.0.1")
420            .request_id("req-123");
421
422        assert_eq!(entry.method, HttpMethod::Get);
423        assert_eq!(entry.path, "/api/users");
424        assert_eq!(entry.query, Some("page=1".to_string()));
425        assert_eq!(entry.status, 200);
426    }
427
428    #[test]
429    fn test_logger_plain_format() {
430        let logger = RequestLogger::new(OutputMode::Plain);
431        let entry = LogEntry::new(HttpMethod::Get, "/api/users", 200)
432            .timing(ResponseTiming::new(Duration::from_millis(50)));
433
434        let output = logger.format(&entry);
435
436        assert!(output.contains("GET"));
437        assert!(output.contains("/api/users"));
438        assert!(output.contains("200"));
439        assert!(!output.contains("\x1b[")); // No ANSI codes
440    }
441
442    #[test]
443    fn test_logger_plain_with_query() {
444        let logger = RequestLogger::new(OutputMode::Plain);
445        let entry = LogEntry::new(HttpMethod::Get, "/api/users", 200).query("page=1&limit=10");
446
447        let output = logger.format(&entry);
448
449        assert!(output.contains("/api/users?page=1&limit=10"));
450    }
451
452    #[test]
453    fn test_logger_rich_has_ansi() {
454        let logger = RequestLogger::new(OutputMode::Rich);
455        let entry = LogEntry::new(HttpMethod::Post, "/api/create", 201);
456
457        let output = logger.format(&entry);
458
459        assert!(output.contains("\x1b["));
460    }
461
462    #[test]
463    fn test_logger_with_client_ip() {
464        let mut logger = RequestLogger::new(OutputMode::Plain);
465        logger.show_client_ip = true;
466
467        let entry = LogEntry::new(HttpMethod::Get, "/", 200).client_ip("192.168.1.1");
468
469        let output = logger.format(&entry);
470
471        assert!(output.contains("[192.168.1.1]"));
472    }
473
474    #[test]
475    fn test_logger_with_request_id() {
476        let mut logger = RequestLogger::new(OutputMode::Plain);
477        logger.show_request_id = true;
478
479        let entry = LogEntry::new(HttpMethod::Get, "/", 200).request_id("abc-123");
480
481        let output = logger.format(&entry);
482
483        assert!(output.contains("(abc-123)"));
484    }
485}