1use apimock_config::config::log_config::verbose_config::VerboseConfig;
12use apimock_routing::{
13 ParsedRequest,
14 util::http::{normalize_url_path, percent_decode_url_path},
15};
16use console::style;
17use http_body_util::{BodyExt, LengthLimitError, Limited};
18use hyper::header::ORIGIN;
19use hyper::{Version, body::Incoming};
20use serde_json::{Value, to_string_pretty};
21
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use crate::http_util::content_type_is_application_json;
25use crate::trace::{REDACTED_HEADER_VALUE, TraceConfig};
26
27#[derive(Debug)]
34#[non_exhaustive]
35pub enum ParsedRequestError {
36 BodyTooLarge,
38 Other(String),
41}
42
43impl std::fmt::Display for ParsedRequestError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::BodyTooLarge => write!(f, "request body exceeded the configured size limit"),
47 Self::Other(reason) => write!(f, "{}", reason),
48 }
49 }
50}
51
52pub async fn parsed_request_from(
74 request: hyper::Request<Incoming>,
75 max_body_bytes: usize,
76) -> Result<ParsedRequest, ParsedRequestError> {
77 let (component_parts, body) = request.into_parts();
78
79 let body_bytes = match Limited::new(body, max_body_bytes).collect().await {
80 Ok(x) => Some(x.to_bytes()),
81 Err(err) => {
82 if err.downcast_ref::<LengthLimitError>().is_some() {
83 return Err(ParsedRequestError::BodyTooLarge);
84 }
85 log::warn!("failed to collect request incoming body: {}", err);
86 None
87 }
88 };
89
90 let has_body = body_bytes.as_ref().map(|b| !b.is_empty()).unwrap_or(false);
91
92 let body_json = if has_body {
93 let bytes = body_bytes
94 .as_ref()
95 .expect("body_bytes presence checked by has_body");
96 let raw_body_json = serde_json::from_slice::<Option<Value>>(bytes);
97
98 let content_type_is_application_json =
101 content_type_is_application_json(&component_parts.headers);
102
103 match (content_type_is_application_json, raw_body_json) {
104 (Some(true), Err(err)) => {
106 return Err(ParsedRequestError::Other(format!(
107 "failed to get json value from request body: {}",
108 err
109 )));
110 }
111 (Some(true), Ok(v)) => v,
112 (_, Ok(v)) => {
113 if matches!(content_type_is_application_json, Some(false)) {
114 log::warn!("request has body but its content-type is not application/json");
115 } else if content_type_is_application_json.is_none() {
116 log::warn!("request has body but doesn't have content-type");
117 }
118 v
119 }
120 (_, Err(_)) => None,
121 }
122 } else {
123 None
124 };
125
126 let decoded_url_path = percent_decode_url_path(component_parts.uri.path());
130 let url_path = normalize_url_path(&decoded_url_path, None);
131
132 let body_len = has_body.then(|| {
135 body_bytes
136 .as_ref()
137 .expect("body_bytes presence checked by has_body")
138 .len()
139 });
140
141 Ok(ParsedRequest::new(url_path, component_parts).with_body(body_json, body_len))
142}
143
144pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
157 capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
158}
159
160pub(crate) fn capture_in_log_with_trace_config(
166 request: &ParsedRequest,
167 verbose: VerboseConfig,
168 trace_config: &TraceConfig,
169) {
170 log::info!("{}", render_request_log(request, verbose, trace_config));
171}
172
173fn render_request_log(
178 request: &ParsedRequest,
179 verbose: VerboseConfig,
180 trace_config: &TraceConfig,
181) -> String {
182 let now = SystemTime::now()
183 .duration_since(UNIX_EPOCH)
184 .map(|d| d.as_secs())
185 .unwrap_or_default();
186 let hours = (now / 3600) % 24;
187 let minutes = (now / 60) % 60;
188 let seconds = now % 60;
189 let timestamp = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
190
191 let version = match request.component_parts.version {
192 Version::HTTP_3 => "HTTP/3",
193 Version::HTTP_2 => "HTTP/2",
194 Version::HTTP_11 => "HTTP/1.1",
195 _ => "HTTP/1.0 or earlier, or HTTP/4 or later",
196 };
197
198 let origin = request
199 .component_parts
200 .headers
201 .get(ORIGIN)
202 .and_then(|v| v.to_str().ok());
203
204 let mut printed = format!(
205 "<- {}\n [{}]",
206 style(request.url_path.as_str()).yellow(),
207 request.component_parts.method,
208 );
209 if let Some(origin) = origin {
210 printed.push_str(&format!(" [ORIGIN {}]", origin));
211 }
212 printed.push_str(&format!(
213 " [{}] request received (at {} UTC)",
214 version, timestamp
215 ));
216
217 if verbose.header || verbose.body {
218 printed.push('\n');
219 }
220 if verbose.header {
221 let headers = request
222 .component_parts
223 .headers
224 .iter()
225 .map(|(name, value)| {
226 let rendered = if trace_config.is_redacted_key(name.as_str()) {
227 REDACTED_HEADER_VALUE
228 } else {
229 value.to_str().unwrap_or("<non-utf8>")
230 };
231 format!("\n{}: {}", name, rendered)
232 })
233 .collect::<String>();
234 printed.push_str(&format!(
235 " [request.headers]{}\n",
236 style(headers).magenta()
237 ));
238 }
239
240 let mut is_verbose_body = false;
241 if verbose.body {
242 let query = request.component_parts.uri.query();
249 if let Some(query) = query {
250 let redacted_query = trace_config.redact_query_string(query);
251 printed.push_str(&format!(" [request.query] {}\n", redacted_query));
252 is_verbose_body = true;
253 }
254
255 if let Some(request_body_json_value) = &request.body_json {
256 printed.push_str(" [request.body.json]\n");
257
258 let redacted_body_json_value = trace_config.redact_json_value(request_body_json_value);
259 let body_str = match to_string_pretty(&redacted_body_json_value) {
260 Ok(x) => x,
261 Err(err) => {
262 log::warn!(
263 "failed to prettify JSON: {} ({})",
264 redacted_body_json_value,
265 err
266 );
267 redacted_body_json_value.to_string()
268 }
269 };
270 let styled_body_str = body_str
271 .split("\n")
272 .map(|s| style(s).green().to_string())
273 .collect::<Vec<String>>()
274 .join("\n");
275 printed.push_str(styled_body_str.as_str());
276
277 is_verbose_body = true;
278 }
279 }
280 if verbose.header || is_verbose_body {
281 printed.push('\n');
282 }
283
284 printed
285}
286
287#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::trace::HeaderRedactionMode;
293
294 fn request_with_headers(headers: &[(&str, &str)]) -> ParsedRequest {
296 let mut builder = hyper::Request::builder().method("GET").uri("/");
297 for (name, value) in headers {
298 builder = builder.header(*name, *value);
299 }
300 let req = builder.body(()).unwrap();
301 let (component_parts, _) = req.into_parts();
302 ParsedRequest::new("/".to_owned(), component_parts)
303 }
304
305 const VERBOSE_HEADERS_ONLY: VerboseConfig = VerboseConfig::new(true, false);
306
307 #[test]
312 fn verbose_header_redacts_credential_headers_by_default() {
313 let request = request_with_headers(&[
314 ("authorization", "Bearer secret-token"),
315 ("cookie", "session=abc123"),
316 ("x-api-key", "sk-live-very-secret"),
317 ("content-type", "application/json"),
318 ]);
319 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
320
321 assert!(
322 !rendered.contains("Bearer secret-token"),
323 "rendered was: {rendered}"
324 );
325 assert!(
326 !rendered.contains("session=abc123"),
327 "rendered was: {rendered}"
328 );
329 assert!(
330 !rendered.contains("sk-live-very-secret"),
331 "rendered was: {rendered}"
332 );
333 assert!(
334 rendered.contains("application/json"),
335 "a non-credential header must survive: {rendered}"
336 );
337 }
338
339 #[test]
343 fn verbose_header_redacted_headers_are_marked_not_omitted() {
344 let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
345 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
346
347 assert!(rendered.contains("authorization"), "rendered: {rendered}");
348 assert!(
349 rendered.contains(REDACTED_HEADER_VALUE),
350 "rendered: {rendered}"
351 );
352 }
353
354 #[test]
357 fn verbose_header_redaction_is_case_insensitive() {
358 let request = request_with_headers(&[
359 ("Authorization", "Bearer secret-token"),
360 ("COOKIE", "session=abc123"),
361 ]);
362 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
363
364 assert!(
365 !rendered.contains("Bearer secret-token"),
366 "rendered: {rendered}"
367 );
368 assert!(!rendered.contains("session=abc123"), "rendered: {rendered}");
369 assert!(
370 rendered.contains(REDACTED_HEADER_VALUE),
371 "rendered: {rendered}"
372 );
373 }
374
375 #[test]
379 fn verbose_header_honours_the_same_trace_config_instance() {
380 let config = TraceConfig {
381 header_redaction: HeaderRedactionMode::Allowlist,
382 header_allowlist: vec!["content-type".into()],
383 ..Default::default()
384 };
385 let request = request_with_headers(&[
386 ("content-type", "application/json"),
387 ("x-request-id", "not-a-credential"),
388 ]);
389 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &config);
390
391 assert!(
392 rendered.contains("application/json"),
393 "allowlisted header must survive: {rendered}"
394 );
395 assert!(
396 !rendered.contains("not-a-credential"),
397 "unlisted header must be redacted under the shared allowlist: {rendered}"
398 );
399 }
400
401 #[test]
408 fn capture_in_log_public_two_argument_form_still_compiles_and_runs() {
409 let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
410 capture_in_log(&request, VERBOSE_HEADERS_ONLY);
411 }
412
413 const VERBOSE_BODY_ONLY: VerboseConfig = VerboseConfig::new(false, true);
416
417 fn request_with_query(query: &str) -> ParsedRequest {
419 let req = hyper::Request::builder()
420 .method("GET")
421 .uri(format!("/search?{query}"))
422 .body(())
423 .unwrap();
424 let (component_parts, _) = req.into_parts();
425 ParsedRequest::new("/search".to_owned(), component_parts)
426 }
427
428 #[test]
434 fn verbose_query_string_redacts_a_token_by_default() {
435 let request = request_with_query("token=secret&page=2");
436 let rendered = render_request_log(&request, VERBOSE_BODY_ONLY, &TraceConfig::default());
437
438 assert!(!rendered.contains("secret"), "rendered: {rendered}");
439 assert!(
440 rendered.contains(REDACTED_HEADER_VALUE),
441 "rendered: {rendered}"
442 );
443 assert!(
444 rendered.contains("page=2"),
445 "a non-denied parameter must survive: {rendered}"
446 );
447 }
448
449 #[test]
452 fn verbose_body_json_redacts_a_secret_by_default() {
453 let req = hyper::Request::builder()
454 .method("POST")
455 .uri("/login")
456 .body(())
457 .unwrap();
458 let (component_parts, _) = req.into_parts();
459 let request = ParsedRequest::new("/login".to_owned(), component_parts).with_body(
460 Some(serde_json::json!({"username": "alice", "password": "hunter2"})),
461 None,
462 );
463 let rendered = render_request_log(&request, VERBOSE_BODY_ONLY, &TraceConfig::default());
464
465 assert!(!rendered.contains("hunter2"), "rendered: {rendered}");
466 assert!(rendered.contains("alice"), "rendered: {rendered}");
467 assert!(
468 rendered.contains(REDACTED_HEADER_VALUE),
469 "rendered: {rendered}"
470 );
471 }
472}