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;
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
24pub async fn parsed_request_from(
36 request: hyper::Request<Incoming>,
37) -> Result<ParsedRequest, String> {
38 let (component_parts, body) = request.into_parts();
39
40 let body_bytes = match body.boxed().collect().await {
41 Ok(x) => Some(x.to_bytes()),
42 Err(err) => {
43 log::warn!("failed to collect request incoming body: {}", err);
44 None
45 }
46 };
47
48 let has_body = body_bytes.as_ref().map(|b| !b.is_empty()).unwrap_or(false);
49
50 let body_json = if has_body {
51 let bytes = body_bytes
52 .as_ref()
53 .expect("body_bytes presence checked by has_body");
54 let raw_body_json = serde_json::from_slice::<Option<Value>>(bytes);
55
56 match (
57 content_type_is_application_json(&component_parts.headers),
58 raw_body_json,
59 ) {
60 (Some(true), Err(err)) => {
62 return Err(format!(
63 "failed to get json value from request body: {}",
64 err
65 ));
66 }
67 (Some(true), Ok(v)) => v,
68 (_, Ok(v)) => {
69 if matches!(
70 content_type_is_application_json(&component_parts.headers),
71 Some(false)
72 ) {
73 log::warn!("request has body but its content-type is not application/json");
74 } else if content_type_is_application_json(&component_parts.headers).is_none() {
75 log::warn!("request has body but doesn't have content-type");
76 }
77 v
78 }
79 (_, Err(_)) => None,
80 }
81 } else {
82 None
83 };
84
85 let url_path = normalize_url_path(component_parts.uri.path(), None);
86
87 let body_len = has_body.then(|| {
90 body_bytes
91 .as_ref()
92 .expect("body_bytes presence checked by has_body")
93 .len()
94 });
95
96 Ok(ParsedRequest::new(url_path, component_parts).with_body(body_json, body_len))
97}
98
99pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
110 capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
111}
112
113pub(crate) fn capture_in_log_with_trace_config(
119 request: &ParsedRequest,
120 verbose: VerboseConfig,
121 trace_config: &TraceConfig,
122) {
123 log::info!("{}", render_request_log(request, verbose, trace_config));
124}
125
126fn render_request_log(
131 request: &ParsedRequest,
132 verbose: VerboseConfig,
133 trace_config: &TraceConfig,
134) -> String {
135 let now = SystemTime::now()
136 .duration_since(UNIX_EPOCH)
137 .map(|d| d.as_secs())
138 .unwrap_or_default();
139 let hours = (now / 3600) % 24;
140 let minutes = (now / 60) % 60;
141 let seconds = now % 60;
142 let timestamp = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
143
144 let version = match request.component_parts.version {
145 Version::HTTP_3 => "HTTP/3",
146 Version::HTTP_2 => "HTTP/2",
147 Version::HTTP_11 => "HTTP/1.1",
148 _ => "HTTP/1.0 or earlier, or HTTP/4 or later",
149 };
150
151 let origin = request
152 .component_parts
153 .headers
154 .get(ORIGIN)
155 .and_then(|v| v.to_str().ok());
156
157 let mut printed = format!(
158 "<- {}\n [{}]",
159 style(request.url_path.as_str()).yellow(),
160 request.component_parts.method,
161 );
162 if let Some(origin) = origin {
163 printed.push_str(&format!(" [ORIGIN {}]", origin));
164 }
165 printed.push_str(&format!(
166 " [{}] request received (at {} UTC)",
167 version, timestamp
168 ));
169
170 if verbose.header || verbose.body {
171 printed.push('\n');
172 }
173 if verbose.header {
174 let headers = request
175 .component_parts
176 .headers
177 .iter()
178 .map(|(name, value)| {
179 let rendered = if trace_config.is_header_redacted(name.as_str()) {
180 REDACTED_HEADER_VALUE
181 } else {
182 value.to_str().unwrap_or("<non-utf8>")
183 };
184 format!("\n{}: {}", name, rendered)
185 })
186 .collect::<String>();
187 printed.push_str(&format!(
188 " [request.headers]{}\n",
189 style(headers).magenta()
190 ));
191 }
192
193 let mut is_verbose_body = false;
194 if verbose.body {
195 let query = request.component_parts.uri.query();
196 if let Some(query) = query {
197 printed.push_str(&format!(" [request.query] {}\n", query));
198 is_verbose_body = true;
199 }
200
201 if let Some(request_body_json_value) = &request.body_json {
202 printed.push_str(" [request.body.json]\n");
203
204 let body_str = match to_string_pretty(request_body_json_value) {
205 Ok(x) => x,
206 Err(err) => {
207 log::warn!(
208 "failed to prettify JSON: {} ({})",
209 request_body_json_value,
210 err
211 );
212 request_body_json_value.to_string()
213 }
214 };
215 let styled_body_str = body_str
216 .split("\n")
217 .map(|s| style(s).green().to_string())
218 .collect::<Vec<String>>()
219 .join("\n");
220 printed.push_str(styled_body_str.as_str());
221
222 is_verbose_body = true;
223 }
224 }
225 if verbose.header || is_verbose_body {
226 printed.push('\n');
227 }
228
229 printed
230}
231
232#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::trace::HeaderRedactionMode;
238
239 fn request_with_headers(headers: &[(&str, &str)]) -> ParsedRequest {
241 let mut builder = hyper::Request::builder().method("GET").uri("/");
242 for (name, value) in headers {
243 builder = builder.header(*name, *value);
244 }
245 let req = builder.body(()).unwrap();
246 let (component_parts, _) = req.into_parts();
247 ParsedRequest::new("/".to_owned(), component_parts)
248 }
249
250 const VERBOSE_HEADERS_ONLY: VerboseConfig = VerboseConfig::new(true, false);
251
252 #[test]
257 fn verbose_header_redacts_credential_headers_by_default() {
258 let request = request_with_headers(&[
259 ("authorization", "Bearer secret-token"),
260 ("cookie", "session=abc123"),
261 ("x-api-key", "sk-live-very-secret"),
262 ("content-type", "application/json"),
263 ]);
264 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
265
266 assert!(
267 !rendered.contains("Bearer secret-token"),
268 "rendered was: {rendered}"
269 );
270 assert!(
271 !rendered.contains("session=abc123"),
272 "rendered was: {rendered}"
273 );
274 assert!(
275 !rendered.contains("sk-live-very-secret"),
276 "rendered was: {rendered}"
277 );
278 assert!(
279 rendered.contains("application/json"),
280 "a non-credential header must survive: {rendered}"
281 );
282 }
283
284 #[test]
288 fn verbose_header_redacted_headers_are_marked_not_omitted() {
289 let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
290 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
291
292 assert!(rendered.contains("authorization"), "rendered: {rendered}");
293 assert!(
294 rendered.contains(REDACTED_HEADER_VALUE),
295 "rendered: {rendered}"
296 );
297 }
298
299 #[test]
302 fn verbose_header_redaction_is_case_insensitive() {
303 let request = request_with_headers(&[
304 ("Authorization", "Bearer secret-token"),
305 ("COOKIE", "session=abc123"),
306 ]);
307 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
308
309 assert!(
310 !rendered.contains("Bearer secret-token"),
311 "rendered: {rendered}"
312 );
313 assert!(!rendered.contains("session=abc123"), "rendered: {rendered}");
314 assert!(
315 rendered.contains(REDACTED_HEADER_VALUE),
316 "rendered: {rendered}"
317 );
318 }
319
320 #[test]
324 fn verbose_header_honours_the_same_trace_config_instance() {
325 let config = TraceConfig {
326 header_redaction: HeaderRedactionMode::Allowlist,
327 header_allowlist: vec!["content-type".into()],
328 ..Default::default()
329 };
330 let request = request_with_headers(&[
331 ("content-type", "application/json"),
332 ("x-request-id", "not-a-credential"),
333 ]);
334 let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &config);
335
336 assert!(
337 rendered.contains("application/json"),
338 "allowlisted header must survive: {rendered}"
339 );
340 assert!(
341 !rendered.contains("not-a-credential"),
342 "unlisted header must be redacted under the shared allowlist: {rendered}"
343 );
344 }
345
346 #[test]
353 fn capture_in_log_public_two_argument_form_still_compiles_and_runs() {
354 let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
355 capture_in_log(&request, VERBOSE_HEADERS_ONLY);
356 }
357}