1use apimock_config::config::log_config::verbose_config::VerboseConfig;
12use apimock_routing::{ParsedRequest, util::http::normalize_url_path};
13use console::style;
14use http_body_util::{BodyExt, LengthLimitError, Limited};
15use hyper::header::ORIGIN;
16use hyper::{Version, body::Incoming};
17use serde_json::{Value, to_string_pretty};
18
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use crate::http_util::content_type_is_application_json;
22use crate::trace::{REDACTED_HEADER_VALUE, TraceConfig};
23
24#[derive(Debug)]
31#[non_exhaustive]
32pub enum ParsedRequestError {
33 BodyTooLarge,
35 Other(String),
38}
39
40impl std::fmt::Display for ParsedRequestError {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::BodyTooLarge => write!(f, "request body exceeded the configured size limit"),
44 Self::Other(reason) => write!(f, "{}", reason),
45 }
46 }
47}
48
49pub async fn parsed_request_from(
71 request: hyper::Request<Incoming>,
72 max_body_bytes: usize,
73) -> Result<ParsedRequest, ParsedRequestError> {
74 let (component_parts, body) = request.into_parts();
75
76 let body_bytes = match Limited::new(body, max_body_bytes).collect().await {
77 Ok(x) => Some(x.to_bytes()),
78 Err(err) => {
79 if err.downcast_ref::<LengthLimitError>().is_some() {
80 return Err(ParsedRequestError::BodyTooLarge);
81 }
82 log::warn!("failed to collect request incoming body: {}", err);
83 None
84 }
85 };
86
87 let has_body = body_bytes.as_ref().map(|b| !b.is_empty()).unwrap_or(false);
88
89 let body_json = if has_body {
90 let bytes = body_bytes
91 .as_ref()
92 .expect("body_bytes presence checked by has_body");
93 let raw_body_json = serde_json::from_slice::<Option<Value>>(bytes);
94
95 let content_type_is_application_json =
98 content_type_is_application_json(&component_parts.headers);
99
100 match (content_type_is_application_json, raw_body_json) {
101 (Some(true), Err(err)) => {
103 return Err(ParsedRequestError::Other(format!(
104 "failed to get json value from request body: {}",
105 err
106 )));
107 }
108 (Some(true), Ok(v)) => v,
109 (_, Ok(v)) => {
110 if matches!(content_type_is_application_json, Some(false)) {
111 log::warn!("request has body but its content-type is not application/json");
112 } else if content_type_is_application_json.is_none() {
113 log::warn!("request has body but doesn't have content-type");
114 }
115 v
116 }
117 (_, Err(_)) => None,
118 }
119 } else {
120 None
121 };
122
123 let url_path = normalize_url_path(component_parts.uri.path(), None);
124
125 let body_len = has_body.then(|| {
128 body_bytes
129 .as_ref()
130 .expect("body_bytes presence checked by has_body")
131 .len()
132 });
133
134 Ok(ParsedRequest::new(url_path, component_parts).with_body(body_json, body_len))
135}
136
137pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
150 capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
151}
152
153pub(crate) fn capture_in_log_with_trace_config(
159 request: &ParsedRequest,
160 verbose: VerboseConfig,
161 trace_config: &TraceConfig,
162) {
163 log::info!("{}", render_request_log(request, verbose, trace_config));
164}
165
166fn render_request_log(
171 request: &ParsedRequest,
172 verbose: VerboseConfig,
173 trace_config: &TraceConfig,
174) -> String {
175 let now = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .map(|d| d.as_secs())
178 .unwrap_or_default();
179 let hours = (now / 3600) % 24;
180 let minutes = (now / 60) % 60;
181 let seconds = now % 60;
182 let timestamp = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
183
184 let version = match request.component_parts.version {
185 Version::HTTP_3 => "HTTP/3",
186 Version::HTTP_2 => "HTTP/2",
187 Version::HTTP_11 => "HTTP/1.1",
188 _ => "HTTP/1.0 or earlier, or HTTP/4 or later",
189 };
190
191 let origin = request
192 .component_parts
193 .headers
194 .get(ORIGIN)
195 .and_then(|v| v.to_str().ok());
196
197 let mut printed = format!(
198 "<- {}\n [{}]",
199 style(request.url_path.as_str()).yellow(),
200 request.component_parts.method,
201 );
202 if let Some(origin) = origin {
203 printed.push_str(&format!(" [ORIGIN {}]", origin));
204 }
205 printed.push_str(&format!(
206 " [{}] request received (at {} UTC)",
207 version, timestamp
208 ));
209
210 if verbose.header || verbose.body {
211 printed.push('\n');
212 }
213 if verbose.header {
214 let headers = request
215 .component_parts
216 .headers
217 .iter()
218 .map(|(name, value)| {
219 let rendered = if trace_config.is_header_redacted(name.as_str()) {
220 REDACTED_HEADER_VALUE
221 } else {
222 value.to_str().unwrap_or("<non-utf8>")
223 };
224 format!("\n{}: {}", name, rendered)
225 })
226 .collect::<String>();
227 printed.push_str(&format!(
228 " [request.headers]{}\n",
229 style(headers).magenta()
230 ));
231 }
232
233 let mut is_verbose_body = false;
234 if verbose.body {
235 let query = request.component_parts.uri.query();
236 if let Some(query) = query {
237 printed.push_str(&format!(" [request.query] {}\n", query));
238 is_verbose_body = true;
239 }
240
241 if let Some(request_body_json_value) = &request.body_json {
242 printed.push_str(" [request.body.json]\n");
243
244 let body_str = match to_string_pretty(request_body_json_value) {
245 Ok(x) => x,
246 Err(err) => {
247 log::warn!(
248 "failed to prettify JSON: {} ({})",
249 request_body_json_value,
250 err
251 );
252 request_body_json_value.to_string()
253 }
254 };
255 let styled_body_str = body_str
256 .split("\n")
257 .map(|s| style(s).green().to_string())
258 .collect::<Vec<String>>()
259 .join("\n");
260 printed.push_str(styled_body_str.as_str());
261
262 is_verbose_body = true;
263 }
264 }
265 if verbose.header || is_verbose_body {
266 printed.push('\n');
267 }
268
269 printed
270}
271
272#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::trace::HeaderRedactionMode;
278
279 fn request_with_headers(headers: &[(&str, &str)]) -> ParsedRequest {
281 let mut builder = hyper::Request::builder().method("GET").uri("/");
282 for (name, value) in headers {
283 builder = builder.header(*name, *value);
284 }
285 let req = builder.body(()).unwrap();
286 let (component_parts, _) = req.into_parts();
287 ParsedRequest::new("/".to_owned(), component_parts)
288 }
289
290 const VERBOSE_HEADERS_ONLY: VerboseConfig = VerboseConfig::new(true, false);
291
292 #[test]
297 fn verbose_header_redacts_credential_headers_by_default() {
298 let request = request_with_headers(&[
299 ("authorization", "Bearer secret-token"),
300 ("cookie", "session=abc123"),
301 ("x-api-key", "sk-live-very-secret"),
302 ("content-type", "application/json"),
303 ]);
304 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
305
306 assert!(
307 !rendered.contains("Bearer secret-token"),
308 "rendered was: {rendered}"
309 );
310 assert!(
311 !rendered.contains("session=abc123"),
312 "rendered was: {rendered}"
313 );
314 assert!(
315 !rendered.contains("sk-live-very-secret"),
316 "rendered was: {rendered}"
317 );
318 assert!(
319 rendered.contains("application/json"),
320 "a non-credential header must survive: {rendered}"
321 );
322 }
323
324 #[test]
328 fn verbose_header_redacted_headers_are_marked_not_omitted() {
329 let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
330 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
331
332 assert!(rendered.contains("authorization"), "rendered: {rendered}");
333 assert!(
334 rendered.contains(REDACTED_HEADER_VALUE),
335 "rendered: {rendered}"
336 );
337 }
338
339 #[test]
342 fn verbose_header_redaction_is_case_insensitive() {
343 let request = request_with_headers(&[
344 ("Authorization", "Bearer secret-token"),
345 ("COOKIE", "session=abc123"),
346 ]);
347 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
348
349 assert!(
350 !rendered.contains("Bearer secret-token"),
351 "rendered: {rendered}"
352 );
353 assert!(!rendered.contains("session=abc123"), "rendered: {rendered}");
354 assert!(
355 rendered.contains(REDACTED_HEADER_VALUE),
356 "rendered: {rendered}"
357 );
358 }
359
360 #[test]
364 fn verbose_header_honours_the_same_trace_config_instance() {
365 let config = TraceConfig {
366 header_redaction: HeaderRedactionMode::Allowlist,
367 header_allowlist: vec!["content-type".into()],
368 ..Default::default()
369 };
370 let request = request_with_headers(&[
371 ("content-type", "application/json"),
372 ("x-request-id", "not-a-credential"),
373 ]);
374 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &config);
375
376 assert!(
377 rendered.contains("application/json"),
378 "allowlisted header must survive: {rendered}"
379 );
380 assert!(
381 !rendered.contains("not-a-credential"),
382 "unlisted header must be redacted under the shared allowlist: {rendered}"
383 );
384 }
385
386 #[test]
393 fn capture_in_log_public_two_argument_form_still_compiles_and_runs() {
394 let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
395 capture_in_log(&request, VERBOSE_HEADERS_ONLY);
396 }
397}