fastapi_output/components/
logging.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum HttpMethod {
15 Get,
17 Post,
19 Put,
21 Delete,
23 Patch,
25 Options,
27 Head,
29 Trace,
31 Connect,
33}
34
35impl HttpMethod {
36 #[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 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 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#[derive(Debug, Clone, Copy)]
89pub struct ResponseTiming {
90 pub total: Duration,
92}
93
94impl ResponseTiming {
95 #[must_use]
97 pub const fn new(total: Duration) -> Self {
98 Self { total }
99 }
100
101 #[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#[derive(Debug, Clone)]
121pub struct LogEntry {
122 pub method: HttpMethod,
124 pub path: String,
126 pub query: Option<String>,
128 pub status: u16,
130 pub timing: Option<ResponseTiming>,
132 pub client_ip: Option<String>,
134 pub request_id: Option<String>,
136}
137
138impl LogEntry {
139 #[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 #[must_use]
155 pub fn query(mut self, query: impl Into<String>) -> Self {
156 self.query = Some(query.into());
157 self
158 }
159
160 #[must_use]
162 pub fn timing(mut self, timing: ResponseTiming) -> Self {
163 self.timing = Some(timing);
164 self
165 }
166
167 #[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 #[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#[derive(Debug, Clone)]
184pub struct RequestLogger {
185 mode: OutputMode,
186 theme: FastApiTheme,
187 pub show_client_ip: bool,
189 pub show_request_id: bool,
191 pub show_query: bool,
193}
194
195impl RequestLogger {
196 #[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 #[must_use]
210 pub fn theme(mut self, theme: FastApiTheme) -> Self {
211 self.theme = theme;
212 self
213 }
214
215 #[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 parts.push(format!("{:7}", entry.method.as_str()));
230
231 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 parts.push(format!("{}", entry.status));
244
245 if let Some(timing) = &entry.timing {
247 parts.push(timing.format());
248 }
249
250 if self.show_client_ip {
252 if let Some(ip) = &entry.client_ip {
253 parts.push(format!("[{ip}]"));
254 }
255 }
256
257 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 parts.push(format!(
275 "{method_color}{:7}{ANSI_RESET}",
276 entry.method.as_str()
277 ));
278
279 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 parts.push(format!("{status_color}{}{ANSI_RESET}", entry.status));
292
293 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 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 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 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 if let Some(timing) = &entry.timing {
337 parts.push(format!("{muted}{}{ANSI_RESET}", timing.format()));
338 }
339
340 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 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[")); }
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}