use apimock_config::config::log_config::verbose_config::VerboseConfig;
use apimock_routing::{
ParsedRequest,
util::http::{normalize_url_path, percent_decode_url_path},
};
use console::style;
use http_body_util::{BodyExt, LengthLimitError, Limited};
use hyper::header::ORIGIN;
use hyper::{Version, body::Incoming};
use serde_json::{Value, to_string_pretty};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::http_util::content_type_is_application_json;
use crate::trace::{REDACTED_HEADER_VALUE, TraceConfig};
#[derive(Debug)]
#[non_exhaustive]
pub enum ParsedRequestError {
BodyTooLarge,
Other(String),
}
impl std::fmt::Display for ParsedRequestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BodyTooLarge => write!(f, "request body exceeded the configured size limit"),
Self::Other(reason) => write!(f, "{}", reason),
}
}
}
pub async fn parsed_request_from(
request: hyper::Request<Incoming>,
max_body_bytes: usize,
) -> Result<ParsedRequest, ParsedRequestError> {
let (component_parts, body) = request.into_parts();
let body_bytes = match Limited::new(body, max_body_bytes).collect().await {
Ok(x) => Some(x.to_bytes()),
Err(err) => {
if err.downcast_ref::<LengthLimitError>().is_some() {
return Err(ParsedRequestError::BodyTooLarge);
}
log::warn!("failed to collect request incoming body: {}", err);
None
}
};
let has_body = body_bytes.as_ref().map(|b| !b.is_empty()).unwrap_or(false);
let body_json = if has_body {
let bytes = body_bytes
.as_ref()
.expect("body_bytes presence checked by has_body");
let raw_body_json = serde_json::from_slice::<Option<Value>>(bytes);
let content_type_is_application_json =
content_type_is_application_json(&component_parts.headers);
match (content_type_is_application_json, raw_body_json) {
(Some(true), Err(err)) => {
return Err(ParsedRequestError::Other(format!(
"failed to get json value from request body: {}",
err
)));
}
(Some(true), Ok(v)) => v,
(_, Ok(v)) => {
if matches!(content_type_is_application_json, Some(false)) {
log::warn!("request has body but its content-type is not application/json");
} else if content_type_is_application_json.is_none() {
log::warn!("request has body but doesn't have content-type");
}
v
}
(_, Err(_)) => None,
}
} else {
None
};
let decoded_url_path = percent_decode_url_path(component_parts.uri.path());
let url_path = normalize_url_path(&decoded_url_path, None);
let body_len = has_body.then(|| {
body_bytes
.as_ref()
.expect("body_bytes presence checked by has_body")
.len()
});
Ok(ParsedRequest::new(url_path, component_parts).with_body(body_json, body_len))
}
pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
}
pub(crate) fn capture_in_log_with_trace_config(
request: &ParsedRequest,
verbose: VerboseConfig,
trace_config: &TraceConfig,
) {
log::info!("{}", render_request_log(request, verbose, trace_config));
}
fn render_request_log(
request: &ParsedRequest,
verbose: VerboseConfig,
trace_config: &TraceConfig,
) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let hours = (now / 3600) % 24;
let minutes = (now / 60) % 60;
let seconds = now % 60;
let timestamp = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
let version = match request.component_parts.version {
Version::HTTP_3 => "HTTP/3",
Version::HTTP_2 => "HTTP/2",
Version::HTTP_11 => "HTTP/1.1",
_ => "HTTP/1.0 or earlier, or HTTP/4 or later",
};
let origin = request
.component_parts
.headers
.get(ORIGIN)
.and_then(|v| v.to_str().ok());
let mut printed = format!(
"<- {}\n [{}]",
style(request.url_path.as_str()).yellow(),
request.component_parts.method,
);
if let Some(origin) = origin {
printed.push_str(&format!(" [ORIGIN {}]", origin));
}
printed.push_str(&format!(
" [{}] request received (at {} UTC)",
version, timestamp
));
if verbose.header || verbose.body {
printed.push('\n');
}
if verbose.header {
let headers = request
.component_parts
.headers
.iter()
.map(|(name, value)| {
let rendered = if trace_config.is_redacted_key(name.as_str()) {
REDACTED_HEADER_VALUE
} else {
value.to_str().unwrap_or("<non-utf8>")
};
format!("\n{}: {}", name, rendered)
})
.collect::<String>();
printed.push_str(&format!(
" [request.headers]{}\n",
style(headers).magenta()
));
}
let mut is_verbose_body = false;
if verbose.body {
let query = request.component_parts.uri.query();
if let Some(query) = query {
let redacted_query = trace_config.redact_query_string(query);
printed.push_str(&format!(" [request.query] {}\n", redacted_query));
is_verbose_body = true;
}
if let Some(request_body_json_value) = &request.body_json {
printed.push_str(" [request.body.json]\n");
let redacted_body_json_value = trace_config.redact_json_value(request_body_json_value);
let body_str = match to_string_pretty(&redacted_body_json_value) {
Ok(x) => x,
Err(err) => {
log::warn!(
"failed to prettify JSON: {} ({})",
redacted_body_json_value,
err
);
redacted_body_json_value.to_string()
}
};
let styled_body_str = body_str
.split("\n")
.map(|s| style(s).green().to_string())
.collect::<Vec<String>>()
.join("\n");
printed.push_str(styled_body_str.as_str());
is_verbose_body = true;
}
}
if verbose.header || is_verbose_body {
printed.push('\n');
}
printed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trace::HeaderRedactionMode;
fn request_with_headers(headers: &[(&str, &str)]) -> ParsedRequest {
let mut builder = hyper::Request::builder().method("GET").uri("/");
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let req = builder.body(()).unwrap();
let (component_parts, _) = req.into_parts();
ParsedRequest::new("/".to_owned(), component_parts)
}
const VERBOSE_HEADERS_ONLY: VerboseConfig = VerboseConfig::new(true, false);
#[test]
fn verbose_header_redacts_credential_headers_by_default() {
let request = request_with_headers(&[
("authorization", "Bearer secret-token"),
("cookie", "session=abc123"),
("x-api-key", "sk-live-very-secret"),
("content-type", "application/json"),
]);
let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
assert!(
!rendered.contains("Bearer secret-token"),
"rendered was: {rendered}"
);
assert!(
!rendered.contains("session=abc123"),
"rendered was: {rendered}"
);
assert!(
!rendered.contains("sk-live-very-secret"),
"rendered was: {rendered}"
);
assert!(
rendered.contains("application/json"),
"a non-credential header must survive: {rendered}"
);
}
#[test]
fn verbose_header_redacted_headers_are_marked_not_omitted() {
let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
assert!(rendered.contains("authorization"), "rendered: {rendered}");
assert!(
rendered.contains(REDACTED_HEADER_VALUE),
"rendered: {rendered}"
);
}
#[test]
fn verbose_header_redaction_is_case_insensitive() {
let request = request_with_headers(&[
("Authorization", "Bearer secret-token"),
("COOKIE", "session=abc123"),
]);
let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &TraceConfig::default());
assert!(
!rendered.contains("Bearer secret-token"),
"rendered: {rendered}"
);
assert!(!rendered.contains("session=abc123"), "rendered: {rendered}");
assert!(
rendered.contains(REDACTED_HEADER_VALUE),
"rendered: {rendered}"
);
}
#[test]
fn verbose_header_honours_the_same_trace_config_instance() {
let config = TraceConfig {
header_redaction: HeaderRedactionMode::Allowlist,
header_allowlist: vec!["content-type".into()],
..Default::default()
};
let request = request_with_headers(&[
("content-type", "application/json"),
("x-request-id", "not-a-credential"),
]);
let rendered = render_request_log(&request, VERBOSE_HEADERS_ONLY, &config);
assert!(
rendered.contains("application/json"),
"allowlisted header must survive: {rendered}"
);
assert!(
!rendered.contains("not-a-credential"),
"unlisted header must be redacted under the shared allowlist: {rendered}"
);
}
#[test]
fn capture_in_log_public_two_argument_form_still_compiles_and_runs() {
let request = request_with_headers(&[("authorization", "Bearer secret-token")]);
capture_in_log(&request, VERBOSE_HEADERS_ONLY);
}
const VERBOSE_BODY_ONLY: VerboseConfig = VerboseConfig::new(false, true);
fn request_with_query(query: &str) -> ParsedRequest {
let req = hyper::Request::builder()
.method("GET")
.uri(format!("/search?{query}"))
.body(())
.unwrap();
let (component_parts, _) = req.into_parts();
ParsedRequest::new("/search".to_owned(), component_parts)
}
#[test]
fn verbose_query_string_redacts_a_token_by_default() {
let request = request_with_query("token=secret&page=2");
let rendered = render_request_log(&request, VERBOSE_BODY_ONLY, &TraceConfig::default());
assert!(!rendered.contains("secret"), "rendered: {rendered}");
assert!(
rendered.contains(REDACTED_HEADER_VALUE),
"rendered: {rendered}"
);
assert!(
rendered.contains("page=2"),
"a non-denied parameter must survive: {rendered}"
);
}
#[test]
fn verbose_body_json_redacts_a_secret_by_default() {
let req = hyper::Request::builder()
.method("POST")
.uri("/login")
.body(())
.unwrap();
let (component_parts, _) = req.into_parts();
let request = ParsedRequest::new("/login".to_owned(), component_parts).with_body(
Some(serde_json::json!({"username": "alice", "password": "hunter2"})),
None,
);
let rendered = render_request_log(&request, VERBOSE_BODY_ONLY, &TraceConfig::default());
assert!(!rendered.contains("hunter2"), "rendered: {rendered}");
assert!(rendered.contains("alice"), "rendered: {rendered}");
assert!(
rendered.contains(REDACTED_HEADER_VALUE),
"rendered: {rendered}"
);
}
}